Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

02 — Data clean (Iberian birds, HEALPix-NESTED ladder, two BoR strategies)

Lifewatch-ERIC, Spain

Bins each of the two BoR-strategy GBIF zips from 01_data_download.py onto a HEALPix NESTED ladder of Nside in {16, 32, 64, 128, 256, 512}, with a year-stage split at the cleaning step:

  • year >= 2000 -> modern frame = “atlas-equivalent”. Per-cell species richness = number of distinct species observed in the cell.

  • year < 2000 -> historical frame = “range-map-equivalent”. Per species, build the convex hull of pre-2000 occurrences (Shapely); for each Nside, the hull’s HEALPix-NESTED cell coverage is computed via healpix_geo.nested.polygon_coverage; per-cell richness = number of species whose hull covers the cell.

Conceptually we relabel modern as atlas and historical as rangemap downstream (they are the H&J 2007 analogues, and the year-window source is now an implementation detail of the substitute).

Two strategies x two sources = four per-cell richness arrays per Nside, per strategy. The output layout:

  • data/clean/richness_<strategy>.nc — one NetCDF per strategy, with one NetCDF group per Nside, each group holding richness_atlas and richness_rangemap variables on the cell dimension. Different per-Nside cell counts prevent a single rectangular array.

  • data/clean/species_eoo_polygons.parquet — per-strategy, per-species convex-hull WKT polygons + record counts. strategy column.

  • data/clean/clean_report.json — per-strategy record / species counts + which strategy is synthetic.

Domain conventions enforced (DOMAIN.md):

  • HEALPix indexing is always NESTED at every Nside; healpix-geo (geographic, WGS84-aware), never healpy.

  • Intermediate arrays use NetCDF + Parquet; never .npz.

  • Per-strategy synthetic flag is honoured — synthetic data is not silently mixed with real data; the report json calls it out and downstream artefacts carry the flag.

import json
import zipfile
from collections.abc import Iterator
from datetime import date
from pathlib import Path

import numpy as np
import pandas as pd
import xarray as xr
from healpix_geo import nested as hp_nested
from shapely.geometry import MultiPoint, Polygon

Constants

  • HEALPix ladder = Nside in {16, 32, 64, 128, 256, 512}, each NESTED. Cell side ~407 km at Nside=16 down to ~13 km at Nside=512 — bracketing H&J’s 0.25°-2° range comfortably.

  • Iberia bbox matches the prior sibling chain (-10..4 lon, 35..44 lat).

  • Ellipsoid = “WGS84”.

STRATEGIES = ["museum", "allbor"]
NSIDES = [16, 32, 64, 128, 256, 512]
DEPTHS = {n: int(np.log2(n)) for n in NSIDES}

ELLIPSOID = "WGS84"
YEAR_SPLIT = 2000  # year >= 2000 -> modern (atlas), year < 2000 -> historical

IBERIA_LON_MIN, IBERIA_LAT_MIN = -10.0, 35.0
IBERIA_LON_MAX, IBERIA_LAT_MAX = 4.0, 44.0

ROOT = Path("..").resolve()
DATA = ROOT / "data"
GBIF_DIR = DATA / "gbif"
RAW_DIR = DATA / "raw"
CLEAN_DIR = DATA / "clean"
CLEAN_DIR.mkdir(parents=True, exist_ok=True)

STRATEGY_ZIPS = {
    "museum": GBIF_DIR / "birds_iberia_museum.zip",
    "allbor": GBIF_DIR / "birds_iberia_allbor.zip",
}
STRATEGY_SYNTH_FLAGS = {
    "museum": RAW_DIR / "USING_SYNTHETIC_DEMO_DATA_museum.txt",
    "allbor": RAW_DIR / "USING_SYNTHETIC_DEMO_DATA_allbor.txt",
}
STRATEGY_RICHNESS_NC = {
    s: CLEAN_DIR / f"richness_{s}.nc" for s in STRATEGIES
}

EOO_PARQUET = CLEAN_DIR / "species_eoo_polygons.parquet"
CLEAN_REPORT = CLEAN_DIR / "clean_report.json"

SYNTHETIC = {s: STRATEGY_SYNTH_FLAGS[s].exists() for s in STRATEGIES}
print(f"ROOT       = {ROOT}")
print(f"STRATEGIES = {STRATEGIES}")
print(f"NSIDES     = {NSIDES}")
print(f"YEAR_SPLIT = {YEAR_SPLIT} (>= modern, < historical)")
print(f"SYNTHETIC  = {SYNTHETIC}")

report: dict = {
    "written_on": date.today().isoformat(),
    "strategies": STRATEGIES,
    "year_split": YEAR_SPLIT,
    "synthetic_per_strategy": SYNTHETIC,
    "nsides": NSIDES,
    "iberia_bbox": {
        "lon": [IBERIA_LON_MIN, IBERIA_LON_MAX],
        "lat": [IBERIA_LAT_MIN, IBERIA_LAT_MAX],
    },
    "per_strategy": {},
}
ROOT       = /home/runner/work/sdm-scale-replication/sdm-scale-replication
STRATEGIES = ['museum', 'allbor']
NSIDES     = [16, 32, 64, 128, 256, 512]
YEAR_SPLIT = 2000 (>= modern, < historical)
SYNTHETIC  = {'museum': False, 'allbor': False}

