Skip to content
DanielCamarenaPublic

About

Marine and atmospheric heatwaves and cold spells on native grids: baselines, event catalogs, spatial objects and their genealogy

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

xHW: thermal extremes, spatial objects and their genealogy

License: Apache-2.0 Python 3.11+ Version 0.0.1

Detect marine heatwaves (MHW), marine cold spells (MCS) and atmospheric heatwaves (AHW) on the grid you already have, then follow them as connected objects that split and merge through time.

xHW is a Python library built on NumPy, xarray and SciPy. It turns daily temperature into a fitted reference and threshold, a per-cell event mask, an event catalog, daily spatial patches, optional overlap tracking and a genealogy of branches and spacetime events. Each product keeps its own evidence: an exceedance, a day joined across a short gap, and a cell added by spatial processing are stored separately and never confused.

A constructed event splits into two branches and merges again

Percentage of valid days in MHW and MCS events in a synthetic reconstruction of the SST field off Peru

Top: executed synthetic example. One patch splits on day 6 and merges on day 11, giving four branches and one 15-day spacetime event. Arrows mean overlap, not causation. Bottom: percentage of valid days inside MHW (P90) and MCS (P10) events in a seeded synthetic reconstruction of the SST field off Peru, 1991–2025, generated in a second; it is not an observation. Both figures come from the tutorials.


Key features

  • Three phenomena, one model. MHW and MCS (Hobday et al. 2016 thresholds and persistence) and AHW (TX90, TN90 and a joint daily TX+TN profile).
  • Seven baselines. A fixed seasonal climatology and six dynamic references: detrended, shifting, STL, state space, GAM and SSA. All can be fitted retrospectively or causally, with optional within-reference correction.
  • Native grids. Points, lines, surfaces and volumes on structured or curvilinear grids, with physical cell measures (m, m², m³). No regridding is required.
  • Explicit evidence. exceedance, event_membership, joined_gap, valid and bit-coded invalid_reason are separate layers. Missing data is never read as "no event".
  • Objects, tracking and genealogy. Ordered spatial recipes (morphology, connectivity, area filters), overlap tracking, and a lineage graph that keeps every parent and child.
  • Weighted metrics. Area- or volume-weighted size, size-time, footprint, displacement and signed intensity, for each branch and spacetime event.
  • Reproducible. Every spec is an immutable, serializable dataclass, and the test suite (about 800 tests) and every tutorial here run offline. Real-data cases, with checksums, live in xhw-benchmark.

Installation

Python 3.11 or newer. From a source checkout:

python -m venv .venv
source .venv/bin/activate          # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install .
python examples/quickstart.py      # prints "xHW 0.0.1: known 10-day synthetic event detected"
Extra Adds Needed for
(core) NumPy, xarray, SciPy Everything below, including the vectorized STL, GAM and SSA engines
.[dynamic] statsmodels, pygam state_space baselines and the statsmodels/pygam reference engines
.[parallel] Dask Lazy temporal operations on chunked data
.[dev] pytest, Ruff, numerical test engines, Matplotlib, Plotly and widgets Complete test suite
examples/requirements-interactive.txt Plotly, ipywidgets, Pillow and example tools GIF and interactive examples
examples/requirements.txt Matplotlib, h5netcdf, Jupyter Tutorials, notebooks and figures

First detection with a prepared configuration

From this source checkout, use the deterministic tutorial's ready temperature, grid, baseline specification and event definition. Here mhw is an ordinary Python alias:

import sys
sys.path.insert(0, "examples")
from _data import synthetic_inputs
import xhw

ds, grid, baseline, mhw = synthetic_inputs()
fitted = xhw.fit_baseline(ds, variable="sst", grid=grid, baseline=baseline, definition=mhw)
mask = xhw.detect(ds, variable="sst", grid=grid, definition=mhw, baseline_result=fitted)
mask.check_invariants()

If a compatible fitted baseline is already prepared, only the final detect call is needed. mhw.to_dict() and baseline.to_dict() expose the scientific choices. The examples' profiles.example_profiles() also returns MCS, TX90, TN90 and TX+TN configurations; these are not new defaults of the installed API. The complete standalone example follows.

