File size: 14,730 Bytes
ae73c7f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 | """
Paleobiology Database (PBDB) real-data client.
VERIFICATION STATUS (be precise about what's actually confirmed vs. not β
this matters because paleobiodb.org's robots.txt blocked automated
fetching in the development sandbox, so the exact live JSON field names
below were NOT confirmed against a real response in that environment):
CONFIRMED (via web search of PBDB's own documentation, dev.paleobiodb.org
and multiple independent third-party docs, cross-checked):
- Base endpoint: https://paleobiodb.org/data1.2/occs/list.{json,csv,txt}
- No authentication required; data is CC0.
- `limit`: positive integer, 0, or the literal string "all" (NOT a
fixed default of 500 β that claim in an earlier draft plan was
unverified and is not repeated here).
- `offset`: skips N records at the start of the result set (used for
pagination together with `limit`).
- `base_name`: taxon + all children (e.g. "Dinosauria").
- `interval`: named geologic interval (e.g. "Miocene").
- `lngmin`/`lngmax`/`latmin`/`latmax`: bounding-box filter.
- `show`: comma-separated list of extra output blocks (e.g. "coords",
"classext", "full").
- JSON responses wrap records in a top-level "records" array.
NOT CONFIRMED as of the previous session, NOW PARTIALLY CONFIRMED:
A real live-data example (not just prose docs) surfaced this session
in the `paleobioDB` R package documentation, showing an actual
response row. It confirms:
- Numeric age fields are named `max_ma` / `min_ma`.
CORRECTION: an earlier version of this module assumed
`early_age`/`late_age` β that was never verified and was WRONG.
Fixed below; REQUIRED_FIELDS and occurrences_to_field() now use
max_ma/min_ma.
- `show=classext` returns `phylum`, `class`, `order`, `family`,
`genus` as direct columns β used by data_pbdb_taxonomy.py to
build the taxonomic tree from this SAME verified endpoint,
rather than introducing a separate, unverified `/taxa/list` API.
- `occurrence_no` (confirmed this session via multiple independent
real-example sources: ropensci paleobioDB docs, CRAN README,
GitHub) is the primary-key field present on every occurrence
record β used as data_pbdb_taxonomy.py's TAXONOMY_REQUIRED_FIELDS
instead of any specific taxonomic rank, since ranks are
legitimately sparse per-record on real fossil data.
- PBDB omits keys entirely for empty/null values rather than
including them as null β confirmed via the same sources (a field
can be genuinely present in the dataset but absent from any
individual record, including record 0). _validate_record_schema()
therefore checks a union of keys over a sample of up to 50
records, not just the first one.
Field names beyond these remain unconfirmed for other `show`/`vocab`
combinations. This module still does NOT hardcode a fixed set of
trusted field names and silently proceed if they're missing.
Instead: discover_schema() must be run first, by a human, to see the
ACTUAL fields returned; fetch_occurrences() then validates every
declared REQUIRED_FIELD is present and raises SchemaValidationError
(naming the fields that were actually present) if not. This is the
same "validate the declared contract, never guess" pattern as
LocalWellHDF5 in data_real.py.
Run discovery FIRST, before trusting anything else in this module:
python -m src.data_pbdb --discover --base-name Dinosauria --limit 5
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
import torch
from torch.utils.data import Dataset
from .provenance import DataLoadError, SchemaValidationError
BASE_URL = "https://paleobiodb.org/data1.2/occs/list.json"
USER_AGENT = "well_poincare_rl-research-client/0.3 (contact: set via PBDB_CONTACT env var)"
POLITE_DELAY_SECONDS = 0.5 # spacing between paginated requests; no documented
# rate limit was found, so this is precautionary
# good-citizen behavior, not a confirmed requirement.
MAX_RETRIES = 3
REQUIRED_FIELDS = ("lng", "lat", "max_ma", "min_ma", "genus")
class PBDBFetchError(DataLoadError):
outcome_code = "PBDB_FETCH_FAILED"
def _cache_key(params: Dict[str, Any]) -> str:
blob = json.dumps(params, sort_keys=True).encode()
return hashlib.sha256(blob).hexdigest()[:24]
def discover_schema(base_name: str = "Dinosauria", limit: int = 5,
show: str = "full") -> Dict[str, Any]:
import requests
params = {"base_name": base_name, "limit": limit, "show": show, "vocab": "pbdb"}
resp = requests.get(BASE_URL, params=params,
headers={"User-Agent": USER_AGENT}, timeout=30)
if resp.status_code != 200:
raise PBDBFetchError(
f"discovery request failed: HTTP {resp.status_code} β {resp.text[:300]}",
outcome_code="PBDB_HTTP_ERROR",
)
data = resp.json()
records = data.get("records", [])
if not records:
raise PBDBFetchError(
f"discovery request returned zero records for base_name={base_name!r} "
f"β check the taxon name, or the API contract may have changed.",
outcome_code="PBDB_EMPTY_RESPONSE",
)
observed_fields = sorted(set().union(*(r.keys() for r in records)))
return {
"n_records": len(records),
"observed_fields": observed_fields,
"required_fields_present": [f for f in REQUIRED_FIELDS if f in observed_fields],
"required_fields_missing": [f for f in REQUIRED_FIELDS if f not in observed_fields],
"sample_record": records[0],
}
def _validate_record_schema(records: List[Dict[str, Any]], required_fields: Sequence[str] = REQUIRED_FIELDS) -> None:
if not records:
raise SchemaValidationError(
"fetch returned zero occurrence records", outcome_code="EMPTY_PBDB_RESULT",
)
sample = records[: min(50, len(records))]
observed = set().union(*(r.keys() for r in sample))
missing = [f for f in required_fields if f not in observed]
if missing:
raise SchemaValidationError(
f"PBDB response is missing required field(s) {missing}. "
f"Fields actually present (union of first {len(sample)} records): "
f"{sorted(observed)}. "
f"This means either the request params (show=/vocab=) need "
f"adjusting, or PBDB's schema has changed since REQUIRED_FIELDS "
f"was declared. Run discover_schema() to see the live response "
f"before changing REQUIRED_FIELDS β never guess a fix.",
outcome_code="PBDB_SCHEMA_MISMATCH",
)
def fetch_occurrences(
base_name: str,
interval: Optional[str] = None,
max_records: int = 5000,
page_size: int = 500,
show: str = "full",
cache_dir: Optional[str] = "data/pbdb_cache",
required_fields: Sequence[str] = REQUIRED_FIELDS,
) -> List[Dict[str, Any]]:
import requests
params_base = {"base_name": base_name, "show": show, "vocab": "pbdb"}
if interval:
params_base["interval"] = interval
cache_path = None
if cache_dir:
os.makedirs(cache_dir, exist_ok=True)
cache_key_params = dict(params_base, _required_fields=list(required_fields))
cache_path = Path(cache_dir) / f"{_cache_key(cache_key_params)}_{max_records}.json"
if cache_path.exists():
records = json.loads(cache_path.read_text())
_validate_record_schema(records, required_fields=required_fields)
return records
all_records: List[Dict[str, Any]] = []
offset = 0
while len(all_records) < max_records:
params = dict(params_base)
params["limit"] = min(page_size, max_records - len(all_records))
params["offset"] = offset
last_err = None
for attempt in range(MAX_RETRIES):
try:
resp = requests.get(BASE_URL, params=params,
headers={"User-Agent": USER_AGENT}, timeout=30)
if resp.status_code == 200:
break
last_err = f"HTTP {resp.status_code}: {resp.text[:200]}"
except requests.RequestException as e:
last_err = str(e)
time.sleep(1.5 * (attempt + 1))
else:
raise PBDBFetchError(
f"failed after {MAX_RETRIES} attempts at offset={offset}: {last_err}",
outcome_code="PBDB_HTTP_ERROR",
)
page = resp.json().get("records", [])
if not page:
break # exhausted the result set before hitting max_records
all_records.extend(page)
offset += len(page)
if len(page) < params["limit"]:
break # short page => last page
time.sleep(POLITE_DELAY_SECONDS)
_validate_record_schema(all_records, required_fields=required_fields)
if cache_path:
cache_path.write_text(json.dumps(all_records))
return all_records
def occurrences_to_field(
records: List[Dict[str, Any]],
n_time_bins: int = 12,
height: int = 24,
width: int = 24,
age_min: Optional[float] = None,
age_max: Optional[float] = None,
) -> torch.Tensor:
ages, lats, lngs, genera = [], [], [], []
for r in records:
try:
ea, la = float(r["max_ma"]), float(r["min_ma"])
mid_age = (ea + la) / 2.0
lng, lat = float(r["lng"]), float(r["lat"])
except (KeyError, TypeError, ValueError):
continue # malformed individual record; skipped, not fabricated
ages.append(mid_age)
lats.append(lat)
lngs.append(lng)
genera.append(r.get("genus") or r.get("accepted_name") or "")
if not ages:
raise SchemaValidationError(
"no occurrence records had usable age/coordinate fields after parsing",
outcome_code="NO_USABLE_RECORDS",
)
age_max = age_max if age_max is not None else max(ages)
age_min = age_min if age_min is not None else min(ages)
if age_max <= age_min:
raise SchemaValidationError(
f"degenerate age range [{age_min}, {age_max}] β cannot bin into "
f"{n_time_bins} time steps",
outcome_code="DEGENERATE_AGE_RANGE",
)
field = torch.zeros(n_time_bins, 2, height, width)
for age, lat, lng, genus in zip(ages, lats, lngs, genera):
t = int((age_max - age) / (age_max - age_min) * (n_time_bins - 1e-6))
t = min(max(t, 0), n_time_bins - 1)
h = int((lat + 90.0) / 180.0 * (height - 1e-6))
h = min(max(h, 0), height - 1)
w = int((lng + 180.0) / 360.0 * (width - 1e-6))
w = min(max(w, 0), width - 1)
field[t, 0, h, w] += 1.0
genus_sets = [[[set() for _ in range(width)] for _ in range(height)] for _ in range(n_time_bins)]
for age, lat, lng, genus in zip(ages, lats, lngs, genera):
if not genus:
continue
t = min(max(int((age_max - age) / (age_max - age_min) * (n_time_bins - 1e-6)), 0), n_time_bins - 1)
h = min(max(int((lat + 90.0) / 180.0 * (height - 1e-6)), 0), height - 1)
w = min(max(int((lng + 180.0) / 360.0 * (width - 1e-6)), 0), width - 1)
genus_sets[t][h][w].add(genus)
for t in range(n_time_bins):
for h in range(height):
for w in range(width):
field[t, 1, h, w] = float(len(genus_sets[t][h][w]))
return field
class PBDBFieldDataset(Dataset):
def __init__(
self,
taxon_groups: Sequence[str],
n_time_bins: int = 12,
height: int = 24,
width: int = 24,
max_records_per_group: int = 3000,
show: str = "full",
cache_dir: Optional[str] = "data/pbdb_cache",
):
self.taxon_groups = list(taxon_groups)
self.fields: List[torch.Tensor] = []
self.group_names: List[str] = []
for group in self.taxon_groups:
records = fetch_occurrences(
base_name=group, max_records=max_records_per_group,
show=show, cache_dir=cache_dir,
)
field = occurrences_to_field(records, n_time_bins=n_time_bins, height=height, width=width)
self.fields.append(field)
self.group_names.append(group)
if not self.fields:
raise DataLoadError(
"no taxon groups produced usable fields", outcome_code="NO_PBDB_FIELDS",
)
def __len__(self):
return len(self.fields)
def __getitem__(self, idx):
return {"fields": self.fields[idx], "idx": idx, "taxon_group": self.group_names[idx]}
DEFAULT_TAXON_GROUPS = (
"Trilobita", "Ammonoidea", "Bivalvia", "Dinosauria",
"Mammalia", "Brachiopoda", "Gastropoda", "Crinoidea",
)
def get_pbdb_dataset(
taxon_groups: Sequence[str] = DEFAULT_TAXON_GROUPS,
n_time_bins: int = 12,
height: int = 24,
width: int = 24,
max_records_per_group: int = 3000,
cache_dir: Optional[str] = "data/pbdb_cache",
):
ds = PBDBFieldDataset(
taxon_groups=taxon_groups, n_time_bins=n_time_bins, height=height,
width=width, max_records_per_group=max_records_per_group, cache_dir=cache_dir,
)
print(f"[data] using REAL_PBDB data: {len(ds)} taxon-group trajectories "
f"({', '.join(ds.group_names)})")
return ds, "REAL_PBDB"
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--discover", action="store_true",
help="Fetch a small sample and print the RAW schema for "
"human inspection. Run this first.")
p.add_argument("--base-name", default="Dinosauria")
p.add_argument("--limit", type=int, default=5)
args = p.parse_args()
if args.discover:
result = discover_schema(base_name=args.base_name, limit=args.limit)
print(json.dumps(result, indent=2, default=str))
if result["required_fields_missing"]:
print(f"\nWARNING: REQUIRED_FIELDS {result['required_fields_missing']} "
f"are NOT present in the live response. Update REQUIRED_FIELDS "
f"in src/data_pbdb.py to match reality before running fetch_occurrences().")
else:
print("\nAll REQUIRED_FIELDS confirmed present in the live response.")
else:
print("Pass --discover to inspect the live PBDB schema before trusting this module.")
|