Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
ccb7c4f
Add pressure_dimension_str arg to geopotential_thickness (#297)
aaTman Jan 11, 2026
d5ca07c
`DurationMeanError` memory fix and add time resolution option (#296)
aaTman Jan 13, 2026
f2d8cd2
Move parallel config check outside of function (#301)
aaTman Jan 13, 2026
d09f456
feat: Forecast wrapper for custom xarray datasets (#302)
darothen Jan 13, 2026
1035230
Simplify IBTrACS polars subset (#303)
aaTman Jan 14, 2026
4027de8
Update `geopotential_thickness` var names and docstring (#306)
aaTman Jan 14, 2026
1afff35
Clarify default preprocess function names; geopotential division fix …
aaTman Jan 14, 2026
5d239b8
Remove "cases" key requirement in yamls and dicts (#308)
aaTman Jan 15, 2026
4793532
remove out-of-date notebook from docs
aaTman Jan 15, 2026
ed3b9e6
CIRA Icechunk store (#310)
aaTman Jan 15, 2026
342668a
update pyproject and uv lock
aaTman Jan 16, 2026
fcd329e
Merge pull request #311 from brightbandtech:chore/upgrade-scores
aaTman Jan 16, 2026
89a9bdb
add TODO
aaTman Jan 17, 2026
94eeb98
update PR template
aaTman Jan 19, 2026
0fabca5
Remove `IndividualCaseCollection` (#317)
aaTman Jan 22, 2026
63f8b13
Cleanup docstrings in repo (#318)
aaTman Jan 24, 2026
5350563
add explanation for dim reqs (#320)
aaTman Jan 24, 2026
71f3a0b
Update `defaults` and `inputs` to include new CIRA icechunk store (#319)
aaTman Jan 24, 2026
79f4b74
Bump version from 0.2.0 to 0.3.0 (#324)
aaTman Jan 26, 2026
b1fc1b6
Updated API (#321)
aaTman Jan 26, 2026
34a3965
Golden tests (#323)
aaTman Jan 26, 2026
8979bed
PyPI Preparation (#315)
aaTman Jan 26, 2026
54f5c35
update pyproject version for release
aaTman Jan 26, 2026
c367043
Merge branch 'main' into develop
aaTman Jan 26, 2026
43fafbf
Remove duplicate function and fixtures (#326)
aaTman Jan 27, 2026
8445eba
Merge branch 'main' into develop
aaTman Mar 2, 2026
42c176e
update install docs for pypi and fix typo in link
aaTman Mar 2, 2026
9794aaf
Update ar calcs with improved parallelization (#329)
aaTman Mar 20, 2026
ecde8c6
Merge branch 'main' into develop
aaTman Mar 20, 2026
50adbd9
bump pyproject version (#331)
aaTman Mar 20, 2026
e2cd113
Add ROCSS (#300)
rodrigoalmeida94 Mar 21, 2026
cf0e296
update pyproject with icechunk (#335)
aaTman Mar 26, 2026
3e0bff0
move dask and distributed to core deps (#336)
aaTman Mar 26, 2026
a7d59d2
Make new AR parallelization work for forecasts (>1 time dim) (#334)
aaTman Mar 26, 2026
349dbd8
Update to `icechunk` for example scripts (#333)
aaTman Mar 27, 2026
d34ad17
Make numba (more) threadsafe working with dask (#337)
aaTman Mar 27, 2026
72c6c13
Landfall metric fixes (#338)
aaTman Mar 30, 2026
c20c2b1
update landfall intensity mae metric to remove nans
aaTman Mar 30, 2026
ccf14fb
add landfall number aggregation; remove nans
aaTman Mar 30, 2026
3e33f39
Landfall test dim fix (#341)
aaTman Mar 31, 2026
f38a759
`EarlySignal` fixes (#340)
aaTman Mar 31, 2026
64ec2f0
Relax TC tracking criteria (#339)
aaTman Apr 1, 2026
83bc8ec
set minimum wind >= 10m/s to ten steps by default
aaTman Apr 1, 2026
0f0931d
Catch non-ocean landfalls (#342)
aaTman Apr 1, 2026
30a7e07
enforce level chunk for nantrapezoid_pressure_levels (#343)
aaTman Apr 1, 2026
923e2f2
Update ARs with pressure ceiling and cleaner derived variable (#344)
aaTman Apr 2, 2026
df024eb
Update GHCNh filtering (#346)
aaTman Apr 2, 2026
bde92bf
update ES to avoid failing on dask-backed arrays and add a check in t…
aaTman Apr 2, 2026
ad3d057
Add preprocess func for heatwaves to remove ocean gridpoints (#348)
aaTman Apr 2, 2026
2a754b3
Add temperature event finder scripts (#345)
aaTman Apr 2, 2026
318785d
Add `overlap_target_threshold` to `EarlySignal` (#350)
aaTman Apr 5, 2026
c97b228
Climatology scripting (#349)
aaTman Apr 5, 2026
4d4cea2
added marginal severe cases (#351)
amymcgovern Apr 7, 2026
d1c80ee
add updated 2m temp climatology quantiles (#354)
aaTman Apr 9, 2026
0a6b589
Temperature event bounds update (#353)
aaTman Apr 9, 2026
9ad26d5
Adjust some TC case bounds (#355)
aaTman Apr 9, 2026
9580294
Tune duration logic (#357)
aaTman Apr 10, 2026
aefbfa0
v1.0.2
aaTman Apr 10, 2026
b37a47f
Merge branch 'main' into develop
aaTman Apr 10, 2026
214a3a2
Update global temp bound method; fix event case number mismatch (#359)
aaTman Apr 11, 2026
3f663bc
enable plot to be used in an axes
aaTman Apr 11, 2026
1e77970
add p15-p85 events yaml
aaTman Apr 12, 2026
6206f67
update with more explicit blob criteria
aaTman Apr 13, 2026
4d39b14
add PeriodicBoundaryIndex (h/t TomNicholas) to avoid lon shenanigans
aaTman Apr 13, 2026
fa0dcf7
rename marginal severe with underscores; add marginal temperature eve…
aaTman Apr 13, 2026
e786503
remove marginal severe with dashes
aaTman Apr 13, 2026
7571d1a
update nz heatwave (200k km2 limit doesnt work on small island)
aaTman Apr 14, 2026
9d0e897
swap marginal temp event type to heat wave
aaTman Apr 14, 2026
15c1e3b
remove no landfalling tc's (-8 TCs) (#361)
aaTman Apr 15, 2026
9f9dff8
Manage landfall edge cases (#362)
aaTman Apr 16, 2026
9fad797
update consecutive days label formatting
aaTman Apr 20, 2026
7886f1d
move label position to right for consecutive plots
aaTman Apr 21, 2026
f5420ca
update TC count in readme after removing non-landfall TCs
aaTman Apr 22, 2026
296ada4
Add > 15 degree latitude filter for ARs (#365)
aaTman Apr 22, 2026
3b6506b
Consolidate temperature data_prep scripts into temperature_events.py …
aaTman Apr 23, 2026
446cff9
fix: seed sample_calc_dataset fixture to eliminate flaky pressure tes…
darothen Apr 24, 2026
1e08fac
fix: correct three CIN bugs in mixed-layer CAPE/CIN computation (#366)
darothen Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ EWB has cases broken down by multiple event types within `src/extremeweatherbenc
| ---------- | --------------- |
| 🌇 Heat Waves | 46 |
| 🧊 Freezes | 14 |
| 🌀 Tropical Cyclones | 106 |
| 🌀 Tropical Cyclones | 98 |
| ☔️ Atmospheric Rivers | 56 |
| 🌪️ Severe Convection | 115 |
| **Total Cases** | 337 |
| **Total Cases** | 329 |

# EWB paper and talks

Expand Down
268 changes: 268 additions & 0 deletions data_prep/climatology_to_icechunk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "icechunk>=0.1.0",
# "xarray>=2023.1",
# "zarr>=3.1.0",
# ]
# ///
"""Write a percentile climatology netCDF into a GCS-backed icechunk store.

The commit message is generated automatically from the dataset metadata
written by generate_percentile_climatology.py (percentile, variable,
coordinate ranges, etc.), so every snapshot in the store is
self-describing.

If the target store does not yet exist it is created; if it already
exists the climatology is appended as a new snapshot on the requested
branch.

Usage:
# Authenticate first (write access to the EWB bucket):
gcloud auth application-default login

# Write / update the store:
uv run climatology_to_icechunk.py \\
--input era5_heatwave_climatology.nc \\
--bucket extremeweatherbench \\
--prefix climatology-icechunk \\
--branch main

# Dry-run: print the commit message without touching the store:
uv run climatology_to_icechunk.py \\
--input era5_heatwave_climatology.nc \\
--dry-run

To read the store back:
import icechunk, xarray as xr
storage = icechunk.gcs_storage(
bucket="extremeweatherbench",
prefix="climatology-icechunk",
anonymous=True,
)
repo = icechunk.Repository.open(storage)
session = repo.readonly_session(branch="main")
ds = xr.open_zarr(session.store)
"""

import argparse
import logging
import pathlib

import icechunk
import xarray as xr

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Commit message
# ---------------------------------------------------------------------------


def build_commit_message(ds: xr.Dataset) -> str:
"""Build a descriptive commit message from dataset metadata.

Pulls from dataset attributes set by generate_percentile_climatology.py
and from the coordinate ranges present in the dataset.
"""
attrs = ds.attrs

# --- required metadata written by the generation script ---
percentile_label = attrs.get("percentile_label", "pXX")
percentile = attrs.get("percentile", "unknown")
source = attrs.get("source", "unknown source")

# --- variable name: the first non-coordinate data variable ---
data_vars = list(ds.data_vars)
variable = data_vars[0] if data_vars else "unknown_variable"

# --- coordinate ranges ---
parts = []

if "dayofyear" in ds.coords:
doys = ds.coords["dayofyear"].values
doy_str = (
f"doy {int(doys.min())}–{int(doys.max())}"
if len(doys) > 1
else f"doy {int(doys[0])}"
)
parts.append(doy_str)

if "hour" in ds.coords:
hours = sorted(int(h) for h in ds.coords["hour"].values)
parts.append(f"hours {hours}")

if "latitude" in ds.coords:
lats = ds.coords["latitude"].values
parts.append(f"lat {float(lats.min()):.1f}°–{float(lats.max()):.1f}°")

if "longitude" in ds.coords:
lons = ds.coords["longitude"].values
parts.append(f"lon {float(lons.min()):.1f}°–{float(lons.max()):.1f}°")

coord_summary = ", ".join(parts) if parts else "full domain"

# --- training period, if recorded ---
period_str = ""
if "start_year" in attrs and "end_year" in attrs:
period_str = f", period {attrs['start_year']}–{attrs['end_year']}"
elif "training_period" in attrs:
period_str = f", period {attrs['training_period']}"

msg = (
f"Add {percentile_label} ({float(percentile):.0%}) climatology "
f"of {variable} from {source}{period_str} "
f"[{coord_summary}]"
)
return msg


# ---------------------------------------------------------------------------
# Store helpers
# ---------------------------------------------------------------------------


def _open_or_create_repo(
storage: icechunk.Storage,
branch: str,
) -> icechunk.Repository:
"""Open an existing icechunk repo, or create one if it doesn't exist."""
try:
repo = icechunk.Repository.open(storage)
logger.info("Opened existing icechunk repository.")
except Exception:
logger.info("No existing repository found – creating a new one.")
repo = icechunk.Repository.create(storage, icechunk.RepositoryConfig.default())

if branch != "main" and branch not in repo.list_branches():
snapshot_id = repo.lookup_branch("main")
repo.create_branch(branch, snapshot_id=snapshot_id)
logger.info("Created branch '%s'.", branch)

return repo


# ---------------------------------------------------------------------------
# Main logic
# ---------------------------------------------------------------------------


def write_climatology_to_icechunk(
input_path: pathlib.Path,
bucket: str,
prefix: str,
branch: str,
application_credentials: str,
dry_run: bool,
) -> None:
"""Load a climatology netCDF and commit it to a GCS icechunk store."""
logger.info("Loading climatology from %s", input_path)
ds = xr.open_dataset(input_path)

commit_message = build_commit_message(ds)
logger.info("Commit message: %s", commit_message)

if dry_run:
logger.info("Dry run – skipping store write.")
return

# Build the GCS-backed storage. Pass an empty string for
# application_credentials to let the SDK pick up ADC automatically,
# or provide an explicit path to a service-account JSON file.
storage = icechunk.gcs_storage(
bucket=bucket,
prefix=prefix,
application_credentials=application_credentials,
)

repo = _open_or_create_repo(storage, branch)
session = repo.writable_session(branch=branch)

logger.info(
"Writing dataset to icechunk store (bucket=%s, prefix=%s, branch=%s)…",
bucket,
prefix,
branch,
)
ds.to_zarr(session.store, mode="w")

snapshot_id = session.commit(commit_message)
logger.info("Committed snapshot %s on branch '%s'.", snapshot_id, branch)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Write a percentile climatology netCDF into a GCS-backed icechunk store."
),
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--input",
type=pathlib.Path,
required=True,
help="Path to the climatology netCDF produced by "
"generate_percentile_climatology.py.",
)
parser.add_argument(
"--bucket",
default="extremeweatherbench",
help="GCS bucket name.",
)
parser.add_argument(
"--prefix",
default="climatology-icechunk",
help="Prefix (subdirectory) within the bucket for the store.",
)
parser.add_argument(
"--branch",
default="main",
help="Icechunk branch to commit to. Created if it does not exist.",
)
parser.add_argument(
"--application-credentials",
default="",
help=(
"Path to a GCP service-account JSON file. Leave empty to use "
"Application Default Credentials (gcloud auth application-default "
"login)."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the commit message and exit without writing to the store.",
)
return parser.parse_args()


def main() -> None:
args = parse_args()

if not args.dry_run and not args.input.exists():
raise FileNotFoundError(f"Input file not found: {args.input}")

write_climatology_to_icechunk(
input_path=args.input,
bucket=args.bucket,
prefix=args.prefix,
branch=args.branch,
application_credentials=args.application_credentials,
dry_run=args.dry_run,
)


if __name__ == "__main__":
main()
Loading
Loading