| """ |
| 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 |
| |
| |
| 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 |
| all_records.extend(page) |
| offset += len(page) |
| if len(page) < params["limit"]: |
| break |
| 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 |
| 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.") |
|
|