Quick start

The block below runs as is. It builds 34 years of daily SST on an 8 × 8 grid and injects a warm patch in June 2015 whose middle row cools for a week, so the patch splits in two and merges again. To use your own data, replace the first block with ds = xr.open_dataset("my_sst.nc") plus a domain_mask and a cell_area variable.

import numpy as np
import xarray as xr

import xhw

# --- 1. Daily temperature on a small surface grid --------------------------------------
time = np.arange(np.datetime64("1982-01-01"), np.datetime64("2016-01-01"))
lat, lon = np.arange(-12.0, -4.0), np.arange(-82.0, -74.0)
day_of_year = (time - time.astype("datetime64[Y]")).astype(int)
season = 20 + 3 * np.sin(2 * np.pi * day_of_year / 365.25)
rng = np.random.default_rng(0)
sst = season[:, None, None] + rng.normal(0, 0.5, (time.size, lat.size, lon.size))
start = int(np.flatnonzero(time == np.datetime64("2015-06-01"))[0])
sst[start:start + 20, 2:5, 2:7] += 4.0       # a 3 x 5 warm patch for 20 days ...
sst[start + 7:start + 14, 3, 2:7] -= 4.0     # ... whose middle row cools for a week

area = (6_371_000.0**2 * np.deg2rad(1.0)
        * np.abs(np.sin(np.deg2rad(lat + 0.5)) - np.sin(np.deg2rad(lat - 0.5))))
ds = xr.Dataset(
    {"sst": (("time", "lat", "lon"), sst, {"units": "degC"}),
     "domain_mask": (("lat", "lon"), np.ones((lat.size, lon.size), bool)),
     "cell_area": (("lat", "lon"), np.repeat(area[:, None], lon.size, 1), {"units": "m2"})},
    coords={"time": time.astype("datetime64[ns]"), "lat": lat, "lon": lon},
)

# --- 2. Declare the grid, the data source and the scientific choices ------------------
grid = xhw.GridSpec(
    support="surface", topology="structured", time_dim="time", spatial_dims=("lat", "lon"),
    coords=xhw.CoordRoles(lat="lat", lon="lon"), calendar="standard", frequency="1D",
    time_axes="single", periodic_x=False,
    cell_measure=xhw.CellMeasure(kind="area", var="cell_area", units="m2"),
    vertical=None, domain="ocean", domain_mask="domain_mask", domain_fraction=None,
    native=xhw.NativeGrid(product="synthetic", resolution="1 degree", is_native=True))
grid.check(ds)

source = xhw.SourceRef(dataset_id="synthetic:readme", variable="sst",
                       accessed="2026-10-11", product_status="final")
baseline = xhw.BaselineSpec(               # Hobday et al. (2016) fixed climatology
    method="fixed_climatology", computed_on="temperature",
    reference_period=("1983-01-01", "2012-12-31"), source=source,
    doy_window_days=11, smoothing_window_days=31, percentile_estimator="hyndman_fan_7",
    leap_day_policy="hobday_366_interp", min_samples=264,
    fit_mode="retrospective", available_until=None)
definition = xhw.DefinitionSpec(           # MHW: > P90 for >= 5 days, gaps <= 2 days joined
    profile="hobday2016", phenomenon="mhw", variable="sea_surface_temperature",
    combination="single", daily_statistic="native", day_definition="native",
    tail="upper", comparator=">", threshold_kind="percentile", percentile=90.0,
    absolute_threshold=None, min_duration_days=5, max_gap_days=2,
    join_rule="qualify_then_join", missing_policy="as_non_exceedance",
    season_months=None, category_scheme="hobday2018")

# --- 3. Baseline and threshold -> detection -> per-cell event catalog ----------------
fitted = xhw.fit_baseline(ds, variable="sst", grid=grid, baseline=baseline,
                          definition=definition)
mask = xhw.detect(ds, variable="sst", grid=grid, definition=definition,
                  baseline_result=fitted)
