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 viahealpix_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 holdingrichness_atlasandrichness_rangemapvariables on thecelldimension. 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.strategycolumn.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), neverhealpy.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, PolygonConstants¶
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_cells—healpix_geo.nested.polygon_coverageof 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_SPLITinto 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-streamnp.uniqueacross 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
}
}