Iberian HEALPix-NESTED cell sets at each Nside

Enumerate all global cells at each depth, transform centres to lon/lat via healpix_geo.nested.healpix_to_lonlat, keep those whose centre falls inside the Iberia bbox. (Computed once; reused for both strategies and both sources.)

def iberian_pix(depth: int, nside: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """All NESTED cells at `depth` whose centre lies in the Iberia bbox.

    Returns (pix, lon, lat) — each shape (n_cells,)."""
    pix_all = np.arange(12 * nside * nside, dtype=np.uint64)
    lon, lat = hp_nested.healpix_to_lonlat(pix_all, depth, ELLIPSOID)
    lon = np.where(lon > 180.0, lon - 360.0, lon)
    mask = (
        (lon >= IBERIA_LON_MIN) & (lon <= IBERIA_LON_MAX)
        & (lat >= IBERIA_LAT_MIN) & (lat <= IBERIA_LAT_MAX)
    )
    return (pix_all[mask].astype(np.int64),
            lon[mask].astype(np.float32),
            lat[mask].astype(np.float32))


IBERIA_PIX: dict[int, np.ndarray] = {}
IBERIA_LON: dict[int, np.ndarray] = {}
IBERIA_LAT: dict[int, np.ndarray] = {}
for nside in NSIDES:
    pix, lon, lat = iberian_pix(DEPTHS[nside], nside)
    IBERIA_PIX[nside] = pix
    IBERIA_LON[nside] = lon
    IBERIA_LAT[nside] = lat
    print(f"  nside={nside:>4}  (depth={DEPTHS[nside]}): "
          f"{len(pix):>7,} cells in Iberia bbox")

report["n_cells_per_nside"] = {n: int(len(IBERIA_PIX[n])) for n in NSIDES}
  nside=  16  (depth=4):       8 cells in Iberia bbox
  nside=  32  (depth=5):      29 cells in Iberia bbox
  nside=  64  (depth=6):     120 cells in Iberia bbox
  nside= 128  (depth=7):     479 cells in Iberia bbox
  nside= 256  (depth=8):   1,849 cells in Iberia bbox
  nside= 512  (depth=9):   7,364 cells in Iberia bbox

GBIF zip streaming reader

Streams the SIMPLE_CSV inside a GBIF download zip (tab-separated despite the name) in chunks of 1 M rows. Each yielded chunk is already NA-dropped on the essentials (species, lat, lon, year) and filtered to the Iberia bbox, so downstream consumers can fold chunks into per-cell and per-species accumulators without ever materialising the whole dataset in memory.

Streaming matters because the all-BoR allbor download is ~50–65 M rows (~6.5 GB compressed, ~25–30 GB uncompressed TSV). A one-shot pd.read_csv peaks at >10 GB resident and has been observed to crash the kernel on 16 GB laptops (see memory/pipeline_clean_oom_risk.md).

GBIF_CHUNKSIZE = 1_000_000


def iter_gbif_chunks(zip_path: Path,
                     chunksize: int = GBIF_CHUNKSIZE) -> Iterator[pd.DataFrame]:
    """Yield NA-dropped, bbox-filtered chunks of a GBIF SIMPLE_CSV zip."""
    if not zip_path.exists():
        raise FileNotFoundError(
            f"Expected GBIF zip at {zip_path} — re-run "
            f"notebooks/01_data_download.py to populate it."
        )
    with zipfile.ZipFile(zip_path) as zf:
        candidates = [n for n in zf.namelist() if n.endswith(".csv")]
        if not candidates:
            raise RuntimeError(f"No CSV inside {zip_path}")
        member = candidates[0]
        with zf.open(member) as src:
            reader = pd.read_csv(
                src, sep="\t",
                usecols=lambda c: c in {
                    "gbifID", "species", "decimalLatitude",
                    "decimalLongitude", "year", "basisOfRecord",
                    "countryCode",
                },
                dtype={"gbifID": "Int64", "year": "Int64",
                       "countryCode": "string"},
                chunksize=chunksize, on_bad_lines="skip",
            )
            for raw in reader:
                df = raw.dropna(
                    subset=["species", "decimalLatitude",
                            "decimalLongitude", "year"]
                )
                if df.empty:
                    continue
                lon = df["decimalLongitude"].astype(float)
                lat = df["decimalLatitude"].astype(float)
                in_bbox = (
                    (lon >= IBERIA_LON_MIN) & (lon <= IBERIA_LON_MAX)
                    & (lat >= IBERIA_LAT_MIN) & (lat <= IBERIA_LAT_MAX)
                )
                df = df.loc[in_bbox]
                if df.empty:
                    continue
                yield df.reset_index(drop=True)

Per-strategy helpers

  • species_hull — convex hull of a (lon, lat) point array with pragmatic fallbacks for n < 3.

  • hull_to_cellshealpix_geo.nested.polygon_coverage of the hull.

  • historical_cell_counts — per Nside, per-cell count of species whose EOO hull covers the cell, restricted to the Iberian cell set.

Modern (atlas) per-cell richness is computed inline in the per-strategy streaming loop below, by folding chunked (cell, species_id) pairs into per-Nside numpy arrays and then deduplicating once at the end.

def species_hull(points: np.ndarray) -> Polygon:
    """Convex hull of a (lon, lat) point array with n<3 fallbacks."""
    if len(points) >= 3:
        mp = MultiPoint(points)
        hull = mp.convex_hull
        if hull.geom_type != "Polygon":
            hull = hull.buffer(0.05)
    elif len(points) == 2:
        from shapely.geometry import LineString
        hull = LineString(points).buffer(0.1)
    else:  # 1 point
        from shapely.geometry import Point
        hull = Point(points[0]).buffer(0.2)
    return hull


def hull_to_cells(hull: Polygon, depth: int) -> np.ndarray:
    """NESTED cell IDs covered by `hull` at `depth` via polygon_coverage."""
    exterior = np.asarray(hull.exterior.coords)[:, :2]
    if np.allclose(exterior[0], exterior[-1]):
        exterior = exterior[:-1]
    if len(exterior) < 3:
        return np.empty(0, dtype=np.int64)
    cell_ids, _, _ = hp_nested.polygon_coverage(
        exterior, depth, ellipsoid=ELLIPSOID, flat=True,
    )
    return np.asarray(cell_ids, dtype=np.int64)


def historical_cell_counts(species_hulls: dict[str, Polygon],
                           nside: int,
                           iberian_set: set[int]) -> pd.DataFrame:
    """Per-cell count of species whose EOO hull covers the cell, restricted to
    Iberian cells."""
    depth = DEPTHS[nside]
    counts: dict[int, int] = {}
    iberian_arr = np.fromiter(iberian_set, dtype=np.int64)
    for sp, hull in species_hulls.items():
        cells = hull_to_cells(hull, depth)
        cells = cells[np.isin(cells, iberian_arr)]
        for c in cells:
            counts[int(c)] = counts.get(int(c), 0) + 1
    if not counts:
        return pd.DataFrame({"cell": [], "richness": []}, dtype=np.int64)
    return pd.DataFrame(
        {"cell": list(counts.keys()),
         "richness": list(counts.values())}
    ).astype({"cell": np.int64, "richness": np.int64})

Process each strategy (streaming)

Stream the zip in 1 M-row chunks. Per chunk:

  • Split on YEAR_SPLIT into modern / historical sub-chunks.

  • For modern: for each Nside, compute the (cell, species_id) pairs and accumulate per-chunk-unique pair arrays into per-Nside lists. Final per-cell atlas richness = |distinct species per cell| after a single end-of-stream np.unique across all chunks.

  • For historical: accumulate per-species (lon, lat) point arrays into a dict, then build convex hulls once after the stream completes.

This keeps peak memory bounded by |unique (cell, species) pairs| (atlas) + |historical records| (range-map), rather than by the full row count of the zip.

all_eoo_records: list[dict] = []

for strategy in STRATEGIES:
    print(f"\n{'='*60}")
    print(f"=== Strategy: {strategy}  (synthetic={SYNTHETIC[strategy]}) ===")
    print(f"{'='*60}")
    zip_path = STRATEGY_ZIPS[strategy]
    nc_path = STRATEGY_RICHNESS_NC[strategy]

    # Per-strategy species → small int ID, so modern pair arrays can stay
    # as int64×int64 instead of carrying string objects.
    species_to_id: dict[str, int] = {}

    def _sid(sp: str) -> int:
        sid = species_to_id.get(sp)
        if sid is None:
            sid = len(species_to_id)
            species_to_id[sp] = sid
        return sid

    # Modern accumulator: per Nside, list of per-chunk-unique (cell, sid)
    # int64 pair arrays. Final dedup + per-cell count happens once below.
    modern_pairs: dict[int, list[np.ndarray]] = {n: [] for n in NSIDES}
    # Historical accumulator: per species, list of (lon, lat) point arrays.
    hist_pts: dict[str, list[np.ndarray]] = {}

    n_records_total = 0
    n_records_modern = 0
    n_records_historical = 0
    species_total: set[str] = set()
    species_modern: set[str] = set()
    species_historical: set[str] = set()
    year_min: int | None = None
    year_max: int | None = None

    for ci, chunk in enumerate(iter_gbif_chunks(zip_path), start=1):
        n_records_total += len(chunk)
        years = chunk["year"].astype(int).values
        cmin, cmax = int(years.min()), int(years.max())
        year_min = cmin if year_min is None else min(year_min, cmin)
        year_max = cmax if year_max is None else max(year_max, cmax)
        species_total.update(chunk["species"].unique().tolist())

        is_modern = years >= YEAR_SPLIT
        modern_chunk = chunk.loc[is_modern]
        hist_chunk = chunk.loc[~is_modern]
        n_records_modern += len(modern_chunk)
        n_records_historical += len(hist_chunk)

        if len(modern_chunk):
            species_modern.update(modern_chunk["species"].unique().tolist())
            mod_lon = modern_chunk["decimalLongitude"].astype(float).values
            mod_lat = modern_chunk["decimalLatitude"].astype(float).values
            mod_sp = modern_chunk["species"].values
            mod_sid = np.fromiter(
                (_sid(s) for s in mod_sp),
                dtype=np.int64, count=len(mod_sp),
            )
            for nside in NSIDES:
                cells = hp_nested.lonlat_to_healpix(
                    mod_lon, mod_lat, DEPTHS[nside], ELLIPSOID,
                ).astype(np.int64)
                pairs = np.column_stack([cells, mod_sid])
                pairs = np.unique(pairs, axis=0)
                modern_pairs[nside].append(pairs)

        if len(hist_chunk):
            species_historical.update(hist_chunk["species"].unique().tolist())
            for sp, grp in hist_chunk.groupby("species", sort=False):
                pts = (
                    grp[["decimalLongitude", "decimalLatitude"]]
                    .astype(float)
                    .values
                )
                hist_pts.setdefault(sp, []).append(pts)

        print(f"  chunk {ci:>3}: +{len(chunk):>8,} rows  "
              f"(modern +{len(modern_chunk):>8,}, "
              f"hist +{len(hist_chunk):>7,})  "
              f"running total: {n_records_total:>11,}")

    print(f"\n  loaded     : {n_records_total:>10,} records, "
          f"{len(species_total)} species  "
          f"(years {year_min}..{year_max})")
    print(f"  modern     : {n_records_modern:>10,} records, "
          f"{len(species_modern)} species  (>= {YEAR_SPLIT})")
    print(f"  historical : {n_records_historical:>10,} records, "
          f"{len(species_historical)} species  (< {YEAR_SPLIT})")

    strat_report: dict = {
        "synthetic": SYNTHETIC[strategy],
        "n_records_total": int(n_records_total),
        "n_species_total": int(len(species_total)),
        "n_records_modern": int(n_records_modern),
        "n_species_modern": int(len(species_modern)),
        "n_records_historical": int(n_records_historical),
        "n_species_historical": int(len(species_historical)),
        "year_min": int(year_min) if year_min is not None else None,
        "year_max": int(year_max) if year_max is not None else None,
    }

    # --- Build per-species EOO hulls from accumulated historical points ---
    print(f"\n--- Building per-species EOO hulls (historical, {strategy}) ---")
    species_hulls: dict[str, Polygon] = {}
    n_species_eoo = len(hist_pts)
    sorted_species = sorted(hist_pts.keys())
    for i, sp in enumerate(sorted_species, start=1):
        parts = hist_pts.pop(sp)
        pts = np.vstack(parts) if len(parts) > 1 else parts[0]
        hull = species_hull(pts)
        species_hulls[sp] = hull
        all_eoo_records.append({
            "strategy": strategy,
            "species": sp,
            "n_points": int(len(pts)),
            "hull_area_sqdeg": float(hull.area),
            "wkt": hull.wkt,
        })
        if i % 50 == 0 or i == n_species_eoo:
            print(f"  built hull {i:>4}/{n_species_eoo}  ({sp[:32]}, "
                  f"area={hull.area:.2f} deg^2)")
    strat_report["n_species_with_eoo"] = int(n_species_eoo)

    # --- Per-Nside per-cell richness for this strategy ---
    print(f"\n--- Cross-tabulating richness per Nside ({strategy}) ---")
    # Write each Nside as a NetCDF group inside the per-strategy file.
    if nc_path.exists():
        nc_path.unlink()
    strat_report["per_nside"] = {}
    for nside in NSIDES:
        iberian_pix_arr = IBERIA_PIX[nside]
        iberian_set = set(int(p) for p in iberian_pix_arr)

        # Atlas: dedup across chunks, restrict to Iberian cells, then count
        # distinct species per cell.
        parts = modern_pairs[nside]
        if parts:
            all_pairs = np.vstack(parts) if len(parts) > 1 else parts[0]
            all_pairs = np.unique(all_pairs, axis=0)
            mask = np.isin(all_pairs[:, 0], iberian_pix_arr)
            kept_cells = all_pairs[mask, 0]
            uniq_cells, counts = np.unique(kept_cells, return_counts=True)
            mod = pd.DataFrame(
                {"cell": uniq_cells, "richness": counts}
            ).astype({"cell": np.int64, "richness": np.int64}).set_index("cell")
        else:
            mod = pd.DataFrame(
                {"cell": [], "richness": []}, dtype=np.int64,
            ).set_index("cell")
        # Free per-Nside pair memory now that we've reduced it.
        modern_pairs[nside] = []

        hist = historical_cell_counts(species_hulls, nside, iberian_set).set_index("cell")

        df = pd.DataFrame({"cell": iberian_pix_arr}).set_index("cell")
        df["richness_atlas"] = (
            mod["richness"].reindex(df.index, fill_value=0).astype(np.int32)
        )
        df["richness_rangemap"] = (
            hist["richness"].reindex(df.index, fill_value=0).astype(np.int32)
        )
        df["lon"] = IBERIA_LON[nside]
        df["lat"] = IBERIA_LAT[nside]

        n_cells = len(df)
        mean_a = float(df["richness_atlas"].mean())
        mean_r = float(df["richness_rangemap"].mean())
        zz = int(((df["richness_atlas"] == 0) & (df["richness_rangemap"] == 0)).sum())
        print(f"  nside={nside:>4}  "
              f"n_cells={n_cells:>6,}  "
              f"mean richness atlas={mean_a:5.2f}, rangemap={mean_r:5.2f}  "
              f"zero-zero={zz:>5,}")
        strat_report["per_nside"][nside] = {
            "n_cells": int(n_cells),
            "mean_richness_atlas": round(mean_a, 3),
            "mean_richness_rangemap": round(mean_r, 3),
            "zero_zero_cells": zz,
        }

        ds = xr.Dataset(
            data_vars={
                "richness_atlas": (
                    ("cell",), df["richness_atlas"].values,
                    {"long_name": "Per-cell species richness from modern "
                                  "(year>=2000) GBIF occurrences (atlas-equivalent)",
                     "units": "n_species",
                     "strategy": strategy,
                     "synthetic": str(SYNTHETIC[strategy])}),
                "richness_rangemap": (
                    ("cell",), df["richness_rangemap"].values,
                    {"long_name": "Per-cell species richness from historical "
                                  "(year<2000) EOO convex-hull coverage (range-map-equivalent)",
                     "units": "n_species",
                     "strategy": strategy,
                     "synthetic": str(SYNTHETIC[strategy])}),
            },
            coords={
                "cell": ("cell", df.index.values.astype(np.int64),
                         {"long_name": f"HEALPix NESTED pixel index (nside={nside})"}),
                "lon": ("cell", df["lon"].values,
                        {"units": "degrees_east"}),
                "lat": ("cell", df["lat"].values,
                        {"units": "degrees_north"}),
            },
            attrs={
                "nside": nside,
                "depth": DEPTHS[nside],
                "ellipsoid": ELLIPSOID,
                "healpix_ordering": "NESTED",
                "strategy": strategy,
                "synthetic": str(SYNTHETIC[strategy]),
                "year_split": YEAR_SPLIT,
            },
        )
        ds.to_netcdf(
            nc_path,
            mode="a" if nc_path.exists() else "w",
            group=f"nside_{nside}",
            engine="netcdf4",
            encoding={
                "richness_atlas": {"zlib": True, "complevel": 4},
                "richness_rangemap": {"zlib": True, "complevel": 4},
            },
        )

    size_mb = nc_path.stat().st_size / 1e6
    print(f"\n  saved {nc_path}  ({size_mb:.2f} MB)")
    strat_report["richness_nc"] = str(nc_path.relative_to(ROOT))
    strat_report["richness_nc_size_mb"] = round(size_mb, 2)
    report["per_strategy"][strategy] = strat_report

============================================================
=== Strategy: museum  (synthetic=False) ===
============================================================
  chunk   1: + 919,117 rows  (modern + 907,028, hist + 12,089)  running total:     919,117
  chunk   2: +  80,841 rows  (modern +  79,873, hist +    968)  running total:     999,958

  loaded     :    999,958 records, 452 species  (years 1627..2026)
  modern     :    986,901 records, 391 species  (>= 2000)
  historical :     13,057 records, 356 species  (< 2000)

--- Building per-species EOO hulls (historical, museum) ---
  built hull   50/356  (Calandrella brachydactyla, area=24.54 deg^2)
  built hull  100/356  (Corvus frugilegus, area=1.05 deg^2)
  built hull  150/356  (Glareola pratincola, area=17.27 deg^2)
  built hull  200/356  (Melanocorypha calandra, area=21.17 deg^2)
  built hull  250/356  (Phylloscopus ibericus, area=9.19 deg^2)
  built hull  300/356  (Sitta europaea, area=18.35 deg^2)
  built hull  350/356  (Turdus viscivorus, area=14.11 deg^2)
  built hull  356/356  (Vireo olivaceus, area=0.13 deg^2)

--- Cross-tabulating richness per Nside (museum) ---
  nside=  16  n_cells=     8  mean richness atlas=152.62, rangemap=175.38  zero-zero=    0
  nside=  32  n_cells=    29  mean richness atlas=67.31, rangemap=114.90  zero-zero=    1
  nside=  64  n_cells=   120  mean richness atlas=24.88, rangemap=79.08  zero-zero=   15
  nside= 128  n_cells=   479  mean richness atlas= 9.02, rangemap=61.97  zero-zero=   79
  nside= 256  n_cells= 1,849  mean richness atlas= 3.65, rangemap=55.63  zero-zero=  354
  nside= 512  n_cells= 7,364  mean richness atlas= 1.54, rangemap=51.62  zero-zero=1,720

  saved /home/runner/work/sdm-scale-replication/sdm-scale-replication/data/clean/richness_museum.nc  (0.23 MB)

============================================================
=== Strategy: allbor  (synthetic=False) ===
============================================================
  chunk   1: + 938,397 rows  (modern + 815,524, hist +122,873)  running total:     938,397
  chunk   2: + 906,165 rows  (modern + 905,461, hist +    704)  running total:   1,844,562
  chunk   3: + 897,739 rows  (modern + 896,971, hist +    768)  running total:   2,742,301
  chunk   4: + 805,922 rows  (modern + 805,327, hist +    595)  running total:   3,548,223
  chunk   5: + 927,850 rows  (modern + 895,916, hist + 31,934)  running total:   4,476,073
  chunk   6: + 688,088 rows  (modern + 684,083, hist +  4,005)  running total:   5,164,161
  chunk   7: + 815,742 rows  (modern + 808,836, hist +  6,906)  running total:   5,979,903
  chunk   8: + 964,318 rows  (modern + 946,375, hist + 17,943)  running total:   6,944,221
  chunk   9: + 965,842 rows  (modern + 950,566, hist + 15,276)  running total:   7,910,063
  chunk  10: + 965,441 rows  (modern + 950,086, hist + 15,355)  running total:   8,875,504
  chunk  11: + 965,806 rows  (modern + 951,300, hist + 14,506)  running total:   9,841,310
  chunk  12: + 965,433 rows  (modern + 951,162, hist + 14,271)  running total:  10,806,743
  chunk  13: + 965,835 rows  (modern + 950,808, hist + 15,027)  running total:  11,772,578
  chunk  14: + 966,183 rows  (modern + 951,803, hist + 14,380)  running total:  12,738,761
  chunk  15: + 965,731 rows  (modern + 949,950, hist + 15,781)  running total:  13,704,492
  chunk  16: + 943,845 rows  (modern + 928,999, hist + 14,846)  running total:  14,648,337
  chunk  17: + 963,385 rows  (modern + 947,652, hist + 15,733)  running total:  15,611,722
  chunk  18: + 965,495 rows  (modern + 950,844, hist + 14,651)  running total:  16,577,217
  chunk  19: + 965,713 rows  (modern + 950,981, hist + 14,732)  running total:  17,542,930
  chunk  20: + 965,664 rows  (modern + 950,207, hist + 15,457)  running total:  18,508,594
  chunk  21: + 965,521 rows  (modern + 950,730, hist + 14,791)  running total:  19,474,115
  chunk  22: + 907,856 rows  (modern + 893,716, hist + 14,140)  running total:  20,381,971
  chunk  23: + 965,882 rows  (modern + 951,457, hist + 14,425)  running total:  21,347,853
  chunk  24: + 965,876 rows  (modern + 950,589, hist + 15,287)  running total:  22,313,729
  chunk  25: + 965,834 rows  (modern + 950,158, hist + 15,676)  running total:  23,279,563
  chunk  26: + 965,973 rows  (modern + 950,825, hist + 15,148)  running total:  24,245,536
  chunk  27: + 965,958 rows  (modern + 950,127, hist + 15,831)  running total:  25,211,494
  chunk  28: + 965,791 rows  (modern + 951,073, hist + 14,718)  running total:  26,177,285
  chunk  29: + 965,509 rows  (modern + 950,635, hist + 14,874)  running total:  27,142,794
  chunk  30: + 965,495 rows  (modern + 950,519, hist + 14,976)  running total:  28,108,289
  chunk  31: + 965,084 rows  (modern + 949,297, hist + 15,787)  running total:  29,073,373
  chunk  32: + 964,526 rows  (modern + 946,149, hist + 18,377)  running total:  30,037,899
  chunk  33: + 965,676 rows  (modern + 950,555, hist + 15,121)  running total:  31,003,575
  chunk  34: + 965,413 rows  (modern + 949,878, hist + 15,535)  running total:  31,968,988
  chunk  35: + 965,979 rows  (modern + 951,658, hist + 14,321)  running total:  32,934,967
  chunk  36: + 966,065 rows  (modern + 950,288, hist + 15,777)  running total:  33,901,032
  chunk  37: + 965,976 rows  (modern + 951,345, hist + 14,631)  running total:  34,867,008
  chunk  38: + 966,044 rows  (modern + 950,893, hist + 15,151)  running total:  35,833,052
  chunk  39: + 965,241 rows  (modern + 949,874, hist + 15,367)  running total:  36,798,293
  chunk  40: + 965,379 rows  (modern + 950,097, hist + 15,282)  running total:  37,763,672
  chunk  41: + 965,640 rows  (modern + 949,537, hist + 16,103)  running total:  38,729,312
  chunk  42: + 965,590 rows  (modern + 950,564, hist + 15,026)  running total:  39,694,902
  chunk  43: + 966,038 rows  (modern + 950,976, hist + 15,062)  running total:  40,660,940
  chunk  44: + 962,486 rows  (modern + 947,154, hist + 15,332)  running total:  41,623,426
  chunk  45: + 965,965 rows  (modern + 950,456, hist + 15,509)  running total:  42,589,391
  chunk  46: + 965,788 rows  (modern + 950,651, hist + 15,137)  running total:  43,555,179
  chunk  47: + 965,925 rows  (modern + 949,990, hist + 15,935)  running total:  44,521,104
  chunk  48: + 965,870 rows  (modern + 951,157, hist + 14,713)  running total:  45,486,974
  chunk  49: + 965,644 rows  (modern + 950,472, hist + 15,172)  running total:  46,452,618
  chunk  50: + 965,464 rows  (modern + 949,685, hist + 15,779)  running total:  47,418,082
  chunk  51: + 965,625 rows  (modern + 950,501, hist + 15,124)  running total:  48,383,707
  chunk  52: + 965,464 rows  (modern + 950,148, hist + 15,316)  running total:  49,349,171
  chunk  53: + 965,412 rows  (modern + 949,836, hist + 15,576)  running total:  50,314,583
  chunk  54: + 877,363 rows  (modern + 843,172, hist + 34,191)  running total:  51,191,946
  chunk  55: + 902,869 rows  (modern + 898,062, hist +  4,807)  running total:  52,094,815
  chunk  56: + 784,714 rows  (modern + 728,980, hist + 55,734)  running total:  52,879,529
  chunk  57: + 965,568 rows  (modern + 658,789, hist +306,779)  running total:  53,845,097
  chunk  58: + 965,285 rows  (modern + 659,248, hist +306,037)  running total:  54,810,382
  chunk  59: + 965,454 rows  (modern + 659,393, hist +306,061)  running total:  55,775,836
  chunk  60: + 965,437 rows  (modern + 659,119, hist +306,318)  running total:  56,741,273
  chunk  61: + 965,674 rows  (modern + 658,242, hist +307,432)  running total:  57,706,947
  chunk  62: + 965,546 rows  (modern + 659,310, hist +306,236)  running total:  58,672,493
  chunk  63: + 965,317 rows  (modern + 657,367, hist +307,950)  running total:  59,637,810
  chunk  64: + 965,648 rows  (modern + 660,113, hist +305,535)  running total:  60,603,458
  chunk  65: + 847,909 rows  (modern + 579,393, hist +268,516)  running total:  61,451,367
  chunk  66: + 252,026 rows  (modern + 251,013, hist +  1,013)  running total:  61,703,393

  loaded     : 61,703,393 records, 964 species  (years 1574..2026)
  modern     : 58,016,042 records, 931 species  (>= 2000)
  historical :  3,687,351 records, 608 species  (< 2000)

--- Building per-species EOO hulls (historical, allbor) ---
  built hull   50/608  (Anser cygnoides, area=0.13 deg^2)
  built hull  100/608  (Bubo bubo, area=63.10 deg^2)
  built hull  150/608  (Charadrius morinellus, area=62.85 deg^2)
  built hull  200/608  (Cyanoliseus patagonus, area=14.73 deg^2)
  built hull  250/608  (Falco naumanni, area=64.61 deg^2)
  built hull  300/608  (Hydroprogne caspia, area=70.54 deg^2)
  built hull  350/608  (Lullula arborea, area=67.85 deg^2)
  built hull  400/608  (Oena capensis, area=0.13 deg^2)
  built hull  450/608  (Pica mauritanica, area=0.01 deg^2)
  built hull  500/608  (Pyrrhocorax graculus, area=34.55 deg^2)
  built hull  550/608  (Sylvia cantillans, area=71.56 deg^2)
  built hull  600/608  (Uraeginthus bengalus, area=0.13 deg^2)
  built hull  608/608  (Xenus cinereus, area=21.49 deg^2)

--- Cross-tabulating richness per Nside (allbor) ---
  nside=  16  n_cells=     8  mean richness atlas=492.88, rangemap=380.00  zero-zero=    0
  nside=  32  n_cells=    29  mean richness atlas=374.93, rangemap=341.28  zero-zero=    1
  nside=  64  n_cells=   120  mean richness atlas=243.53, rangemap=269.24  zero-zero=   13
  nside= 128  n_cells=   479  mean richness atlas=167.40, rangemap=238.58  zero-zero=   67
  nside= 256  n_cells= 1,849  mean richness atlas=121.44, rangemap=227.59  zero-zero=  297
  nside= 512  n_cells= 7,364  mean richness atlas=84.32, rangemap=218.48  zero-zero=1,360

  saved /home/runner/work/sdm-scale-replication/sdm-scale-replication/data/clean/richness_allbor.nc  (0.23 MB)

Persist per-strategy EOO polygons + clean report

Single parquet covering both strategies, with a strategy column.

eoo_df = pd.DataFrame(all_eoo_records)
eoo_df.to_parquet(EOO_PARQUET, index=False)
print(f"\nsaved {EOO_PARQUET} "
      f"({EOO_PARQUET.stat().st_size / 1e3:.1f} KB; "
      f"{len(eoo_df)} species-rows across {eoo_df['strategy'].nunique()} strategies)")

with open(CLEAN_REPORT, "w") as f:
    json.dump(report, f, indent=2, default=str)
print(f"\n--- Clean report -> {CLEAN_REPORT}")
print(json.dumps(report, indent=2, default=str))

saved /home/runner/work/sdm-scale-replication/sdm-scale-replication/data/clean/species_eoo_polygons.parquet (342.8 KB; 964 species-rows across 2 strategies)

--- Clean report -> /home/runner/work/sdm-scale-replication/sdm-scale-replication/data/clean/clean_report.json
{
  "written_on": "2026-05-30",
  "strategies": [
    "museum",
    "allbor"
  ],
  "year_split": 2000,
  "synthetic_per_strategy": {
    "museum": false,
    "allbor": false
  },
  "nsides": [
    16,
    32,
    64,
    128,
    256,
    512
  ],
  "iberia_bbox": {
    "lon": [
      -10.0,
      4.0
    ],
    "lat": [
      35.0,
      44.0
    ]
  },
  "per_strategy": {
    "museum": {
      "synthetic": false,
      "n_records_total": 999958,
      "n_species_total": 452,
      "n_records_modern": 986901,
      "n_species_modern": 391,
      "n_records_historical": 13057,
      "n_species_historical": 356,
      "year_min": 1627,
      "year_max": 2026,
      "n_species_with_eoo": 356,
      "per_nside": {
        "16": {
          "n_cells": 8,
          "mean_richness_atlas": 152.625,
          "mean_richness_rangemap": 175.375,
          "zero_zero_cells": 0
        },
        "32": {
          "n_cells": 29,
          "mean_richness_atlas": 67.31,
          "mean_richness_rangemap": 114.897,
          "zero_zero_cells": 1
        },
        "64": {
          "n_cells": 120,
          "mean_richness_atlas": 24.883,
          "mean_richness_rangemap": 79.083,
          "zero_zero_cells": 15
        },
        "128": {
          "n_cells": 479,
          "mean_richness_atlas": 9.019,
          "mean_richness_rangemap": 61.971,
          "zero_zero_cells": 79
        },
        "256": {
          "n_cells": 1849,
          "mean_richness_atlas": 3.651,
          "mean_richness_rangemap": 55.625,
          "zero_zero_cells": 354
        },
        "512": {
          "n_cells": 7364,
          "mean_richness_atlas": 1.535,
          "mean_richness_rangemap": 51.623,
          "zero_zero_cells": 1720
        }
      },
      "richness_nc": "data/clean/richness_museum.nc",
      "richness_nc_size_mb": 0.23
    },
    "allbor": {
      "synthetic": false,
      "n_records_total": 61703393,
      "n_species_total": 964,
      "n_records_modern": 58016042,
      "n_species_modern": 931,
      "n_records_historical": 3687351,
      "n_species_historical": 608,
      "year_min": 1574,
      "year_max": 2026,
      "n_species_with_eoo": 608,
      "per_nside": {
        "16": {
          "n_cells": 8,
          "mean_richness_atlas": 492.875,
          "mean_richness_rangemap": 380.0,
          "zero_zero_cells": 0
        },
        "32": {
          "n_cells": 29,
          "mean_richness_atlas": 374.931,
          "mean_richness_rangemap": 341.276,
          "zero_zero_cells": 1
        },
        "64": {
          "n_cells": 120,
          "mean_richness_atlas": 243.525,
          "mean_richness_rangemap": 269.242,
          "zero_zero_cells": 13
        },
        "128": {
          "n_cells": 479,
          "mean_richness_atlas": 167.399,
          "mean_richness_rangemap": 238.576,
          "zero_zero_cells": 67
        },
        "256": {
          "n_cells": 1849,
          "mean_richness_atlas": 121.442,
          "mean_richness_rangemap": 227.595,
          "zero_zero_cells": 297
        },
        "512": {
          "n_cells": 7364,
          "mean_richness_atlas": 84.317,
          "mean_richness_rangemap": 218.48,
          "zero_zero_cells": 1360
        }
      },
      "richness_nc": "data/clean/richness_allbor.nc",
      "richness_nc_size_mb": 0.23
    }
  },
  "n_cells_per_nside": {
    "16": 8,
    "32": 29,
    "64": 120,
    "128": 479,
    "256": 1849,
    "512": 7364
  }
}