catalog = xhw.build_catalog(mask, analysis_mode="retrospective")

# --- 4. Daily patches -> overlap tracking -> genealogy --------------------------------
recipe = xhw.SpatialRecipe(input_layer="event_membership", steps=(
    xhw.RecipeStep.segment(footprint=xhw.Footprint.connected(1),
                           min_area=xhw.MinArea.none()),
    xhw.RecipeStep.track(min_overlap=0.1, max_gap_days=0, min_duration_days=5),
))
objects = xhw.build_objects(mask, recipe=recipe)
graph = xhw.build_lineage(objects, field=mask.intensity(), weighted=True)

largest = int(np.argmax(graph.dataset.event_area_days.values))     # by area x days
event = int(graph.dataset.event_number.values[largest])
series = graph.series(spacetime_event=event)         # daily area, intensity, ...
print(catalog.dataset.sizes["event"], "per-cell events;",
      graph.dataset.sizes["spacetime_event"], "spacetime events;",
      f"largest lasts {int(graph.dataset.event_duration_days.max())} days")

Plot the products with xarray and Matplotlib. xHW has no plotting API of its own; the plotting guide collects ready-made recipes.

import matplotlib.pyplot as plt

fig, (left, right) = plt.subplots(1, 2, figsize=(10, 3.5), layout="constrained")
valid_days = mask.dataset.valid.sum("time")
frequency = 100 * (mask.dataset.event_membership & mask.dataset.valid).sum("time")
(frequency / valid_days.where(valid_days > 0)).plot(ax=left, cbar_kwargs={"label": "% of valid days"})
left.set_title("MHW frequency")
(series.area / 1e6).plot(ax=right)
right.set(title=f"Spacetime event {event}", ylabel="Occupied area (km²)")
plt.show()

Core workflow

 daily temperature (xarray)
        │  aggregate_daily()      hourly → daily, with sample counts      (optional)
        │  interpolate_gaps()     short interior gaps, flagged "filled"   (optional)
        ▼
 1. BASELINE      fit_baseline()   ──► BaselineResult  reference + threshold
        ▼
 2. DETECT        detect()         ──► XMask           exceedance, membership, validity
        ├──────────────► build_catalog()  ──► EventCatalog   one row per event and cell
        ▼
 3. OBJECTS       build_objects()  ──► ObjectField     daily patches (+ track IDs if asked)
        ▼
 4. GENEALOGY     build_lineage()  ──► LineageGraph    branches, splits, merges, events
        ▼
 5. ANALYSE       graph.series() / graph.maps() / xarray + Matplotlib (examples/plotting.py)

You can stop at any stage: a temporal study ends at the catalog, and a spatial study can skip tracking because build_lineage also works on untracked patches.

Stage Function Input → output Main choices
Prepare aggregate_daily, interpolate_gaps, GridSpec.check Raw samples → regular daily dataset Units, calendar, day clock, statistic, gap length
Baseline fit_baseline Daily temperature + BaselineSpec → BaselineResult Method, reference period, window, estimator, leap days
Detect detect Temperature + baseline + DefinitionSpec → XMask Tail, percentile, minimum duration, gaps, missing policy
Catalog build_catalog XMask → EventCatalog Retrospective or monitoring mode, censoring
Objects build_objects XMask + SpatialRecipe → ObjectField Input layer, connectivity, area filter, morphology, order
Tracking RecipeStep.track Daily patches → track IDs Overlap measure and threshold, object gaps, duration
Genealogy build_lineage ObjectField → LineageGraph LinkPolicy, merge rule, intensity weighting

Details: processing guide.


Stage by stage

These snippets continue the quick-start session.

1. Baseline and threshold

fit_baseline returns a BaselineResult, not events. The fixed climatology pools an 11-day window around each day of the year over the reference period, takes the 90th percentile, and smooths it with a 31-day moving average:

clim = fitted.dataset
print(clim.reference.dims)                  # ('dayofyear', 'lat', 'lon') for a fixed baseline
print(float(clim.threshold.isel(dayofyear=180, lat=3, lon=3)), "degC on day 181")
fitted.check_invariants()

