This is the scientific result of the Replication Study. The prior light-only run of this repository reached a pooled median error of 441 km to the Argos referee; the prior temperature-only chain reached 276 km. Neither single physical signal alone matches the proprietary GPE3 product (54 km median to Argos on these four referee tags).
Here we fuse several independent likelihood factors into a single
pangeo-fish HMM emission, then run the identical low-level σ-fit
(EagerEstimator + EagerBoundsSearch) and decode the per-day MAP state
with decode(mode="mode") (the posterior argmax cell per day).
Why mode, not Viterbi (a hard pangeo-fish constraint)¶
The original plan was to decode with the Viterbi most-probable path. In
pangeo-fish 2026.4.0, however, EagerEstimator.decode(mode="viterbi") (and
"viterbi2") is implemented only for the structured 2-D (x, y) projected
emission grid — it calls gaussian_kernel(shape=(2,)) and indexes the pdf as
a 2-D image with input_core_dims=[("x","y"), ...]. This whole pipeline (and
both sibling chains) runs on a 1-D HEALPix cells emission with the
Gaussian1DHealpix convolution predictor. Calling Viterbi on the HEALPix
emission raises ValueError: tuple.index(x): x not in tuple because the decoder
cannot find the x/y dims. Viterbi is therefore not available on the
HEALPix grid without abandoning the HEALPix state space.
We use the mode (per-day maximum-a-posteriori cell) instead. The mode shares Viterbi’s decisive property — and the property the posterior mean lacks: it returns an actual posterior-supported cell, so on a multimodal day it picks one mode rather than averaging two modes onto the coastline between them, and because masked land cells carry zero posterior it can never select a banned land cell. It differs from Viterbi only in that it is a per-day MAP rather than a jointly-most-probable path (no inter-day transition smoothing in the decode; the σ-Brownian prior already shapes the forward-backward posterior the mode reads from). The emission fusion mirrors both prior notebooks:
emission_fused[day, cell] = normalize(
light_pdf × temperature_pdf × land_mask × depth_emission )This reproduces GPE3’s ingredient list (light + sea-surface/at-depth temperature
a movement prior) plus a bathymetric constraint that GPE3 does not use.
This run — two changes over the fused-barrier baseline (189.6 km pooled)¶
Bathymetric depth EMISSION (Change 1). The old binary depth floor (cell allowed iff seabed ≥ max-dive − tol) is replaced by a soft, floor-aware likelihood
sigmoid((S − D)/τ)over (seabed S − day’s max dive D), τ = 75 m. It downweights only physically-impossible too-shallow cells and never penalises a deep cell on a shallow-diving day (see the depth-emission section).Relaxed coastal land mask (Change 2). A cell is water if its land fraction ≤ 0.8 or its footprint minimum elevation ≤ +10 m, so the shallow Bahía Sebastián Vizcaíno nursery lagoon (which ETOPO renders as +3..+5 m land) is no longer excluded. The dense full-resolution ETOPO segment tests in the barrier kernel and the path Viterbi are unchanged, so no real land-crossing returns.
Factors¶
temperature_pdf — built with the exact sibling path (
pangeo_fish.helpers.compute_diff → regrid_dataset → compute_emission_pdf → normalize_pdf,differences_std=0.75,relative_depth_threshold=0.8) against the GLORYS12V1thetao3-D field. The temperature emission’s HEALPix cell set (level 9, NESTED) and daily time axis are the canonical grid for fusion; itsinitial/finalendpoint anchors are reused unchanged.light_pdf — the twilight emission of this repo’s prior notebook: each detected sunrise/sunset event is scored at every cell by a Gaussian (
SIGMA_T_MIN=20 min) over the astropy-modelled-minus-detected time residual. Events are binned to days (the product of a day’s events, renormalised); days with no twilight default to uniform.land_mask — from NOAA NCEI ETOPO 2022 v1 15-arc-second relief, regridded to the level-9 cells. RELAXED (Change 2): a cell is water if its land fraction (
elevation ≥ 0) ≤ 0.8 or its footprint minimum elevation ≤ +10 m, so shallow lagoon/tidal-flat nursery cells are not excluded.depth_emission — daily soft floor (Change 1):
sigmoid((seabed − max-dive)/τ)with τ = 75 m, replacing the old binary floor. ~1 where the seabed is deeper than the dives, →0 only where it is impossibly shallower.
Anti-circularity¶
The only spatial anchors fed to the fit are the release/pop-up endpoints. Argos is never fed to the fit — it is the held-out referee only. GPE3 is a comparison baseline, never ground truth. Light, temperature and bathymetry are three independent physical observations, none derived from the referee.
Tag scope¶
Referee tags
07_05, 08_01, 08_02, 08_09— full fusion (GLORYS on disk + DST + Argos referee). These carry the headline.07_01— has GLORYS + DST but no referee → full fusion, best-effort.06_10— basin-scale roamer, 2 GB GLORYS field → memory risk; full fusion at a reduced level if needed, else best-effort drop with the exact reason.02_01— PAT2 with no external water-temperature sensor (no DST, no GLORYS) → fusion = light × land only (no SST factor, no depth floor). No referee.
Self-contained temperature factor (baseline-free species-range grid)¶
The temperature emission reads this repository’s own data: the GLORYS12V1
reference models data/clean/reference_model_<tag>_gulfext.nc (built by notebook
02 from the species-range GLORYS subset downloaded by notebook 01) and the
natively-cleaned DST logs data/clean/tags/<tag>/dst.csv (notebook 02). There is
no sibling-repository dependency — the analysis grid is the baseline-free
juvenile-white-shark species range lon[-125,-106], lat[22,38] for every
temperature tag (NOT a GPE3/Argos-derived box), which is what makes the held-out
Argos comparison baseline-free.
import json
import warnings
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import pandas as pd
import pint
import sparse # noqa: F401 (sparse.COO rebuild in LandBarrierGaussian1DHealpix)
import xarray as xr
import xdggs # noqa: F401 (registers the .dggs accessor)
import healpix_geo.nested as hgn
from astropy.coordinates import get_sun
from astropy.time import Time
from pangeo_fish.distributions import healpix as distrib_hp
from pangeo_fish.helpers import (
compute_diff,
compute_emission_pdf,
load_tag,
normalize_pdf,
regrid_dataset,
to_healpix,
)
from pangeo_fish.hmm.estimator import EagerEstimator
from pangeo_fish.hmm.optimize import EagerBoundsSearch
from pangeo_fish.hmm.prediction import Gaussian1DHealpix
from tlz.functoolz import curry
from xarray.namedarray._typing import _arrayfunction_or_api as _ArrayLike
warnings.filterwarnings("ignore", category=RuntimeWarning)CLEAN_DIR = Path("../data/clean")
RAW_DIR = Path("../data/raw")
RESULTS_DIR = Path("../results")
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
(RESULTS_DIR / "logs").mkdir(parents=True, exist_ok=True)
# This repo's own per-tag clean root (DST + tagging events) for pangeo-fish
# `load_tag`. Baseline-free GLORYS reference models live alongside as
# reference_model_<tag>_gulfext.nc. No sibling repository is read.
TAG_ROOT = str((CLEAN_DIR / "tags").resolve())
# Bathymetry: NOAA NCEI ETOPO 2022 v1 15-arc-second (~450 m) regional subset,
# downloaded by notebook 01 from the CoastWatch ERDDAP griddap dataset
# `ETOPO_2022_v1_15s` (variable `z`, EGM2008 height, lon -180..180).
# https://coastwatch.pfeg.noaa.gov/erddap/griddap/ETOPO_2022_v1_15s.html
BATHY_NC = RAW_DIR / "etopo2022_15s_nepac.nc"
# Registry: shark_id -> (PAT_DEPLOY_ID, SPOT_DEPLOY_ID or None, has_referee).
TAGS = {
"07_05": ("07_05-66885", "07_05-77272", True),
"08_01": ("08_01-40561", "08_01-77274", True),
"08_02": ("08_02-55716", "08_02-77273", True),
"08_09": ("08_09-83066", "08_09-83076", True),
"02_01": ("02_01-18616", None, False),
"06_10": ("06_10-40564", None, False),
"07_01": ("07_01-64272", None, False),
}
# Tags whose temperature factor uses GLORYS (a native DST log
# data/clean/tags/<tag>/dst.csv and reference_model_<tag>_gulfext.nc must both
# exist, built by notebooks 01/02). 02_01 has no external thermistor, so it runs
# light × land only.
HAS_TEMPERATURE = {"07_05", "08_01", "08_02", "08_09", "07_01", "06_10"}
# Emission version tag. Bump this whenever the emission recipe OR the analysis grid
# changes so a σ cached against an older recipe/grid is NOT reused (a stale σ is no
# longer the bounds-search optimum, and σ is grid-dependent). v4 = light ×
# temperature × relaxed-land × SOFT bathy depth emission using the cell's DEEPEST
# seabed point (Changes 1 & 2 with the deepest-point fix). The `_baselinefree`
# suffix marks the canonical BASELINE-FREE species-range grid (lon[-125,-106]
# lat[22,38]) used for ALL temperature tags — distinct from the earlier GPE3-sized
# grid, so any GPE3-grid σ cache is invalidated and a fresh σ-fit is forced.
EMISSION_VERSION = "v4_softbathy_deepest_relaxedmask_baselinefree"
# HEALPix level for the state space — level 9 (~6.4 km cells), NESTED (DOMAIN.md).
# A per-tag override drops the basin-roaming 06_10 to level 8 so its 2 GB GLORYS
# field fits the single-kernel memory budget (stated deviation; recorded per tag
# in summary.csv as healpix_level).
DEFAULT_HEALPIX_LEVEL = 9
HEALPIX_LEVEL_OVERRIDE: dict[str, int] = {"06_10": 8}
# --- emission hyperparameters ---
# Light: Gaussian spread (minutes) over the twilight time residual. 20 min is the
# proven value from this repo's light-only run (open analogue of probGLS/FLightR
# "twilight error").
SIGMA_T_MIN = 20.0
SOLAR_HORIZON_DEG = -0.833 # geometric sunrise/sunset (refraction + semi-diameter)
# Temperature: sibling parameters, used verbatim so the temperature factor is the
# same likelihood the 276 km chain used.
DIFFERENCES_STD = 0.75
RELATIVE_DEPTH_THRESHOLD = 0.8
# Bathymetry: land-fraction cut and depth-floor tolerance (deviations, stated).
#
# CHANGE 2 — relaxed coastal land mask (shallow nursery habitat).
# A cell is WATER (mask 1) iff EITHER
# (i) its ETOPO land fraction <= LAND_FRAC_THRESHOLD, OR
# (ii) its MINIMUM elevation over the footprint <= MIN_ELEV_WATER_CUTOFF_M.
# Rule (ii) captures tidal-flat / lagoon habitat that ETOPO renders as a thin
# +3..+5 m "land" sliver (e.g. the Bahía Sebastián Vizcaíno nursery at
# -114.05/27.98 where 46 good Argos fixes sit): if ANY part of the cell footprint
# is at or below +10 m it is treated as usable shallow water. The land_fraction
# cut is also relaxed 0.5 -> 0.8 so a cell that is mostly land but still partly
# water/lagoon is admitted. The DENSE ETOPO segment test in the barrier kernel and
# the path Viterbi remains at full 15-arc-second resolution, so relaxing the CELL
# candidate threshold cannot reintroduce a real land-crossing (re-verified to 0).
# Genuine high-elevation inland cells (footprint min well above +10 m) stay land.
LAND_FRAC_THRESHOLD = 0.8
MIN_ELEV_WATER_CUTOFF_M = 10.0
# Vizcaíno nursery centroid used only to CONFIRM the relaxed mask opens it (never
# fed to the fit).
VIZCAINO_LON, VIZCAINO_LAT = -114.05, 27.98
#
# CHANGE 1 — bathymetric depth EMISSION (replaces the binary depth floor).
# DEPTH_FLOOR_TOL_M is the sigmoid scale (metres) of the soft floor: a cell whose
# shallowest seabed S is deeper than the day's max dive D is fully allowed (factor
# ~1); a cell shallower than the dives is smoothly downweighted by
# sigmoid((S - D) / DEPTH_FLOOR_TOL_M), reaching ~0 only where the seabed is many
# tens of metres shallower than depths the animal demonstrably reached (physically
# impossible). 75 m absorbs ETOPO vertical RMS + pressure-sensor error in this band.
DEPTH_FLOOR_TOL_M = 75.0
# Endpoint-anchor spreads (HEALPix-distance sigma), matching the siblings.
INITIAL_STD = 1e-3
RECAPTURE_STD = 1e-3
# Brownian speed prior -> σ-search upper bound, matching the siblings.
MAX_SPEED = pint.Quantity(5.0, "km/h")
EARTH_RADIUS = pint.Quantity(6371.0, "km")
ADJUSTMENT_FACTOR = 5.0
TRUNCATE = 4.0
TOLERANCE = 1e-3
# Bbox padding (deg) for the light-only (02_01) state space.
BBOX_PAD_DEG = 4.0
MESH_STEP_DEG = 0.03
# Prior reference numbers (do NOT recompute — they feed the ablation table).
LIGHT_ONLY_ERR_KM = {"07_05": 563.0, "08_01": 454.0, "08_02": 427.0, "08_09": 334.0}
LIGHT_ONLY_POOLED_KM = 441.0
TEMP_ONLY_ERR_KM = {"07_05": 300.0, "08_01": 354.0, "08_02": 202.0, "08_09": 251.0}
TEMP_ONLY_POOLED_KM = 276.0
GPE3_BASELINE_KM = 54.0
# Fused land-barrier baseline (binary depth floor + strict mask), the run this
# experiment is measured against. Per-tag land-aware-path-vs-Argos medians and the
# pooled median, recorded so the ablation table carries the before/after delta.
FUSED_BARRIER_ERR_KM = {
"07_05": 386.2, "08_01": 66.1, "08_02": 228.8, "08_09": 158.7}
FUSED_BARRIER_POOLED_KM = 189.6
clean_status = json.loads((CLEAN_DIR / "clean_status.json").read_text())Great-circle + daily-lookup helpers (identical to the siblings)¶
def gc_km(lon1, lat1, lon2, lat2):
"""Great-circle distance (km) via the haversine formula."""
r = 6371.0088
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
d = (np.sin((lat2 - lat1) / 2) ** 2
+ np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2)
return 2 * r * np.arcsin(np.sqrt(d))
def daily_lookup(track: pd.DataFrame) -> pd.DataFrame:
t = track.copy()
t["day"] = pd.to_datetime(t["time"]).dt.floor("D")
return t.groupby("day")[["longitude", "latitude"]].mean()Bathymetry → HEALPix (relaxed land mask + per-cell seabed depths)¶
Every ETOPO pixel is mapped to its level-L NESTED cell, then reduced per cell:
the land fraction is the share of pixels with elevation ≥ 0; the
shallowest seabed is -max(elev) (reporting only); the deepest seabed
max_ocean_depth_m = -min(elev) is what the depth-emission floor reads (a dive
of depth D fits iff SOME water in the cell is at least D deep — see the
depth-emission section); and min_elev = min(elev) feeds the relaxed coastal
water rule (ii). The relaxed mask + deepest-seabed floor are Changes 1 & 2.
def bathymetry_per_cell(
cells: np.ndarray, level: int
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Return (land_fraction, min_ocean_depth_m, max_ocean_depth_m, min_elev_m).
Aligned to ``cells``:
- ``min_ocean_depth_m = -max(elev)`` — the cell's SHALLOWEST point (positive m
below sea level; negative for a partly-land coastal cell). Kept for reporting.
- ``max_ocean_depth_m = -min(elev)`` — the cell's DEEPEST point. This is the
seabed the depth EMISSION floor uses: a dive of depth D fits in the cell iff
SOME water in it is at least D deep, i.e. the deepest point is ≥ D. Using the
shallowest point (the earlier bug) crushed partly-land coastal nursery cells
whose shallowest "seabed" is actually land elevation.
- ``min_elev_m = min(elev)`` — deepest footprint elevation, for the relaxed
coastal-water rule (ii).
"""
bz = xr.open_dataset(BATHY_NC)["z"]
mlon, mlat = np.meshgrid(bz["longitude"].values, bz["latitude"].values)
bcell = hgn.lonlat_to_healpix(
mlon.ravel(), mlat.ravel(), level).astype("int64")
bdf = pd.DataFrame({"cell": bcell, "elev": np.asarray(bz.values).ravel()})
frac_land = bdf.assign(land=bdf["elev"] >= 0).groupby("cell")["land"].mean()
max_elev = bdf.groupby("cell")["elev"].max() # shallowest point / highest land
min_elev = bdf.groupby("cell")["elev"].min() # deepest point in the footprint
fl = frac_land.reindex(cells).fillna(0.0).to_numpy()
me = max_elev.reindex(cells).to_numpy()
mn = min_elev.reindex(cells).to_numpy()
min_ocean_depth = -me # shallowest seabed (positive below sea level)
max_ocean_depth = -mn # deepest seabed (positive below sea level)
return fl, min_ocean_depth, max_ocean_depth, mn
def water_mask_from_bathy(
land_frac: np.ndarray, min_elev: np.ndarray
) -> np.ndarray:
"""Relaxed coastal water mask (Change 2): water iff rule (i) OR rule (ii)."""
rule_i = land_frac <= LAND_FRAC_THRESHOLD
rule_ii = np.isfinite(min_elev) & (min_elev <= MIN_ELEV_WATER_CUTOFF_M)
return rule_i | rule_ii
def persist_full_domain_bathymetry(level: int) -> None:
"""Write the canonical full-domain land-mask + depth grid once (all cells).
Independent of any tag: covers every level-`L` cell touched by the ETOPO
subset, so `data/clean/bathymetry_healpix_L{level}.nc` is the reusable grid.
"""
out_path = CLEAN_DIR / f"bathymetry_healpix_L{level}.nc"
# Always (re)write: Change 2 redefines land_mask, so a file from a prior
# strict-mask run must be regenerated, not reused. The grid is small and cheap.
if out_path.exists():
out_path.unlink()
bz = xr.open_dataset(BATHY_NC)["z"]
mlon, mlat = np.meshgrid(bz["longitude"].values, bz["latitude"].values)
bcell = hgn.lonlat_to_healpix(
mlon.ravel(), mlat.ravel(), level).astype("int64")
bdf = pd.DataFrame({"cell": bcell, "elev": np.asarray(bz.values).ravel()})
frac_land = bdf.assign(land=bdf["elev"] >= 0).groupby("cell")["land"].mean()
max_elev = bdf.groupby("cell")["elev"].max()
min_elev = bdf.groupby("cell")["elev"].min()
cells = frac_land.index.to_numpy().astype("int64")
clon, clat = hgn.healpix_to_lonlat(cells, level)
clon = ((np.asarray(clon) + 180.0) % 360.0) - 180.0
_persist_bathymetry(
out_path, cells, np.asarray(clon), np.asarray(clat),
frac_land.to_numpy(), -max_elev.to_numpy(), -min_elev.to_numpy(),
min_elev.to_numpy(), level)
def _persist_bathymetry(
out_path: Path, cells: np.ndarray, clon: np.ndarray, clat: np.ndarray,
land_frac: np.ndarray, min_ocean_depth: np.ndarray,
max_ocean_depth: np.ndarray, min_elev: np.ndarray, level: int
) -> None:
"""Save a per-cell land mask + seabed-depth grid as NetCDF.
``land_mask`` here is the RELAXED coastal mask (Change 2): water (1) where rule
(i) ``land_fraction <= LAND_FRAC_THRESHOLD`` OR rule (ii) min footprint
elevation ``<= MIN_ELEV_WATER_CUTOFF_M``. ``max_ocean_depth_m`` (deepest point)
is the seabed the depth emission floor reads.
"""
water = water_mask_from_bathy(land_frac, min_elev)
ds = xr.Dataset(
{
"land_fraction": ("cells", land_frac),
"min_ocean_depth_m": ("cells", min_ocean_depth),
"max_ocean_depth_m": ("cells", max_ocean_depth),
"min_elev_m": ("cells", min_elev),
"land_mask": ("cells", water.astype("i1")),
},
coords={"cell_ids": ("cells", cells),
"longitude": ("cells", clon), "latitude": ("cells", clat)},
)
ds["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": level, "indexing_scheme": "nested"})
ds.attrs.update({
"source": "NOAA NCEI ETOPO 2022 v1 15 arc-second relief via CoastWatch "
"ERDDAP griddap ETOPO_2022_v1_15s",
"land_frac_threshold": LAND_FRAC_THRESHOLD,
"min_elev_water_cutoff_m": MIN_ELEV_WATER_CUTOFF_M,
"coastal_mask_rule": "water iff land_fraction<=thr OR min_elev<=cutoff",
})
ds.to_netcdf(out_path)Light (twilight) emission — per event, vectorised over cells¶
def event_emission(
t_event: pd.Timestamp, kind: str, clon: np.ndarray, clat: np.ndarray
) -> np.ndarray:
"""Per-cell Gaussian over the modelled-minus-detected twilight time residual."""
ts = pd.Timestamp(t_event)
if ts.tzinfo is not None:
ts = ts.tz_localize(None)
t = Time(ts.isoformat(), scale="utc")
sun = get_sun(t)
dec, ra = sun.dec.rad, sun.ra.rad
gast = t.sidereal_time("apparent", "greenwich").rad
h0 = np.radians(SOLAR_HORIZON_DEG)
lat_r = np.radians(clat)
cos_h = ((np.sin(h0) - np.sin(lat_r) * np.sin(dec))
/ (np.cos(lat_r) * np.cos(dec)))
valid = np.abs(cos_h) <= 1.0
cos_h = np.clip(cos_h, -1.0, 1.0)
h_mag = np.arccos(cos_h)
target_h = -h_mag if kind == "sunrise" else h_mag
lha_now = gast + np.radians(clon) - ra
dlha = (target_h - lha_now + np.pi) % (2 * np.pi) - np.pi
resid_min = np.degrees(dlha) / 15.0 / 1.00273790935 * 60.0
pdf = np.exp(-0.5 * (resid_min / SIGMA_T_MIN) ** 2)
return np.where(valid, pdf, 0.0)
def daily_light_factor(
shark: str, cells: np.ndarray, clon: np.ndarray, clat: np.ndarray,
days: pd.DatetimeIndex
) -> tuple[np.ndarray, int]:
"""Daily light factor on `cells`: product of each day's events, renormalised."""
tw = pd.read_csv(
CLEAN_DIR / "tags" / shark / "twilights.csv", parse_dates=["time"])
tw["time"] = pd.to_datetime(tw["time"]).dt.tz_localize(None)
tw["day"] = tw["time"].dt.floor("D")
day_index = {d: i for i, d in enumerate(days)}
light = np.ones((len(days), cells.size)) # uniform default for empty days
n_with = 0
for d, grp in tw.groupby("day"):
if d not in day_index:
continue
fac = np.ones(cells.size)
for _, row in grp.iterrows():
e = event_emission(row["time"], row["event"], clon, clat)
s = e.sum()
if s > 0:
fac *= (e / s)
fs = fac.sum()
if fs > 0:
light[day_index[d]] = fac / fs
n_with += 1
return light, int(tw["day"].nunique()), n_withTemperature emission via the sibling pangeo-fish path¶
Returns the temperature emission Dataset on the canonical level-L cell set,
already carrying pdf(time, cells), initial, final, mask, and a daily
time axis. This is the same likelihood the 276 km temperature chain used.
def temperature_emission(shark: str, level: int) -> xr.Dataset:
"""GLORYS temperature emission on a level-`L` HEALPix grid (baseline-free grid).
Reads this repo's own DST (``data/clean/tags/<shark>/dst.csv``) and the
species-range GLORYS reference model
``data/clean/reference_model_<shark>_gulfext.nc``.
"""
tag, tag_log, time_slice = load_tag(tag_root=TAG_ROOT, tag_name=shark)
rm = (xr.open_dataset(CLEAN_DIR / f"reference_model_{shark}_gulfext.nc")
.chunk({"time": 24, "lat": -1, "lon": -1, "depth": -1})
.sel(time=time_slice))
diff, _ = compute_diff(
reference_model=rm, tag_log=tag_log,
relative_depth_threshold=RELATIVE_DEPTH_THRESHOLD, chunk_time=24)
diff = diff.compute()
diff = (diff.assign(latitude=rm["latitude"], longitude=rm["longitude"])
.swap_dims({"lat": "yi", "lon": "xi"})
.drop_vars(["lat", "lon"]).compute())
reg, _ = regrid_dataset(ds=diff, refinement_level=level, dims=["cells"])
reg["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": level, "indexing_scheme": "nested"})
em, _ = compute_emission_pdf(
diff_ds=reg, events_ds=tag["tagging_events"].ds,
differences_std=DIFFERENCES_STD, initial_std=INITIAL_STD,
recapture_std=RECAPTURE_STD, dims=["cells"])
return em.compute()Light-only state space (for 02_01, which has no temperature factor)¶
def light_only_grid(shark: str, level: int):
"""Build a bbox HEALPix grid + daily axis + endpoint anchors for 02_01."""
events = pd.read_csv(
CLEAN_DIR / "tags" / shark / "tagging_events.csv", parse_dates=["time"])
gpe3 = pd.read_csv(CLEAN_DIR / f"gpe3_{shark}.csv", parse_dates=["time"])
alon = np.concatenate([events["longitude"], gpe3["longitude"]])
alat = np.concatenate([events["latitude"], gpe3["latitude"]])
lon_min, lon_max = alon.min() - BBOX_PAD_DEG, alon.max() + BBOX_PAD_DEG
lat_min, lat_max = alat.min() - BBOX_PAD_DEG, alat.max() + BBOX_PAD_DEG
lons = np.arange(lon_min, lon_max + MESH_STEP_DEG, MESH_STEP_DEG)
lats = np.arange(lat_min, lat_max + MESH_STEP_DEG, MESH_STEP_DEG)
mlon, mlat = np.meshgrid(lons, lats)
cells = np.unique(hgn.lonlat_to_healpix(
mlon.ravel(), mlat.ravel(), level).astype("int64"))
clon, clat = hgn.healpix_to_lonlat(cells, level)
clon = ((np.asarray(clon) + 180.0) % 360.0) - 180.0
clat = np.asarray(clat)
# Daily axis spanning release..pop-up.
t0 = pd.to_datetime(events.loc[events.event_name == "release", "time"].iloc[0])
t1 = pd.to_datetime(
events.loc[events.event_name == "fish_death", "time"].iloc[0])
days = pd.date_range(t0.floor("D"), t1.floor("D"), freq="1D").tz_localize(None)
grid = xr.Dataset(coords={
"cell_ids": ("cells", cells),
"latitude": ("cells", clat), "longitude": ("cells", clon)})
grid["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": level, "indexing_scheme": "nested"})
grid = to_healpix(grid)
release = events[events.event_name == "release"].iloc[0]
popup = events[events.event_name == "fish_death"].iloc[0]
initial = distrib_hp.normal_at(
grid, pos=xr.Dataset({"longitude": float(release.longitude),
"latitude": float(release.latitude)}),
sigma=INITIAL_STD)
final = distrib_hp.normal_at(
grid, pos=xr.Dataset({"longitude": float(popup.longitude),
"latitude": float(popup.latitude)}),
sigma=RECAPTURE_STD)
return cells, clon, clat, days, initial, finalDaily bathymetric depth EMISSION from the DST dive record (Change 1)¶
Why hand-rolled, not pangeo_fish.bathy. pangeo_fish.bathy provides
compute_healpix_histogram_region_bin_size (a per-cell seabed-depth survival
curve 1 - cumsum(hist)) and batch_compute_pdf_bathy, which evaluates that
survival curve at each day’s max(pressure) - XE + 10 bin. Its semantics are
exactly the soft floor we want — but the batch entry point is rigidly coupled
to a {target_root}/diff-regridded.zarr reference store and an XE reference
field that must share its own nside-based histogram cell set. This pipeline’s
emission cells come from the sibling temperature regrid (level-9 NESTED), and we
materialise neither diff-regridded.zarr nor an XE field on that grid, so
wiring the batch API would mean reconstructing those artefacts. We therefore
build an EQUIVALENT floor-aware soft likelihood by hand with the same
survival-curve semantics, stated here as a deviation.
Formulation. For each day t let D_t = max_o pressure[t, o] be the deepest
dive that day (the binding floor constraint — the seabed must be at least this
deep). For a cell c we use its DEEPEST seabed point S_c (max_ocean_depth_m
= -min(elev)): a dive of depth D_t fits in the cell iff SOME water in it is at
least that deep, so the deepest point is the correct reduction. (Using the cell’s
SHALLOWEST point — an earlier bug — read land elevation as “seabed” on partly-land
coastal cells and wrongly crushed exactly the shallow-nursery cells the relaxed
mask admits; a 08_01 diagnostic showed the true Argos cells dropping to factor
~0.05 on 26/26 days. With the deepest point they recover to ~0.996.)
depth_emission[t, c] = sigmoid( (S_c - D_t) / DEPTH_FLOOR_TOL_M )sigmoid(x) = 1/(1+e^-x). This is ~1 where the seabed is deeper than the dives
(S_c >> D_t, fully allowed — a 50 m-diving shark over 2000 m water is NOT
penalised), 0.5 at the boundary S_c = D_t, and →0 only where the deepest seabed
is still many tens of metres SHALLOWER than depths the animal demonstrably reached
(physically impossible). It is the smooth, single-evaluation analogue of
bathy.py’s survival curve read at the max-dive bin. Cells with unknown seabed
(S_c NaN) get 0. We deliberately do NOT add a benthic-association upweight (the
task’s optional term): keeping it a pure floor avoids over-penalising pelagic
days, as requested. Days with no DST coverage default to uniform (factor 1).
def _sigmoid(x: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(x, -60.0, 60.0)))
def daily_depth_emission(
shark: str, cells: np.ndarray, days: pd.DatetimeIndex,
seabed_depth: np.ndarray, land_mask: np.ndarray
) -> np.ndarray:
"""Daily soft, floor-aware depth emission (Change 1); uniform if no DST.
``seabed_depth`` is the cell's DEEPEST point (``max_ocean_depth_m``), positive
metres below sea level — see the depth-emission markdown for why deepest, not
shallowest.
"""
emis = np.ones((len(days), cells.size))
dst_path = CLEAN_DIR / "tags" / shark / "dst.csv"
if not dst_path.exists():
return emis # no DST -> no depth constraint (uniform)
dst = pd.read_csv(dst_path, parse_dates=["time"])
dst["day"] = pd.to_datetime(dst["time"]).dt.floor("D").dt.tz_localize(None)
dmax = dst.groupby("day")["pressure"].max()
finite = np.isfinite(seabed_depth)
for i, d in enumerate(days):
if d not in dmax.index:
continue
d_t = float(dmax.loc[d])
fac = np.zeros(cells.size)
fac[finite] = _sigmoid((seabed_depth[finite] - d_t) / DEPTH_FLOOR_TOL_M)
# Guard: if the soft factor would zero out every water cell for this day
# (would collapse the day), fall back to uniform so light×temp still decide.
if (fac * (land_mask > 0)).sum() <= 0:
continue
emis[i] = fac
return emisHMM fit (or reuse cached σ) + MAP (mode) decode on a fused daily emission¶
The expensive step is the EagerBoundsSearch σ-fit. When a previous run already
fitted σ for this tag (cached in results/rec_<tag>.json under sigma_fused),
we reuse it and only re-decode — far cheaper. The decode uses the per-day
mode (MAP cell), which selects an allowed ocean cell and (unlike the mean)
never averages onto land — see the note above on why Viterbi is unavailable on
the HEALPix grid. The per-day posterior from predict_proba is cached to
results/posterior_<tag>.nc.
def _build_em_hp(
cells: np.ndarray, clon: np.ndarray, clat: np.ndarray,
times: np.ndarray, fused: np.ndarray, initial, final,
land_mask: np.ndarray, level: int
):
"""Assemble + normalise the HEALPix emission Dataset; return (em_hp, max_σ)."""
em = xr.Dataset(
{"pdf": (("time", "cells"), fused)},
coords={"time": times, "cell_ids": ("cells", cells),
"latitude": ("cells", clat), "longitude": ("cells", clon)})
em["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": level, "indexing_scheme": "nested"})
em = to_healpix(em)
em = em.assign(
initial=initial.fillna(0.0) if hasattr(initial, "fillna") else initial,
final=final.fillna(0.0) if hasattr(final, "fillna") else final,
mask=("cells", (land_mask > 0)))
normd, _ = normalize_pdf(ds=em, chunks={"time": 24})
normd = normd.compute()
normd["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": level, "indexing_scheme": "nested"})
em_hp = to_healpix(normd)
tv = em_hp["time"].values
dt_h = float(np.median(np.diff(tv)) / np.timedelta64(1, "h"))
max_sigma = (MAX_SPEED.to("km/h").magnitude * dt_h * ADJUSTMENT_FACTOR
/ EARTH_RADIUS.to("km").magnitude)
return em_hp, max_sigma
def _estimator(em_hp, sigma, blocked_keys: np.ndarray, shark: str = "default"):
"""Build an EagerEstimator with the LAND-BARRIER predictor factory.
The movement kernel forbids transitions whose great-circle segment crosses land
(``LandBarrierGaussian1DHealpix``). ``blocked_keys`` (the σ-independent
precomputed forbidden-pair set) is reused across every predictor build the
EagerBoundsSearch σ-fit constructs.
"""
pf = curry(
LandBarrierGaussian1DHealpix, cell_ids=em_hp["cell_ids"].data,
grid_info=em_hp.dggs.grid_info, truncate=TRUNCATE,
weights_threshold=1e-8,
pad_kwargs={"mode": "constant", "constant_value": 0},
optimize_convolution=True,
blocked_keys=blocked_keys, barrier_tag=shark)
return EagerEstimator(sigma=sigma, predictor_factory=pf)
def _traj_to_df(traj, traj_id: str) -> pd.DataFrame:
"""Extract one named trajectory as a daily lon/lat DataFrame."""
t = next(t for t in traj.trajectories if t.id == traj_id)
df = t.df.copy()
return pd.DataFrame({
"time": pd.to_datetime(df.index).tz_localize(None),
"longitude": df.geometry.x.values, "latitude": df.geometry.y.values,
}).set_index("time").resample("1D").mean().dropna().reset_index()
def fit_and_decode(
cells: np.ndarray, clon: np.ndarray, clat: np.ndarray,
times: np.ndarray, fused: np.ndarray, initial, final,
land_mask: np.ndarray, level: int, shark: str,
cached_sigma: float | None = None,
) -> tuple[pd.DataFrame, pd.DataFrame, float, float, int]:
"""Fit (or reuse) σ, run predict_proba + mode/mean decode.
Returns (mode_track, mean_track, σ, σ_max, n_barrier_fallback_rows). The per-day
posterior is written to ``results/posterior_<shark>.nc``.
"""
em_hp, max_sigma = _build_em_hp(
cells, clon, clat, times, fused, initial, final, land_mask, level)
# Precompute the σ-independent land-crossing pair set ONCE for this grid; the
# bounds-search reuses it across every predictor build (see the barrier section).
grid_cells = np.asarray(em_hp["cell_ids"].data).astype("int64")
gclon, gclat = hgn.healpix_to_lonlat(grid_cells, level)
gclon = ((np.asarray(gclon) + 180.0) % 360.0) - 180.0
gclat = np.asarray(gclat)
_t0 = pd.Timestamp.utcnow()
blocked_keys = precompute_blocked_pairs(
grid_cells, gclon, gclat, em_hp.dggs.grid_info, level)
_dt = (pd.Timestamp.utcnow() - _t0).total_seconds()
print(f" [barrier] precomputed {blocked_keys.size} blocked land-crossing "
f"pairs (≤{BARRIER_MAX_KM:.0f} km) in {_dt:.1f}s", flush=True)
if cached_sigma is not None and np.isfinite(cached_sigma):
sigma = float(cached_sigma)
optimized = _estimator(em_hp, sigma, blocked_keys, shark)
print(f" reusing cached sigma={sigma:.5g} (skipping bounds search)",
flush=True)
else:
estimator = _estimator(em_hp, None, blocked_keys, shark)
optimizer = EagerBoundsSearch(
estimator, (1e-4, max_sigma), optimizer_kwargs={"xtol": TOLERANCE})
optimized = optimizer.fit(em_hp)
sigma = float(optimized.sigma)
print(f" fitted sigma={sigma:.5g} (bounds search)", flush=True)
# Per-day posterior (forward-backward), cached to NetCDF (never .npz). This
# builds the barrier predictor at the fitted σ, so the fallback-row counter
# below reflects the final kernel actually used for the posterior.
states = optimized.predict_proba(em_hp) # DataArray "states" (time, cells)
n_barrier_fallback = int(_BARRIER_FALLBACK_ROWS.get(shark, 0))
post = states.to_dataset(name="posterior")
post["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": level, "indexing_scheme": "nested"})
post.attrs.update({"tag": shark, "sigma": sigma, "decode": "mode"})
post.to_netcdf(RESULTS_DIR / f"posterior_{shark}.nc")
states_ds = post.rename({"posterior": "states"})
traj = optimized.decode(
em_hp, states_ds.fillna(0), mode=["mode", "mean"], progress=False)
mode_track = _traj_to_df(traj, "mode")
mean = _traj_to_df(traj, "mean")
return mode_track, mean, sigma, max_sigma, n_barrier_fallbackFuse the factors for one tag, run the HMM¶
Returns the fused daily track plus diagnostics (σ, σ_max, level, twilight count, land-mask cell count, before/after on-land point counts).
On-land check by sampling ETOPO elevation at each track point¶
Independent of the HEALPix mask: bilinear-nearest lookup into the raw ETOPO
relief (z), z >= 0 = land. This is the honest land test for the Viterbi
track and the safety-net snap target.
def _etopo_elev_at(lons: np.ndarray, lats: np.ndarray) -> np.ndarray:
"""Nearest-pixel ETOPO elevation (m) at each (lon, lat)."""
bz = xr.open_dataset(BATHY_NC)["z"]
return bz.sel(longitude=xr.DataArray(np.asarray(lons), dims="p"),
latitude=xr.DataArray(np.asarray(lats), dims="p"),
method="nearest").values
def n_land_by_etopo(track: pd.DataFrame) -> int:
"""Count track daily points whose ETOPO elevation is >= 0 (on land)."""
if track.empty:
return 0
elev = _etopo_elev_at(track["longitude"].values, track["latitude"].values)
return int(np.nansum(elev >= 0))
def snap_to_ocean(
track: pd.DataFrame, cells: np.ndarray, clon: np.ndarray, clat: np.ndarray,
land_mask: np.ndarray
) -> tuple[pd.DataFrame, int]:
"""Snap any on-land (ETOPO z>=0) point to the nearest allowed ocean cell."""
elev = _etopo_elev_at(track["longitude"].values, track["latitude"].values)
bad = np.where(elev >= 0)[0]
if bad.size == 0:
return track, 0
ocean = land_mask > 0
olon, olat = clon[ocean], clat[ocean]
t = track.copy()
for i in bad:
d = gc_km(t.loc[i, "longitude"], t.loc[i, "latitude"], olon, olat)
j = int(np.argmin(d))
t.loc[i, "longitude"] = olon[j]
t.loc[i, "latitude"] = olat[j]
return t, int(bad.size)def run_fused_hmm(shark: str, level: int, cached_sigma: float | None = None) -> dict:
has_temp = shark in HAS_TEMPERATURE
if has_temp:
temp_em = temperature_emission(shark, level)
cells = temp_em["cell_ids"].values.astype("int64")
clon, clat = hgn.healpix_to_lonlat(cells, level)
clon = ((np.asarray(clon) + 180.0) % 360.0) - 180.0
clat = np.asarray(clat)
times = temp_em["time"].values
days = pd.to_datetime(times).floor("D")
# compute_emission_pdf leaves NaN on GLORYS-masked cells; treat those as
# zero likelihood so the fused product is finite.
temp_pdf = np.nan_to_num(
temp_em["pdf"].transpose("time", "cells").values, nan=0.0)
# GLORYS coverage mask (cells with any ocean field): combined with the
# ETOPO land mask below so the HMM mask is the intersection of "ocean per
# GLORYS" and "ocean per ETOPO".
glorys_mask = np.nan_to_num(temp_em["mask"].values, nan=0.0) > 0
initial, final = temp_em["initial"], temp_em["final"]
else:
cells, clon, clat, days, initial, final = light_only_grid(shark, level)
times = days.to_numpy()
temp_pdf = np.ones((len(days), cells.size)) # uniform: no SST factor
glorys_mask = np.ones(cells.size, dtype=bool)
# Bathymetry on these cells (Change 2: relaxed coastal water mask).
land_frac, min_ocean_depth, max_ocean_depth, min_elev = bathymetry_per_cell(
cells, level)
_persist_bathymetry(
CLEAN_DIR / f"bathymetry_healpix_L{level}_{shark}.nc",
cells, clon, clat, land_frac, min_ocean_depth, max_ocean_depth,
min_elev, level)
water_relaxed = water_mask_from_bathy(land_frac, min_elev)
land_mask = np.where(water_relaxed & glorys_mask, 1.0, 0.0)
# Confirm the relaxed mask opens the Vizcaíno nursery cell (diagnostic only).
_viz_idx = int(np.argmin(gc_km(VIZCAINO_LON, VIZCAINO_LAT, clon, clat)))
viz_is_water = bool(land_mask[_viz_idx] > 0)
viz_dist_km = float(gc_km(
VIZCAINO_LON, VIZCAINO_LAT, clon[_viz_idx], clat[_viz_idx]))
# Light factor (daily).
light_pdf, n_tw_days, n_light_days = daily_light_factor(
shark, cells, clon, clat, days)
# Depth EMISSION (daily, soft floor-aware — Change 1); uniform if no DST.
# Uses the cell's DEEPEST seabed point (max_ocean_depth), not the shallowest.
depth_emission = daily_depth_emission(
shark, cells, days, max_ocean_depth, land_mask)
# Fuse and normalise per day, recovering any day that collapses to all-zero.
fused = temp_pdf * light_pdf * land_mask[None, :] * depth_emission
norm = fused.sum(axis=1, keepdims=True)
collapsed = (norm[:, 0] == 0)
for i in np.where(collapsed)[0]:
fallback = temp_pdf[i] * land_mask
s = fallback.sum()
fused[i] = fallback / s if s > 0 else light_pdf[i]
norm[i, 0] = fused[i].sum()
fused = np.where(norm > 0, fused / norm, 0.0)
mode_track, mean_track, sigma, max_sigma, n_barrier_fallback = fit_and_decode(
cells, clon, clat, times, fused, initial, final, land_mask, level,
shark, cached_sigma=cached_sigma)
# Keep the old posterior-mean track for reference; the headline track is now
# the per-day MAP (mode) path.
mean_track.to_csv(
RESULTS_DIR / f"fused_track_mean_{shark}.csv", index=False)
# On-land check via ETOPO elevation (z >= 0 = land).
n_land_mean = n_land_by_etopo(mean_track)
n_land_mode_raw = n_land_by_etopo(mode_track)
mode_track, n_snapped = snap_to_ocean(
mode_track, cells, clon, clat, land_mask)
n_land_mode = n_land_by_etopo(mode_track)
# The per-day MODE track is now the *reference* track; the headline
# fused_track_<tag>.csv is rebuilt below by the land-aware Viterbi path.
mode_track.to_csv(RESULTS_DIR / f"fused_track_mode_{shark}.csv", index=False)
# BEFORE reference: the prior light-only track (all-true mask), ETOPO-tested.
before_path = RESULTS_DIR / f"light_track_{shark}.csv"
n_land_before = (n_land_by_etopo(pd.read_csv(before_path))
if before_path.exists() else None)
return {
"track": mode_track, "sigma": sigma, "max_sigma": max_sigma,
"level": level, "n_twilight_days": n_tw_days,
"n_light_days": n_light_days, "has_temperature": has_temp,
"n_land_mask_cells": int((land_mask == 0).sum()),
"n_collapsed_days": int(collapsed.sum()),
"n_land_before": n_land_before, "n_land_after": n_land_mode,
"n_land_mean": n_land_mean,
"n_land_mode_raw": n_land_mode_raw, "n_snapped": n_snapped,
"n_barrier_fallback_rows": n_barrier_fallback,
"viz_nursery_is_water": viz_is_water,
"viz_nursery_cell_dist_km": viz_dist_km,
"depth_emission_active": bool(
(CLEAN_DIR / "tags" / shark / "dst.csv").exists()),
}Land-aware most-probable PATH (custom constrained Viterbi)¶
The per-day mode decode above treats each day independently, so the headline
track can JUMP across the Baja peninsula between consecutive days (a physically
impossible land-crossing seen at ~29°N). pangeo-fish 2026.4.0’s built-in Viterbi
only runs on the legacy flat (x, y) grid, not the 1-D HEALPix cells grid we
use, so we build the jointly most-probable, land-respecting path ourselves
directly from the cached results/posterior_<tag>.nc (no HMM re-fit, no GLORYS
re-read).
Node score at day t, cell i: log(posterior[t, i] + EPS). For
tractability each day’s candidate set is restricted to the top-K cells by
posterior (VITERBI_K).
Transition (day t-1 cell j → day t cell i) is allowed iff BOTH:
(a) great-circle distance ≤ MAX_DAILY_KM, and
(b) the great-circle segment j→i does not cross land. The land test samples
interior points at a resolution-aware density (_adaptive_n_samples):
at least one sample per ETOPO pixel the segment traverses (sample step ≤
the ~0.4 km ETOPO pixel), so a sub-km island cannot hide between samples.
ETOPO z ≥ 0 = land.
A forbidden transition has potential -inf.
MAX_DAILY_KM justification. The σ-fit prior caps sustained speed at
MAX_SPEED = 5 km/h (≈120 km/day for a juvenile white shark). A hard 120 km/day
cap leaves no admissible water path around the Cabo San Lázaro / Baja cape on
some days (the detour around the cape exceeds the straight-line daily step), so
we allow headroom at 200 km/day. This is loose enough to round the cape but
still far below the width of the Baja isthmus, so it cannot license a straight
land-crossing — condition (b) is the real guard and is verified to give zero
land-crossing segments below. If a tag still has no admissible path we relax
MAX_DAILY_KM stepwise, then VITERBI_K, recording every relaxation honestly.
Endpoint anchoring (soft, log-prior). Rather than forcing days 0 and N to a
single cell — which is brittle: if that exact cell is unreachable from the
neighbouring day’s top-K through water the whole path fails — we add a Gaussian
log-prior to those two days’ node scores, centred on the water cell nearest the
release / pop-up position (from data/clean/tags/<tag>/tagging_events.csv), with
spread ANCHOR_SIGMA_KM. The anchor cell is also injected into those days’
candidate sets so it is always available. This pulls the path to the endpoints
without making a single unreachable cell fatal. Argos is never used.
EPS = 1e-300 # floor inside log(posterior + EPS) so zero-posterior cells -> -inf-ish
VITERBI_K = 600 # candidate cells per day (top-K by posterior)
VITERBI_K_RELAX = [600, 1200, 2400] # K relaxation ladder if no path exists
MAX_DAILY_KM = 200.0 # see justification above (~5 km/h sustained + headroom)
MAX_DAILY_KM_RELAX = [200.0, 300.0, 450.0, 600.0] # km/day relaxation ladder
ANCHOR_SIGMA_KM = 30.0 # Gaussian spread of the release/pop-up endpoint log-prior
# --- Adaptive land-crossing sampler ---
# A fixed sample count (the old N_SEGMENT_SAMPLES=21) is resolution-blind: on a
# ~58 km step it spaces samples ~3 km apart, so a sub-km island (e.g. a Channel
# Island grazed by tag 08_09 seg 19) hides BETWEEN samples and the step is wrongly
# allowed. ETOPO 2022 15-arc-second pixels are ~0.46 km on the latitude axis and
# ~0.39 km on the longitude axis at this latitude band. We sample at least one
# point per ETOPO pixel the segment traverses: n_samples = max(20, ceil(seg_km /
# ETOPO_PIXEL_KM) + 1), so the sample step is <= the pixel size and no island can
# fall through. Use the SMALLER pixel dimension (longitude) as the nominal pixel
# size, which is conservative (denser sampling). Cap at MAX_SEGMENT_SAMPLES so a
# pathological long step can't blow up memory (06_10's longest legitimate steps
# are ~200 km => ~1000 samples here, well under the cap).
#
# FINER-CHECK FIX (gulfext referee re-run): the Gulf-of-California coastline and
# its islands are finer than the 0.39 km ETOPO nominal pixel resolved on some long
# segments — an independent dense check found 1 residual land-crossing in the
# 07_05 gulfext path. We tighten the segment-sample spacing to <= LAND_CHECK_KM
# (0.2 km), well below the ETOPO pixel size, so BOTH the land-barrier kernel's
# segment test AND the path-DP's segment test (both routed through
# `_segment_crosses_land`) sample densely enough that no island graze can slip
# through. The independent dense verification below uses the same 0.2 km spacing.
LAND_CHECK_KM = 0.2 # target sample spacing for ALL land-crossing tests
ETOPO_PIXEL_KM = 0.39 # smaller (longitude) ETOPO pixel dimension in this domain
MIN_SEGMENT_SAMPLES = 20
MAX_SEGMENT_SAMPLES = 12000
def _adaptive_n_samples(seg_km: float) -> int:
"""Samples for one segment: spacing <= LAND_CHECK_KM (0.2 km), capped defensively."""
n = int(np.ceil(seg_km / LAND_CHECK_KM)) + 1
return int(min(MAX_SEGMENT_SAMPLES, max(MIN_SEGMENT_SAMPLES, n)))
def _dense_n_samples(seg_km: float) -> int:
"""Independent verification sampler: spacing <= LAND_CHECK_KM (0.2 km)."""
n = int(np.ceil(seg_km / LAND_CHECK_KM)) + 1
return int(min(MAX_SEGMENT_SAMPLES, max(200, n)))
def _etopo_axes() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (lon_axis, lat_axis, z[lat, lon]) from the ETOPO subset, cached."""
bz = xr.open_dataset(BATHY_NC)
return (bz["longitude"].values, bz["latitude"].values,
np.asarray(bz["z"].values))
_ETOPO_LON, _ETOPO_LAT, _ETOPO_Z = _etopo_axes()
def _etopo_z_vec(lons: np.ndarray, lats: np.ndarray) -> np.ndarray:
"""Vectorised nearest-pixel ETOPO elevation via searchsorted on both axes."""
ix = np.clip(np.searchsorted(_ETOPO_LON, lons), 0, _ETOPO_LON.size - 1)
iy = np.clip(np.searchsorted(_ETOPO_LAT, lats), 0, _ETOPO_LAT.size - 1)
return _ETOPO_Z[iy, ix]
def _segment_crosses_land(
lon0: np.ndarray, lat0: np.ndarray, lon1: np.ndarray, lat1: np.ndarray,
) -> np.ndarray:
"""For each segment (j->i) test whether any INTERIOR sample is on land.
The sample DENSITY is resolution-aware (see `_adaptive_n_samples`): every batch
of segments is sampled at the count required by the LONGEST segment in the batch,
so the per-segment sample step is <= one ETOPO pixel and no island can hide
between samples. (Using the batch max is conservative — shorter segments in the
batch get even finer sampling.) Linear interpolation in lon/lat is an adequate
proxy for the great-circle segment at these short (<= a few hundred km) daily
steps. Returns a boolean array, True where the segment crosses land (forbidden).
"""
lon0 = np.asarray(lon0, dtype="float64")
lat0 = np.asarray(lat0, dtype="float64")
lon1 = np.asarray(lon1, dtype="float64")
lat1 = np.asarray(lat1, dtype="float64")
if lon0.size == 0:
return np.zeros(0, dtype=bool)
seg_km = gc_km(lon0, lat0, lon1, lat1)
out = np.zeros(lon0.size, dtype=bool)
# Each segment gets its OWN resolution-aware sample count (spacing <=
# LAND_CHECK_KM). To stay vectorised without sampling every short segment at the
# global-max count (wasteful at 0.2 km), group segments by their required count
# via a small set of log-spaced length buckets and sample each bucket once. The
# sample STEP for every segment is still <= LAND_CHECK_KM (a segment is placed in
# a bucket whose count is >= its own required count), so no island can hide.
req = np.array([_adaptive_n_samples(float(s)) for s in seg_km])
# Round each requirement UP to the next power-of-two bucket to bound the number
# of distinct sampler passes (<= ~14 buckets between 20 and 12000).
bucket = np.where(req <= MIN_SEGMENT_SAMPLES, MIN_SEGMENT_SAMPLES,
1 << np.ceil(np.log2(np.maximum(req, 1))).astype(int))
bucket = np.minimum(bucket, MAX_SEGMENT_SAMPLES)
for n_samples in np.unique(bucket):
sel = np.where(bucket == n_samples)[0]
fracs = np.linspace(0.0, 1.0, int(n_samples))[1:-1] # interior points
slon = lon0[sel, None] + (lon1[sel] - lon0[sel])[:, None] * fracs[None, :]
slat = lat0[sel, None] + (lat1[sel] - lat0[sel])[:, None] * fracs[None, :]
z = _etopo_z_vec(slon.ravel(), slat.ravel()).reshape(slon.shape)
out[sel] = np.any(z >= 0.0, axis=1)
return out
def _nearest_water_cell(
lon0: float, lat0: float, clon: np.ndarray, clat: np.ndarray,
water: np.ndarray,
) -> int:
"""Index of the water cell nearest (lon0, lat0); falls back to all cells."""
idx = np.where(water)[0]
if idx.size == 0:
idx = np.arange(clon.size)
d = gc_km(lon0, lat0, clon[idx], clat[idx])
return int(idx[int(np.argmin(d))])LAND-BARRIER movement model — the methodological centerpiece¶
Gaussian1DHealpix builds a pure distance-based Gaussian transition kernel that
is blind to land: a single HMM step can move probability straight ACROSS a
peninsula (e.g. ~150 km across Baja at constant 28°N) instead of being forced to
round the cape. The land mask only zeroes the emission on land cells; the
movement model still teleports between water cells on opposite sides of land.
LandBarrierGaussian1DHealpix subclasses the predictor and, after the parent
builds the sparse Gaussian kernel, zeroes every kernel entry (i→j) whose
great-circle segment crosses land, then renormalizes each row so the
surviving outgoing weights still sum to 1 (probability conservation). The
diagonal/self entry (i→i) is never blocked.
Efficiency: the blocked-pair set is precomputed ONCE per grid¶
A pair’s blocked status is σ-independent pure geometry, and the
EagerBoundsSearch σ-fit rebuilds the predictor many times (scipy.fminbound
over σ ∈ [1e-4, σ_max]), with large-σ kernels reaching hundreds of millions of
non-zeros. Re-running the ETOPO land test per σ is intractable. Instead,
precompute_blocked_pairs runs the same adaptive ETOPO 15-arc-second segment
sampler (_segment_crosses_land) ONCE over every cell pair within
BARRIER_MAX_KM (memory-safe chunks), returning a sorted array of packed
(min_cid, max_cid) keys. Each predictor build then only does a vectorised
searchsorted membership test on its kernel’s off-diagonal pairs — O(nnz) and
cheap. Pairs beyond BARRIER_MAX_KM (≈ a physically impossible one-day move) are
left at their tiny plain Gaussian weight: at the fitted σ their weight is already
below weights_threshold, and they cannot license a peninsula crossing because
the Baja isthmus is far narrower than BARRIER_MAX_KM.
# Max great-circle distance (km) for which transitions are land-checked. ~5 km/h
# sustained ⇒ ~120 km/day; 200 km gives headroom to round a cape while staying far
# below the ~150 km Baja crossing the barrier must forbid (the land test, not this
# cap, is the real guard for pairs within it).
BARRIER_MAX_KM = 200.0
_PAIR_KEY_SHIFT = 25 # cell ids at L9 < 12*4^9 ≈ 1.26e7 < 2^25 — packs (min,max) in int64
# Per-tag counter of rows that fell back to self-only (all neighbours blocked).
_BARRIER_FALLBACK_ROWS: dict[str, int] = {}
# Per-grid count of precomputed blocked land-crossing pairs (keyed by call id) —
# populated by precompute_blocked_pairs; summarised in aggregate.json.
_BLOCKED_PAIR_CACHE: dict[str, int] = {}
def _pack_pairs(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Pack undirected cell-id pairs into sorted int64 keys (min<<shift | max)."""
lo = np.minimum(a, b).astype("int64")
hi = np.maximum(a, b).astype("int64")
return (lo << _PAIR_KEY_SHIFT) | hi
def precompute_blocked_pairs(
cells: np.ndarray, clon: np.ndarray, clat: np.ndarray,
grid_info, level: int, chunk: int = 200_000,
) -> np.ndarray:
"""Sorted packed keys of all within-`BARRIER_MAX_KM` pairs that cross land.
σ-independent; computed ONCE per tag grid and reused across every predictor
build. Uses the kernel-ring neighbour query to enumerate candidate pairs, the
great-circle cap to drop impossibly-long moves, and the adaptive ETOPO sampler
(chunked) for the land test.
"""
from healpix_convolution.neighbours import neighbours
n = cells.size
ring = int(np.ceil(BARRIER_MAX_KM / 6.4)) + 1 # ~6.4 km L9 cells
nb = neighbours(cells, grid_info=grid_info, ring=ring)
idx = np.searchsorted(cells, nb)
valid = nb != -1
src = np.broadcast_to(np.arange(n)[:, None], nb.shape)
s = src[valid]
ti = np.clip(idx[valid], 0, n - 1)
ok = cells[ti] == nb[valid] # drop neighbours outside this grid's cell set
s, ti = s[ok], ti[ok]
keep = s < ti # undirected unique pairs only
s, ti = s[keep], ti[keep]
d = gc_km(clon[s], clat[s], clon[ti], clat[ti])
within = d <= BARRIER_MAX_KM
s, ti = s[within], ti[within]
blocked = np.zeros(s.size, dtype=bool)
for i in range(0, s.size, chunk):
sl, tl = s[i:i + chunk], ti[i:i + chunk]
blocked[i:i + chunk] = _segment_crosses_land(
clon[sl], clat[sl], clon[tl], clat[tl])
keys = _pack_pairs(cells[s[blocked]], cells[ti[blocked]])
keys = np.unique(keys) # sorted, for searchsorted membership
_BLOCKED_PAIR_CACHE[f"grid_{len(_BLOCKED_PAIR_CACHE)}_{n}cells"] = int(keys.size)
return keys
@dataclass
class LandBarrierGaussian1DHealpix(Gaussian1DHealpix):
"""Gaussian HEALPix movement kernel with land-crossing transitions forbidden.
Identical to the parent except that, after the gaussian kernel is built, every
transition whose cell pair is in ``blocked_keys`` (a precomputed sorted array of
packed land-crossing pairs) is zeroed and each row renormalised to sum to 1. The
diagonal (self) entry is never blocked.
"""
blocked_keys: _ArrayLike = field(default_factory=lambda: np.empty(0, "int64"))
barrier_tag: str = "default"
def __post_init__(self):
super().__post_init__()
import sparse
import opt_einsum
k = self.kernel # sparse.COO (n_out, n_in); row-normalised (axis=1)
rows, cols = k.coords # row -> index into cell_ids; col -> new_cell_ids
data = k.data.copy()
out_cid = np.asarray(self.cell_ids)[rows].astype("int64")
in_cid = np.asarray(self.new_cell_ids)[cols].astype("int64")
is_diag = out_cid == in_cid
# Vectorised membership: a pair is blocked iff its packed key is present in
# the precomputed sorted blocked_keys (searchsorted). Diagonal never blocked.
bk = np.asarray(self.blocked_keys)
crosses = np.zeros(rows.size, dtype=bool)
if bk.size:
pk = _pack_pairs(out_cid, in_cid)
pos = np.searchsorted(bk, pk)
pos = np.clip(pos, 0, bk.size - 1)
crosses = (bk[pos] == pk) & ~is_diag
data = np.where(crosses, 0.0, data)
# Renormalise each row (output cell) over surviving inputs. A row that loses
# all mass (every neighbour across land — rare) is reset to self-only so the
# cell stays put rather than vanishing.
n_out = k.shape[0]
row_sum = np.zeros(n_out)
np.add.at(row_sum, rows, data)
zero_rows = np.where(row_sum <= 0.0)[0]
n_fallback = 0
if zero_rows.size:
zr = set(zero_rows.tolist())
diag_sel = is_diag & np.array([r in zr for r in rows])
data[diag_sel] = 1.0
row_sum[rows[diag_sel]] = 1.0
n_fallback = int(np.unique(rows[diag_sel]).size)
# Any zero row without a diagonal entry (should not happen) → leave as 1.
row_sum[row_sum <= 0.0] = 1.0
data = data / row_sum[rows]
_BARRIER_FALLBACK_ROWS[self.barrier_tag] = n_fallback
self.kernel = sparse.COO(
coords=np.stack([rows, cols], axis=0), data=data,
shape=k.shape, fill_value=0)
if self.optimize_convolution:
self.convolve = opt_einsum.contract_expression(
"...a,ba->...b", self.padder.cell_ids.shape, self.kernel,
constants=[1])
else:
from healpix_convolution.convolution import convolve as _conv
self.convolve = curry(_conv, kernel=self.kernel)
def _candidate_sets(
post: np.ndarray, k: int, anchor0: int, anchorN: int, water: np.ndarray,
) -> list[np.ndarray]:
"""Top-K WATER cell indices per day; the anchor cell injected into endpoints.
Candidates are restricted to cells whose centre is ocean per ETOPO (``water``),
so every path vertex is guaranteed off land (the cell-centre ETOPO test below
must then return zero on-land vertices). Anchors are water cells by
construction.
"""
n_days = post.shape[0]
water_idx = np.where(water)[0]
cands: list[np.ndarray] = []
for t in range(n_days):
row = post[t]
masked = np.where(water, row, -np.inf)
kk = min(k, water_idx.size)
top = np.argpartition(masked, -kk)[-kk:]
top = top[np.isfinite(masked[top])] # drop any non-water filler
cands.append(top.astype("int64"))
cands[0] = np.unique(np.append(cands[0], anchor0)).astype("int64")
cands[-1] = np.unique(np.append(cands[-1], anchorN)).astype("int64")
return cands
def _anchor_logprior(
cells: np.ndarray, clon: np.ndarray, clat: np.ndarray, anchor: int,
) -> np.ndarray:
"""Gaussian log-prior over `cells` centred on the anchor cell's position."""
d = gc_km(clon[anchor], clat[anchor], clon[cells], clat[cells])
return -0.5 * (d / ANCHOR_SIGMA_KM) ** 2
def _viterbi_path(
post: np.ndarray, clon: np.ndarray, clat: np.ndarray,
cands: list[np.ndarray], max_daily_km: float,
anchor0: int, anchorN: int,
) -> tuple[np.ndarray | None, int]:
"""Constrained Viterbi forward+backtrack over per-day candidate sets.
Node score = log(posterior + EPS), plus a Gaussian anchor log-prior on the two
endpoint days. Returns (path_cell_indices over days, fail_day). path is None if
no admissible path exists, with fail_day the first unreachable day.
"""
n_days = len(cands)
log_node = [np.log(post[t, cands[t]] + EPS) for t in range(n_days)]
log_node[0] = log_node[0] + _anchor_logprior(cands[0], clon, clat, anchor0)
log_node[-1] = log_node[-1] + _anchor_logprior(cands[-1], clon, clat, anchorN)
score = log_node[0].astype("float64") # cumulative best score into day-0 cands
back: list[np.ndarray] = []
for t in range(1, n_days):
prev_c, cur_c = cands[t - 1], cands[t]
plon, plat = clon[prev_c], clat[prev_c]
clon_t, clat_t = clon[cur_c], clat[cur_c]
new_score = np.full(cur_c.size, -np.inf)
bp = np.full(cur_c.size, -1, dtype="int64")
for ii in range(cur_c.size):
d = gc_km(clon_t[ii], clat_t[ii], plon, plat)
ok = (d <= max_daily_km) & np.isfinite(score)
if not ok.any():
continue
cand_prev = np.where(ok)[0]
crosses = _segment_crosses_land(
plon[cand_prev], plat[cand_prev],
np.full(cand_prev.size, clon_t[ii]),
np.full(cand_prev.size, clat_t[ii]))
cand_prev = cand_prev[~crosses]
if cand_prev.size == 0:
continue
trans = score[cand_prev]
jbest = int(np.argmax(trans))
new_score[ii] = trans[jbest] + log_node[t][ii]
bp[ii] = cand_prev[jbest]
if not np.isfinite(new_score).any():
return None, t
score, b = new_score, bp
back.append(b)
# Backtrack from the best final cell.
end = int(np.argmax(score))
path_local = [end]
for t in range(n_days - 1, 0, -1):
end = int(back[t - 1][end])
path_local.append(end)
path_local.reverse()
return np.array([cands[t][path_local[t]] for t in range(n_days)]), -1
def land_aware_path(shark: str) -> dict:
"""Build the land-aware most-probable path from the cached posterior.
Writes ``results/fused_track_<shark>.csv`` (the new headline track) and reports
diagnostics, including any MAX_DAILY_KM / K relaxation used and the on-land /
land-crossing verification counts.
"""
post_ds = xr.open_dataset(RESULTS_DIR / f"posterior_{shark}.nc")
post = np.nan_to_num(
post_ds["posterior"].transpose("time", "cells").values, nan=0.0)
clon = np.asarray(post_ds["longitude"].values)
clat = np.asarray(post_ds["latitude"].values)
times = pd.to_datetime(post_ds["time"].values)
# Water cells per ETOPO (z < 0) at each cell centre — used for anchoring.
water = _etopo_z_vec(clon, clat) < 0.0
events = pd.read_csv(CLEAN_DIR / "tags" / shark / "tagging_events.csv")
rel = events[events.event_name == "release"].iloc[0]
pop = events[events.event_name == "fish_death"].iloc[0]
anchor0 = _nearest_water_cell(
float(rel.longitude), float(rel.latitude), clon, clat, water)
anchorN = _nearest_water_cell(
float(pop.longitude), float(pop.latitude), clon, clat, water)
relaxations: list[str] = []
path = None
fail_day = -1
for k in VITERBI_K_RELAX:
cands = _candidate_sets(post, k, anchor0, anchorN, water)
for mk in MAX_DAILY_KM_RELAX:
path, fail_day = _viterbi_path(
post, clon, clat, cands, mk, anchor0, anchorN)
if path is not None:
if k != VITERBI_K or mk != MAX_DAILY_KM:
relaxations.append(f"K={k}, MAX_DAILY_KM={mk:.0f}")
break
if path is not None:
break
if path is None:
return {"track": None, "fail_day": fail_day,
"relaxations": relaxations, "n_on_land": None,
"n_land_cross": None, "max_daily_km": None, "k": None}
plon = clon[path]
plat = clat[path]
track = pd.DataFrame({
"time": times.tz_localize(None) if times.tz is not None else times,
"longitude": plon, "latitude": plat})
track.to_csv(RESULTS_DIR / f"fused_track_{shark}.csv", index=False)
# VERIFY (i): on-land daily vertices (ETOPO z >= 0).
n_on_land = int(np.sum(_etopo_z_vec(plon, plat) >= 0.0))
# VERIFY (ii): land-crossing segments between consecutive vertices. Run the
# adaptive sampler PER SEGMENT (so each step gets its own resolution-aware
# density), AND cross-check with an INDEPENDENT dense sampler whose spacing is
# <= LAND_CHECK_KM (0.2 km) on every segment (resolution-aware, NOT a fixed
# 200-point count — a fixed count spaces ~1 km on a 200 km step and was how the
# 07_05 gulfext residual crossing slipped through). Both must agree on zero.
n_land_cross = 0
n_land_cross_dense = 0
crossing_segs: list[int] = []
for s in range(plon.size - 1):
a = _segment_crosses_land(
plon[s:s + 1], plat[s:s + 1], plon[s + 1:s + 2], plat[s + 1:s + 2])
if bool(a[0]):
n_land_cross += 1
crossing_segs.append(s)
# independent dense cross-check, spacing <= LAND_CHECK_KM (0.2 km)
seg_km = gc_km(plon[s], plat[s], plon[s + 1], plat[s + 1])
nd = _dense_n_samples(float(seg_km))
fr = np.linspace(0.0, 1.0, nd)[1:-1]
dlon = plon[s] + (plon[s + 1] - plon[s]) * fr
dlat = plat[s] + (plat[s + 1] - plat[s]) * fr
if np.any(_etopo_z_vec(dlon, dlat) >= 0.0):
n_land_cross_dense += 1
if crossing_segs:
print(f" [{shark}] land-crossing segments (adaptive): {crossing_segs}",
flush=True)
if n_land_cross_dense != n_land_cross:
print(f" [{shark}] WARN adaptive ({n_land_cross}) vs dense-200 "
f"({n_land_cross_dense}) land-cross counts disagree", flush=True)
used_mk = MAX_DAILY_KM
used_k = VITERBI_K
if relaxations:
last = relaxations[-1]
used_k = int(last.split("K=")[1].split(",")[0])
used_mk = float(last.split("MAX_DAILY_KM=")[1])
return {"track": track, "fail_day": -1, "relaxations": relaxations,
"n_on_land": n_on_land, "n_land_cross": n_land_cross,
"max_daily_km": used_mk, "k": used_k}VERIFY the land-barrier kernel masking before trusting it¶
Three independent checks on a real level-9 grid spanning Baja California, built at a representative σ (the σ-fit explores a range; the masking is σ-independent geometry):
(a) cross-peninsula pair is blocked — a Pacific-side ~28°N cell and a Gulf-of-California ~28°N cell ~150 km apart, both within the kernel ring at this σ: its kernel weight must be 0 after masking.
(b) probability conservation — every kernel row must still sum to ≈ 1.
(c) open-water pair unchanged — a nearby open-ocean neighbour pair keeps a non-zero weight (the barrier only removes land-crossing transitions).
def _verify_barrier_kernel() -> dict:
"""Build plain vs barrier kernels on a Baja grid and run the 3 masking checks."""
persist_full_domain_bathymetry(9) # ensure the relaxed-mask canonical grid
bath = xr.open_dataset(CLEAN_DIR / "bathymetry_healpix_L9.nc")
blon = np.asarray(bath["longitude"].values)
blat = np.asarray(bath["latitude"].values)
bcell = bath["cell_ids"].values.astype("int64")
# Restrict to a Baja box big enough to contain a cross-peninsula pair.
box = ((blon >= -116.5) & (blon <= -108.5)
& (blat >= 24.0) & (blat <= 30.0))
cells = np.sort(bcell[box])
grid = xr.Dataset(coords={"cell_ids": ("cells", cells)})
grid["cell_ids"].attrs.update(
{"grid_name": "healpix", "level": 9, "indexing_scheme": "nested"})
grid = to_healpix(grid)
gi = grid.dggs.grid_info
clon, clat = hgn.healpix_to_lonlat(cells, 9)
clon = ((np.asarray(clon) + 180.0) % 360.0) - 180.0
clat = np.asarray(clat)
# σ large enough that a ~150 km transition is inside the kernel ring at L9.
sigma_test = 0.03
blocked_keys = precompute_blocked_pairs(cells, clon, clat, gi, 9)
plain = Gaussian1DHealpix(
cell_ids=cells, grid_info=gi, sigma=sigma_test, truncate=TRUNCATE,
weights_threshold=1e-8,
pad_kwargs={"mode": "constant", "constant_value": 0},
optimize_convolution=True)
barrier = LandBarrierGaussian1DHealpix(
cell_ids=cells, grid_info=gi, sigma=sigma_test, truncate=TRUNCATE,
weights_threshold=1e-8,
pad_kwargs={"mode": "constant", "constant_value": 0},
optimize_convolution=True,
blocked_keys=blocked_keys, barrier_tag="_verify")
# Pacific-side and Gulf-side ~26-28°N cells (water on both sides of Baja). Search
# for a (Pacific, Gulf) pair that (i) crosses land and (ii) is within
# BARRIER_MAX_KM so it is a candidate the barrier set actually contains.
water = _etopo_z_vec(clon, clat) < 0.0
pac = np.where(water & (clon < -113.5) & (clat >= 25.5) & (clat <= 28.5))[0]
gulf = np.where(water & (clon > -112.0) & (clat >= 25.5) & (clat <= 28.5))[0]
i_pac = i_gulf = -1
sep_km = np.nan
seg_blocked = False
for a in pac:
d = gc_km(clon[a], clat[a], clon[gulf], clat[gulf])
near = gulf[d <= BARRIER_MAX_KM]
if near.size == 0:
continue
cr = _segment_crosses_land(
np.full(near.size, clon[a]), np.full(near.size, clat[a]),
clon[near], clat[near])
hit = np.where(cr)[0]
if hit.size:
i_pac, i_gulf = int(a), int(near[hit[0]])
sep_km = float(gc_km(clon[i_pac], clat[i_pac],
clon[i_gulf], clat[i_gulf]))
seg_blocked = True
break
def _w(kernel, out_idx, in_cell_id):
rows, cols = kernel.coords
in_cid = np.asarray(barrier.new_cell_ids if kernel is barrier.kernel
else plain.new_cell_ids)[cols]
sel = (rows == out_idx) & (in_cid == in_cell_id)
return float(kernel.data[sel].sum()) if sel.any() else 0.0
# (a) cross-peninsula weight: plain vs barrier (out=Gulf cell, in=Pacific cell).
w_plain = _w(plain.kernel, i_gulf, int(cells[i_pac]))
w_barrier = _w(barrier.kernel, i_gulf, int(cells[i_pac]))
# (b) row sums after renormalisation.
rs = np.zeros(barrier.kernel.shape[0])
np.add.at(rs, barrier.kernel.coords[0], barrier.kernel.data)
row_sum_min, row_sum_max = float(rs.min()), float(rs.max())
# (c) a genuinely open-water Pacific pair: nearest Pacific neighbour whose
# segment does NOT cross land (search outward until one is found).
i_nb = -1
ow_seg_blocked = True
if i_pac >= 0:
d = gc_km(clon[i_pac], clat[i_pac], clon[pac], clat[pac])
order = pac[np.argsort(d)]
order = order[order != i_pac]
for cand in order[:50]:
if not bool(_segment_crosses_land(
clon[i_pac:i_pac + 1], clat[i_pac:i_pac + 1],
clon[cand:cand + 1], clat[cand:cand + 1])[0]):
i_nb = int(cand)
ow_seg_blocked = False
break
w_open_plain = _w(plain.kernel, i_pac, int(cells[i_nb])) if i_nb >= 0 else 0.0
w_open_barrier = _w(barrier.kernel, i_pac, int(cells[i_nb])) if i_nb >= 0 else 0.0
res = {
"cross_pair_separation_km": sep_km,
"cross_segment_crosses_land": seg_blocked,
"cross_weight_plain": w_plain,
"cross_weight_barrier": w_barrier,
"row_sum_min": row_sum_min, "row_sum_max": row_sum_max,
"open_segment_crosses_land": ow_seg_blocked,
"open_weight_plain": w_open_plain,
"open_weight_barrier": w_open_barrier,
"fallback_rows_verify": int(_BARRIER_FALLBACK_ROWS.get("_verify", 0)),
}
print("=== LAND-BARRIER KERNEL VERIFICATION (Baja L9) ===", flush=True)
print(f" (a) cross-peninsula pair {sep_km:.0f} km, "
f"segment crosses land={seg_blocked}: "
f"weight plain={w_plain:.4g} -> barrier={w_barrier:.4g} "
f"({'PASS' if seg_blocked and w_barrier == 0.0 and w_plain > 0 else 'CHECK'})",
flush=True)
print(f" (b) row sums in [{row_sum_min:.6f}, {row_sum_max:.6f}] "
f"({'PASS' if abs(row_sum_min - 1) < 1e-6 and abs(row_sum_max - 1) < 1e-6 else 'CHECK'})",
flush=True)
print(f" (c) open-water pair (crosses land={ow_seg_blocked}): "
f"weight plain={w_open_plain:.4g} -> barrier={w_open_barrier:.4g} "
f"({'PASS' if (not ow_seg_blocked) and w_open_barrier > 0 else 'CHECK'})",
flush=True)
print(f" fallback rows on the verify grid = {res['fallback_rows_verify']}",
flush=True)
return res
barrier_verification = _verify_barrier_kernel()
with open(RESULTS_DIR / "barrier_verification.json", "w") as fh:
json.dump(barrier_verification, fh, indent=2)Run every cleaned tag¶
First write the canonical full-domain level-9 bathymetry grid (the reusable
artefact data/clean/bathymetry_healpix_L9.nc), then loop the tags.
persist_full_domain_bathymetry(DEFAULT_HEALPIX_LEVEL)
# Process tags that LACK a current-emission (v4) cache first, so a wall-clock
# interruption completes the not-yet-done tags (incl. any missing referee tag)
# before re-touching tags whose σ is already cached and only need a fast decode.
def _has_current_cache(shark: str) -> bool:
p = RESULTS_DIR / f"rec_{shark}.json"
if not p.exists():
return False
c = json.loads(p.read_text())
return (c.get("emission_version") == EMISSION_VERSION
and c.get("status") == "ok")
_tag_order = sorted(TAGS, key=_has_current_cache) # un-cached (False) first
records = []
for shark in _tag_order:
pat_dep, spot_dep, has_referee = TAGS[shark]
print(f"\n=== {shark} (referee={has_referee}) ===", flush=True)
cache_path = RESULTS_DIR / f"rec_{shark}.json"
# σ MUST be re-fitted with the land-barrier movement kernel: a σ cached by the
# pre-barrier (pure Gaussian) run is no longer the bounds-search optimum once
# transitions across land are forbidden. Reuse only a σ that was itself fitted
# with the barrier model (recorded as movement_model == land_barrier...).
cached_sigma = None
if cache_path.exists():
cached = json.loads(cache_path.read_text())
cs = cached.get("sigma_fused")
same_model = (cached.get("movement_model")
== "land_barrier_gaussian1d_healpix")
# Reuse σ only if BOTH the movement model AND the emission recipe match.
# Changes 1 & 2 alter the emission, so a v2 cache must be re-fit (v3).
same_emission = (cached.get("emission_version") == EMISSION_VERSION)
if same_model and same_emission and cs is not None and np.isfinite(cs):
cached_sigma = float(cs)
print(f" reusing barrier+v3-fitted sigma_fused={cached_sigma:.5g}",
flush=True)
elif same_model and cs is not None:
print(f" cached sigma_fused={cs:.5g} is from emission "
f"'{cached.get('emission_version')}' != '{EMISSION_VERSION}'; "
f"re-fitting σ", flush=True)
level = HEALPIX_LEVEL_OVERRIDE.get(shark, DEFAULT_HEALPIX_LEVEL)
meta = json.loads(
(CLEAN_DIR / "tags" / shark / "metadata.json").read_text())
rec = {
"tag": shark, "has_referee": has_referee, "healpix_level": level,
"status": "pending",
"deploy_days": clean_status.get(shark, {}).get("window_days"),
"n_twilights": meta.get("n_twilights"),
"n_argos_fixes": clean_status.get(shark, {}).get("n_argos_class123"),
"sigma_fused": np.nan, "max_sigma_rad": np.nan, "sigma_at_bound": None,
"has_temperature": shark in HAS_TEMPERATURE,
"n_twilights_used": None, "n_land_mask_cells": None,
"emission_version": EMISSION_VERSION,
"viz_nursery_is_water": None, "viz_nursery_cell_dist_km": None,
"depth_emission_active": None,
"movement_model": "land_barrier_gaussian1d_healpix",
"n_barrier_fallback_rows": None,
"decode_mode": "constrained_viterbi_path",
"n_land_points_before": None, "n_land_points_after": None,
"n_land_points_mean": None, "n_land_points_mode_raw": None,
"n_snapped": None,
"viterbi_max_daily_km": None, "viterbi_k": None,
"viterbi_relaxations": None, "viterbi_fail_day": None,
"path_on_land_points": None, "path_land_crossing_segments": None,
"mode_fused_vs_argos_median_km": np.nan,
"light_only_err_km": LIGHT_ONLY_ERR_KM.get(shark),
"temp_only_err_km": TEMP_ONLY_ERR_KM.get(shark),
"fused_barrier_err_km": FUSED_BARRIER_ERR_KM.get(shark),
"fused_vs_argos_median_km": np.nan,
"gpe3_vs_argos_median_km": np.nan,
"fused_vs_gpe3_median_km": np.nan, "error": None,
}
if not clean_status.get(shark, {}).get("cleaned", False):
rec["status"] = "skipped_clean"
rec["error"] = clean_status.get(shark, {}).get("reason", "not cleaned")
print(f" SKIP (clean stage): {rec['error']}", flush=True)
records.append(rec)
continue
try:
out = run_fused_hmm(shark, level, cached_sigma=cached_sigma)
rec["healpix_level"] = out["level"]
rec["sigma_fused"] = out["sigma"]
rec["max_sigma_rad"] = out["max_sigma"]
rec["sigma_at_bound"] = bool(
(out["sigma"] >= out["max_sigma"] * (1 - 1e-3))
or (out["sigma"] <= 1e-4 * (1 + 1e-3)))
rec["n_twilights_used"] = out["n_light_days"]
rec["n_land_mask_cells"] = out["n_land_mask_cells"]
rec["n_barrier_fallback_rows"] = out["n_barrier_fallback_rows"]
rec["n_land_points_before"] = out["n_land_before"]
rec["n_land_points_after"] = out["n_land_after"]
rec["decode_mode"] = "mode"
rec["n_land_points_mean"] = out["n_land_mean"]
rec["n_land_points_mode_raw"] = out["n_land_mode_raw"]
rec["n_snapped"] = out["n_snapped"]
rec["viz_nursery_is_water"] = out["viz_nursery_is_water"]
rec["viz_nursery_cell_dist_km"] = out["viz_nursery_cell_dist_km"]
rec["depth_emission_active"] = out["depth_emission_active"]
print(f" [coastal mask] Vizcaíno nursery cell is water = "
f"{out['viz_nursery_is_water']} "
f"(centroid {out['viz_nursery_cell_dist_km']:.1f} km from cell); "
f"depth emission active = {out['depth_emission_active']}", flush=True)
print(f" [barrier] all-neighbours-blocked fallback rows = "
f"{out['n_barrier_fallback_rows']}", flush=True)
print(f" sigma={out['sigma']:.5g} (max {out['max_sigma']:.4g}); "
f"level={out['level']}; at_bound={rec['sigma_at_bound']}; "
f"land pts (ETOPO): mean={out['n_land_mean']} "
f"mode_raw={out['n_land_mode_raw']} "
f"mode_snapped={out['n_land_after']} (snapped {out['n_snapped']})",
flush=True)
# --- Land-aware most-probable PATH (Change 1) over the cached posterior.
mode_track = out["track"] # per-day MAP, kept as the reference comparison
vit = land_aware_path(shark)
if vit["track"] is None:
rec["status"] = "failed"
rec["viterbi_fail_day"] = vit["fail_day"]
rec["error"] = (f"no admissible land-aware path; first unreachable "
f"day index = {vit['fail_day']} after relaxations "
f"{vit['relaxations']}")
print(f" PATH FAILED: {rec['error']}", flush=True)
records.append(rec)
continue
track = vit["track"] # the new HEADLINE land-aware track
rec["viterbi_max_daily_km"] = vit["max_daily_km"]
rec["viterbi_k"] = vit["k"]
rec["viterbi_relaxations"] = "; ".join(vit["relaxations"]) or None
rec["path_on_land_points"] = vit["n_on_land"]
rec["path_land_crossing_segments"] = vit["n_land_cross"]
rec["n_land_points_after"] = vit["n_on_land"]
print(f" land-aware path: MAX_DAILY_KM={vit['max_daily_km']:.0f} "
f"K={vit['k']} relax={vit['relaxations'] or 'none'}; "
f"on-land vertices={vit['n_on_land']} "
f"land-crossing segments={vit['n_land_cross']}", flush=True)
gpe3 = pd.read_csv(CLEAN_DIR / f"gpe3_{shark}.csv", parse_dates=["time"])
fused_daily = daily_lookup(track)
gpe3_daily = daily_lookup(gpe3)
common = fused_daily.index.intersection(gpe3_daily.index)
if len(common):
rec["fused_vs_gpe3_median_km"] = float(np.median(gc_km(
fused_daily.loc[common, "longitude"].values,
fused_daily.loc[common, "latitude"].values,
gpe3_daily.loc[common, "longitude"].values,
gpe3_daily.loc[common, "latitude"].values)))
mode_daily = daily_lookup(mode_track)
if has_referee:
argos = pd.read_csv(
CLEAN_DIR / f"argos_{shark}.csv", parse_dates=["time"])
argos["day"] = argos["time"].dt.floor("D")
rows = []
for _, fix in argos.iterrows():
day = fix["day"]
r = {"time": fix["time"], "quality": fix["quality"]}
# fused_err_km is now the LAND-AWARE PATH error (headline).
if day in fused_daily.index:
r["fused_err_km"] = gc_km(
fix["longitude"], fix["latitude"],
fused_daily.loc[day, "longitude"],
fused_daily.loc[day, "latitude"])
if day in mode_daily.index:
r["mode_err_km"] = gc_km(
fix["longitude"], fix["latitude"],
mode_daily.loc[day, "longitude"],
mode_daily.loc[day, "latitude"])
if day in gpe3_daily.index:
r["gpe3_err_km"] = gc_km(
fix["longitude"], fix["latitude"],
gpe3_daily.loc[day, "longitude"],
gpe3_daily.loc[day, "latitude"])
rows.append(r)
errors = pd.DataFrame(rows)
errors.to_csv(
RESULTS_DIR / f"validation_errors_{shark}.csv", index=False)
if "fused_err_km" in errors:
rec["fused_vs_argos_median_km"] = float(
errors["fused_err_km"].median())
if "mode_err_km" in errors:
rec["mode_fused_vs_argos_median_km"] = float(
errors["mode_err_km"].median())
if "gpe3_err_km" in errors:
rec["gpe3_vs_argos_median_km"] = float(
errors["gpe3_err_km"].median())
print(f" PATH-vs-Argos median = "
f"{rec['fused_vs_argos_median_km']:.1f} km "
f"(mode-track {rec['mode_fused_vs_argos_median_km']:.1f}, "
f"temp-only {TEMP_ONLY_ERR_KM.get(shark)}, "
f"GPE3 {rec['gpe3_vs_argos_median_km']:.1f})", flush=True)
rec["status"] = "ok"
except Exception as e: # noqa: BLE001
rec["status"] = "failed"
rec["error"] = f"{type(e).__name__}: {e}"
print(f" FAILED: {rec['error']}", flush=True)
records.append(rec)
if rec["status"] == "ok":
cache_path.write_text(json.dumps(rec))Aggregate into results/summary.csv + results/aggregate.json¶
# Pre-bathy backup of the previous summary (the fused-barrier baseline run), kept
# once for the before/after comparison. Never overwrite an existing backup.
_prebathy = RESULTS_DIR / "summary_prebathy.csv"
_cur = RESULTS_DIR / "summary.csv"
if _cur.exists() and not _prebathy.exists():
_prebathy.write_text(_cur.read_text())
# Restore canonical tag order (the loop ran un-cached tags first for resilience).
_canon = {t: i for i, t in enumerate(TAGS)}
records = sorted(records, key=lambda r: _canon.get(r["tag"], 999))
summary = pd.DataFrame(records)[[
"tag", "deploy_days", "has_referee", "has_temperature", "healpix_level",
"n_twilights", "n_twilights_used", "n_argos_fixes",
"sigma_fused", "max_sigma_rad", "sigma_at_bound",
"movement_model", "n_barrier_fallback_rows", "decode_mode",
"viterbi_max_daily_km", "viterbi_k", "viterbi_relaxations",
"path_on_land_points", "path_land_crossing_segments",
"n_land_mask_cells", "n_land_points_before", "n_land_points_mean",
"n_land_points_mode_raw", "n_snapped", "n_land_points_after",
"emission_version", "depth_emission_active",
"viz_nursery_is_water", "viz_nursery_cell_dist_km",
"light_only_err_km", "temp_only_err_km", "fused_barrier_err_km",
"mode_fused_vs_argos_median_km", "fused_vs_argos_median_km",
"gpe3_vs_argos_median_km", "fused_vs_gpe3_median_km", "status", "error",
]]
summary.to_csv(RESULTS_DIR / "summary.csv", index=False)
referee_ok = summary[(summary["has_referee"]) & (summary["status"] == "ok")]
pooled = (float(referee_ok["fused_vs_argos_median_km"].median())
if len(referee_ok) else None)
ok_sigmas = summary[summary["status"] == "ok"]
n_off_bound = int((~ok_sigmas["sigma_at_bound"].astype("boolean").fillna(True)).sum())
agg = {
"n_tags_total": int(len(summary)),
"n_tags_ok": int((summary["status"] == "ok").sum()),
"n_referee_tags_ok": int(len(referee_ok)),
"fused_vs_argos_pooled_median_km": pooled,
"gpe3_vs_argos_pooled_median_km": (
float(referee_ok["gpe3_vs_argos_median_km"].median())
if len(referee_ok) else None),
"light_only_pooled_median_km": LIGHT_ONLY_POOLED_KM,
"temp_only_pooled_median_km": TEMP_ONLY_POOLED_KM,
"fused_barrier_pooled_median_km": FUSED_BARRIER_POOLED_KM,
"gpe3_baseline_km": GPE3_BASELINE_KM,
"emission_version": EMISSION_VERSION,
"n_tags_sigma_off_bound": n_off_bound,
"sigma_off_bound": bool(n_off_bound > 0),
"fused_below_temp_floor": (
bool(pooled is not None and pooled < TEMP_ONLY_POOLED_KM)),
"fused_below_barrier_baseline": (
bool(pooled is not None and pooled < FUSED_BARRIER_POOLED_KM)),
"fused_at_or_below_gpe3": (
bool(pooled is not None and pooled <= GPE3_BASELINE_KM)),
# Per-tag delta vs the fused-barrier baseline (negative = improvement).
"delta_vs_barrier_km": {
str(r["tag"]): (
float(r["fused_vs_argos_median_km"] - r["fused_barrier_err_km"])
if pd.notna(r["fused_vs_argos_median_km"])
and pd.notna(r["fused_barrier_err_km"]) else None)
for _, r in referee_ok.iterrows()},
"tag_08_01_improved_with_relaxed_nursery": (
bool(referee_ok.set_index("tag").loc["08_01", "fused_vs_argos_median_km"]
< FUSED_BARRIER_ERR_KM["08_01"])
if "08_01" in set(referee_ok["tag"]) else None),
"all_referee_nursery_cells_water": (
bool(referee_ok["viz_nursery_is_water"].fillna(False).all())
if len(referee_ok) else None),
"decode_mode": "constrained_viterbi_path",
"movement_model": "land_barrier_gaussian1d_healpix",
"total_barrier_fallback_rows": int(
summary["n_barrier_fallback_rows"].dropna().sum()),
"n_blocked_pairs_cached": int(sum(_BLOCKED_PAIR_CACHE.values())),
"n_pairs_cached": int(len(_BLOCKED_PAIR_CACHE)),
"mode_fused_vs_argos_pooled_median_km": (
float(referee_ok["mode_fused_vs_argos_median_km"].median())
if len(referee_ok) else None),
"path_total_on_land_points": int(
summary["path_on_land_points"].dropna().sum()),
"path_total_land_crossing_segments": int(
summary["path_land_crossing_segments"].dropna().sum()),
"total_land_points_before": int(
summary["n_land_points_before"].dropna().sum()),
"total_land_points_mean": int(
summary["n_land_points_mean"].dropna().sum()),
"total_land_points_mode_raw": int(
summary["n_land_points_mode_raw"].dropna().sum()),
"total_land_points_snapped": int(summary["n_snapped"].dropna().sum()),
"total_land_points_after": int(
summary["n_land_points_after"].dropna().sum()),
}
with open(RESULTS_DIR / "aggregate.json", "w") as fh:
json.dump(agg, fh, indent=2)
print("\n========== SUMMARY ==========", flush=True)
print(summary.to_string(index=False))
print("\n========== AGGREGATE ==========")
print(json.dumps(agg, indent=2))