Dynamic baselines model a trend and a seasonal cycle, then add a percentile of the residual:

temperature = trend + seasonal + residual
reference(t) = trend(t) + seasonal(t)
threshold(t) = reference(t) + percentile_p(residual | day of year)
from dataclasses import replace

detrended = replace(baseline, method="detrended",
                    params=(("trend_degree", 1), ("n_harmonics", 3)))
fitted_dynamic = xhw.fit_baseline(ds, variable="sst", grid=grid,
                                  baseline=detrended, definition=definition)
print(fitted_dynamic.dataset.threshold.dims)   # ('time', 'lat', 'lon'): varies in time

See Baseline types for the seven methods and their engines.

2. Detection

detect compares each valid day with its threshold, keeps runs of at least min_duration_days, and joins runs separated by up to max_gap_days. The result is an XMask whose layers stay separate:

Variable Meaning
exceedance The temperature is beyond the threshold on that day
event_membership The day belongs to a qualifying event, including joined gap days
joined_gap Membership without exceedance (a short cool or missing day inside an event)
valid Temperature, baseline and data quality allow interpretation
invalid_reason Bit flags: 1 outside domain, 2 missing, 4 undefined baseline, 8 few samples, 16 low quality
cell = mask.dataset.isel(lat=3, lon=3).sel(time=slice("2015-06-01", "2015-06-25"))
print({name: int(cell[name].sum()) for name in ("exceedance", "event_membership", "joined_gap")})
intensity = mask.intensity()                # temperature − reference, signed

3. Temporal catalog

table = catalog.dataset[["start", "end", "duration_days", "intensity_max", "category_max"]]
print(table.sortby("duration_days", ascending=False).isel(event=slice(0, 3)))
catalog.check_against(mask)

One row per event and cell: counting rows is not the same as counting distinct spatial heatwaves. That is what the object and genealogy stages do.

4. Objects, tracking and genealogy

The default recipe (SpatialRecipe.default()) segments exceedances into face-connected patches of at least 5% of the domain, then applies persistence. It does not track. Tracking is an explicit last step, linking patches that share grid cells on consecutive days:

print(objects.dataset[["object_time", "object_area", "object_cells"]].to_dataframe().head())
print(graph.dataset.sizes["branch"], "branches;",
      int(graph.dataset.is_split.sum()), "splits;", int(graph.dataset.is_merge.sum()), "merges")
edges = graph.transitions()                 # every candidate link, with its overlap measures
maps = graph.maps(objects, spacetime_event=event)    # footprint and occupied days per cell

A branch is a chain of one-to-one links; a split or merge ends a branch and starts new ones. A spacetime event is a connected group of linked patches. See objects and genealogy and metrics.


Event types

DefinitionSpec.phenomenon selects what is detected. The other fields state the rule explicitly, because a profile name alone does not fill in scientific choices.

Phenomenon Typical definition tail / comparator Daily input Categories
MHW: marine heatwave SST > P90, ≥ 5 days, gaps ≤ 2 days upper / > Native daily SST hobday2018 (moderate to extreme)
MCS: marine cold spell SST < P10, ≥ 5 days, gaps ≤ 2 days lower / < Native daily SST schlegel2021
AHW: atmospheric heatwave, TX90 TX > P90, ≥ 3 days upper / > Daily max (daily_statistic="max") none
AHW: TN90 TN > P90, ≥ 3 days upper / > Daily min (daily_statistic="min") none
AHW: joint TX+TN TX and TN both > P90 on the same day (or TN lagged), ≥ 3 days upper / > Two components, combination="and_daily" none

The threshold can be a percentile (threshold_kind="percentile") or a fixed value (threshold_kind="absolute", for example (28.0, "degC")). season_months restricts detection to some months.

mcs = replace(definition, profile="schlegel2021", phenomenon="mcs", tail="lower",
              comparator="<", percentile=10.0, category_scheme="schlegel2021")
above_28 = replace(definition, profile="absolute_28C", threshold_kind="absolute",
                   percentile=None, absolute_threshold=(28.0, "degC"), category_scheme=None)

ahw_common = dict(phenomenon="ahw", day_definition="offset:-05:00", tail="upper",
                  comparator=">", threshold_kind="percentile", absolute_threshold=None,
                  min_duration_days=3, max_gap_days=0, join_rule="qualify_then_join",
                  missing_policy="as_non_exceedance", season_months=None,
                  category_scheme=None)
tx90 = xhw.DefinitionSpec(profile="perkins2013_tx90", variable="tx_daily",
                          combination="single", daily_statistic="max", percentile=90.0,
                          **ahw_common)
tx_and_tn90 = xhw.DefinitionSpec(
    profile="xhw_txtn90", variable="tx_daily&tn_daily", combination="and_daily",
    daily_statistic="native", percentile=None,
    components=(xhw.DailyComponent(variable="tx_daily", daily_statistic="max",
                                   percentile=90.0, lag_days=0),
                xhw.DailyComponent(variable="tn_daily", daily_statistic="min",
                                   percentile=90.0, lag_days=0)),
    **ahw_common)

For AHW, hourly data is first reduced to local days with aggregate_daily. It records which clock the timestamps use and how many samples each day had. The ERA5 tutorial in xhw-benchmark runs all four AHW profiles over land and ocean air.


Baseline types

BaselineSpec.method selects the reference. Method parameters go in the params tuple. Unknown parameters raise ValidationError when fitting.

method What it models Default parameters (0.0.1) Engines
fixed_climatology Day-of-year percentile over a fixed reference period (Hobday et al. 2016) Window and smoothing from the spec core
detrended Polynomial trend + annual harmonics (or a climatology of the detrended series) trend_degree=1, n_harmonics=3, seasonal="harmonic" core
shifting Seasonal mean over the preceding years window_years=30, min_years=10 core
stl Seasonal-trend decomposition by LOESS (Cleveland et al. 1990) trend_window_days=7305, seasonal_window="periodic", robust=0 engine="vectorized" (default) or "statsmodels" (needed for robust=1; cell by cell, slow)
state_space Local linear trend + trigonometric seasonality (Kalman smoother) n_harmonics=3, trend_timescale_days=5600 statsmodels (.[dynamic])
gam Penalized spline trend + cyclic seasonal spline trend_splines_per_decade=1.5, seasonal_splines=20, trend_lam=seasonal_lam=0.6 engine="vectorized" (default) or "pygam"
ssa Singular spectrum analysis: low-frequency components form the trend window_days=5479, n_components=12, trend_period_days=10958 core

Choices that apply to every method:

Field Options Meaning
fit_mode retrospective, causal Causal fitting uses only data available by available_until (causal_scheme="cutoff" or "expanding")
in_base_correction none, leave_one_year_out, mean_threshold Stops reference years from being judged against a threshold they helped estimate (Zhang et al. 2005); in_base_rates gives bootstrap rates
percentile_estimator hyndman_fan_1 … hyndman_fan_9 Explicit sample-quantile definition (Hyndman & Fan 1996)
leap_day_policy hobday_366_interp, calendar_doy, not_applicable How February 29 enters the day-of-year climatology
robust_stl = replace(baseline, method="stl",
                     params=(("engine", "statsmodels"), ("robust", 1)))
causal = replace(detrended, fit_mode="causal", available_until="2012-12-31",
                 params=(("causal_scheme", "cutoff"), ("max_extrapolation_days", 30)))
corrected = replace(baseline, in_base_correction="leave_one_year_out")

These defaults reproduce the validated configurations; they are not universally optimal. The baseline guide covers memory cost, causal limits and the measured differences between engines.


Spatial and tracking options

Step Options
Input layer exceedance (daily evidence) or event_membership (after temporal persistence)
Connectivity Footprint.connected(rank): rank 1 = faces (4 on surfaces, 6 in volumes), rank 2/3 add edges and corners; Footprint.disc(radius_km); Footprint.explicit(offsets); vertical_gap_m for physical vertical links
Morphology RecipeStep.morph("open" or "close", footprint); changed cells are kept in added/removed
Area filter MinArea.default() (5% of the domain), none(), percentile, absolute_km/km2/km3
Persistence RecipeStep.persist(...) per cell on the processed geometry
Tracking RecipeStep.track(min_overlap, max_gap_days, min_duration_days, link_measure=...); link_measure is overlap_smaller, iou, source_fraction or target_fraction; analysis_mode is retrospective or monitoring
Genealogy LinkPolicy(measure, threshold, max_gap_days); merge_rule is new_event or survivor
Other tools partition_merges, ingest_objects / compare_objects (external labels), neighbor_fraction

Data and real-data cases

xHW analyses files you open with xarray; it never downloads data. Everything in this repository runs offline on synthetic data, in minutes: the tutorials generate their input and the tests need no files. The real cases live in a companion project, xhw-benchmark:

Case Data What it shows
OISST Peru NOAA OISST v2.1, 0.25° MHW/MCS, fixed versus detrended baseline, objects, genealogy
OSTIA versus OISST Met Office OSTIA at 0.05° and 0.25°, and OISST Product and resolution effects, in three arms
OISST equatorial Pacific NOAA OISST, Niño 3.4 box (5°S–5°N, 170°W–120°W), 8 000 ocean cells MHW/MCS and the 2015–16 El Niño as one large object, with its genealogy
ERA5 Peru ERA5 TX/TN, local days Hourly aggregation, TX90, TN90 and joint TX+TN over land and ocean air
GLORYS volume GLORYS12V1 thetao Voxel detection, vertical connectivity, volumetric genealogy

That project holds the compressed NetCDF datasets (about 300 MB, as release assets) with two hashes each, so a copy can be verified even after recompression; their downloaders; the processed products; and the notebooks. Your own data go through the same stages: see data and provenance.


Examples

Tutorial Shows Notebook / script
Synthetic (no data) Every stage on a hand-checkable field: gaps, morphology, connectivity, one split and one merge notebook · script
Synthetic Peru (no data) A seeded reconstruction of the OISST case off Peru: MHW and MCS, fixed versus detrended baselines, daily patches, tracking, genealogy notebook · script
API walkthrough Ingestion, comparison, partition, in-base rates, serialization script

The notebooks are saved with their outputs, so they can be read on GitHub. The real-data tutorials (OISST over Peru and the equatorial Pacific, OSTIA, ERA5, GLORYS) are in xhw-benchmark.

Area, intensity and occupied days of the largest spacetime event of the synthetic Peru field

Largest spacetime event of the synthetic Peru field: occupied area, area-weighted intensity and occupied days per cell.


Documentation

Read To
Getting started Install and run a first detection
Processing guide Follow each stage, its inputs, outputs and quality checks
Baselines Choose and configure a reference and threshold
Objects and genealogy Build patches, track them and read the lineage graph
Metrics · Plotting Compute tables and draw figures
Data · Tutorials Prepare your inputs and run the two synthetic tutorials
API reference Look up all 44 public names
Scientific contract · Validation · Reproducibility Check limits, evidence and recorded runs

Tests and validation

python -m pip install ".[dev]"
python -m pytest
python -m ruff check .

Tests run offline. They include analytical cases and stored comparisons with marineHeatWaves, climdex, R and statsmodels STL, ssalib, Ocetrac and marEx; none of those packages is needed to run them. Reproducing a result is not the same as a scientific review: each real example states its review status. See validation and capabilities and evidence.


Citation

Please cite the methods your configuration uses, for example:

Complete method references and verified identifiers, including Bonino STL attribution and Schlegel cold categories, are in the methodological bibliography. Software and data credit remain in sources and attribution.


License and contact

Copyright 2026 Daniel Camarena. Licensed under Apache-2.0. Questions, bug reports and feature requests: GitHub issues.

About

Marine and atmospheric heatwaves and cold spells on native grids: baselines, event catalogs, spatial objects and their genealogy

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages