Skip to content

terraflow.drought

Impact-labeled drought-loss prediction benchmark. Predicts insured drought loss (USDA RMA Cause of Loss) from within-season climate/vegetation anomalies. See the Drought benchmark guide for the task, splits, and reference results.

Configuration

config

Configuration model for the drought-impact benchmark.

The benchmark predicts insured drought loss (USDA RMA Cause of Loss) from within-season climate/vegetation predictors. This module defines the single Pydantic config that drives the whole build so runs are reproducible and fingerprintable.

DroughtConfig

Bases: BaseModel

End-to-end configuration for building and evaluating the drought-impact benchmark.

state_alphas property

USPS postal codes for the configured FIPS states (for the NASS query).

from_yaml(path) classmethod

Load a :class:DroughtConfig from a YAML file.

Source code in terraflow/drought/config.py
@classmethod
def from_yaml(cls, path: Path) -> "DroughtConfig":
    """Load a :class:`DroughtConfig` from a YAML file."""
    data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
    return cls(**data)

RMA Cause of Loss ingest

rma

Ingest USDA RMA Cause of Loss Summary-of-Business files.

The Cause of Loss Historical Data Files are public, pipe-delimited flat files (one ZIP per year, 1989–present) published at https://www.rma.usda.gov/tools-reports/summary-of-business/cause-loss

Each record is broken down by year × state × county × commodity × insurance-plan × coverage × stage × cause-of-loss × month-of-loss. The 30-field record layout is stable across the benchmark's year range (verified 2000 & 2012 → exactly 30 fields). Fields are space-padded within the pipe delimiters and numerics are zero-/dot-padded (e.g. 00.0000000000), so string fields are stripped and numeric fields coerced.

col_url(year)

Public download URL for a given commodity year's Cause of Loss ZIP.

Source code in terraflow/drought/rma.py
def col_url(year: int) -> str:
    """Public download URL for a given commodity year's Cause of Loss ZIP."""
    return f"{BASE_URL}/colsom_{year}.zip"

download_col_years(years, dest_dir, *, overwrite=False)

Download (and cache) the Cause of Loss ZIP for each year into dest_dir.

Returns the list of local ZIP paths. Existing files are reused unless overwrite=True.

Source code in terraflow/drought/rma.py
def download_col_years(years: list[int], dest_dir: Path, *, overwrite: bool = False) -> list[Path]:
    """Download (and cache) the Cause of Loss ZIP for each year into *dest_dir*.

    Returns the list of local ZIP paths. Existing files are reused unless ``overwrite=True``.
    """
    dest_dir = Path(dest_dir)
    dest_dir.mkdir(parents=True, exist_ok=True)
    paths: list[Path] = []
    for year in years:
        target = dest_dir / f"colsom_{year}.zip"
        if overwrite or not target.exists():
            urllib.request.urlretrieve(col_url(year), target)  # noqa: S310 (trusted USDA host)
        paths.append(target)
    return paths

load_col(rma_dir, years, *, states=None, commodity=None)

Load and concatenate Cause of Loss records for years from rma_dir.

Accepts either colsom_YYYY.zip or the extracted colsomYY.txt in rma_dir. states / commodity filters are pushed down into each file for speed.

Source code in terraflow/drought/rma.py
def load_col(
    rma_dir: Path,
    years: list[int],
    *,
    states: list[str] | None = None,
    commodity: str | None = None,
) -> pd.DataFrame:
    """Load and concatenate Cause of Loss records for *years* from *rma_dir*.

    Accepts either ``colsom_YYYY.zip`` or the extracted ``colsomYY.txt`` in *rma_dir*. ``states`` /
    ``commodity`` filters are pushed down into each file for speed.
    """
    rma_dir = Path(rma_dir)
    frames: list[pd.DataFrame] = []
    for year in years:
        candidates = [
            rma_dir / f"colsom_{year}.zip",
            rma_dir / f"colsom{year % 100:02d}.txt",
            rma_dir / f"colsom_{year}.txt",
        ]
        path = next((c for c in candidates if c.exists()), None)
        if path is None:
            raise FileNotFoundError(
                f"No Cause of Loss file for {year} in {rma_dir} "
                f"(expected one of: {', '.join(c.name for c in candidates)})"
            )
        frames.append(parse_col_file(path, states=states, commodity=commodity))
    return pd.concat(frames, ignore_index=True)

parse_col_file(path, *, states=None, commodity=None)

Parse a single Cause of Loss file (.txt or .zip) into a tidy DataFrame.

String fields are stripped; numeric fields are coerced. Adds a 5-digit GEOID (state FIPS + county FIPS) matching the flashdry predictor tables. Optional states / commodity filters are applied before the (costly) strip/convert of every column, which keeps whole-corpus loads fast when only one crop/region is needed.

Source code in terraflow/drought/rma.py
def parse_col_file(
    path: Path,
    *,
    states: list[str] | None = None,
    commodity: str | None = None,
) -> pd.DataFrame:
    """Parse a single Cause of Loss file (``.txt`` or ``.zip``) into a tidy DataFrame.

    String fields are stripped; numeric fields are coerced. Adds a 5-digit ``GEOID`` (state FIPS +
    county FIPS) matching the flashdry predictor tables. Optional ``states`` / ``commodity`` filters
    are applied *before* the (costly) strip/convert of every column, which keeps whole-corpus loads
    fast when only one crop/region is needed.
    """
    handle = _open_col_text(path)
    try:
        df = pd.read_csv(
            handle,
            sep="|",
            header=None,
            names=list(COL_COLUMNS),
            dtype=str,
            encoding="latin-1",
            na_filter=False,
        )
    finally:
        handle.close()

    # Cheap early filter on just the two key columns to shrink the frame before full processing.
    if states is not None:
        df = df[df["state_code"].str.strip().str.zfill(2).isin(states)]
    if commodity is not None:
        df = df[df["commodity_name"].str.strip() == commodity]
    df = df.copy()

    for col in COL_COLUMNS:
        df[col] = df[col].str.strip()
    for col in _NUMERIC_COLUMNS:
        df[col] = pd.to_numeric(df[col], errors="coerce")

    df["state_code"] = df["state_code"].str.zfill(2)
    df["county_code"] = df["county_code"].str.zfill(3)
    df["GEOID"] = df["state_code"] + df["county_code"]
    return df

Labels

labels

Build county-crop-year drought-loss labels from parsed RMA Cause of Loss records.

The impact label is what makes this benchmark distinct from severity (USDM D2+) and yield (CY-Bench/SustainBench) benchmarks: it is the insured drought loss actually paid out.

:func:build_labels aggregates Cause of Loss into per-(GEOID, year) numerators (drought/total indemnity) plus a fallback loss-experience liability. :func:finalize_targets then computes the primary targets once the true total insured liability (from the Summary-of-Business coverage file) and planted acres are joined:

  • drought_indemnity = Σ indemnity where cause_of_loss_desc ∈ drought_causes
  • total_indemnity = Σ indemnity over all causes
  • drought_share = drought_indemnity / total_indemnity (clean, in [0, 1])
  • drought_loss_ratio = drought_indemnity / total insured liability (primary regression target)
  • significant_drought_loss = drought_loss_ratio ≥ threshold (binary target)
  • insured_acre_fraction = insured acres / NASS planted acres (coverage-bias column)

Using the Summary-of-Business total liability (all policies) as the denominator removes the >1 artifact of the Cause-of-Loss loss-experience liability (only loss-experiencing policies).

build_labels(col, cfg)

Aggregate parsed Cause of Loss records into per-(GEOID, year) numerators.

Produces drought/total indemnity, the loss-experience (Cause-of-Loss) liability as a fallback denominator col_liability, and the bounded drought_share. The primary drought_loss_ratio / significant_drought_loss targets are computed by :func:finalize_targets once the true Summary-of-Business total liability is joined.

Source code in terraflow/drought/labels.py
def build_labels(col: pd.DataFrame, cfg: DroughtConfig) -> pd.DataFrame:
    """Aggregate parsed Cause of Loss records into per-(GEOID, year) numerators.

    Produces drought/total indemnity, the loss-experience (Cause-of-Loss) liability as a fallback
    denominator ``col_liability``, and the bounded ``drought_share``. The primary
    ``drought_loss_ratio`` / ``significant_drought_loss`` targets are computed by
    :func:`finalize_targets` once the true Summary-of-Business total liability is joined.
    """
    scoped = col[
        col["state_code"].isin(cfg.states)
        & (col["commodity_name"] == cfg.crop)
        & col["commodity_year"].between(cfg.year_min, cfg.year_max)
    ].copy()

    scoped["_is_drought"] = scoped["cause_of_loss_desc"].isin(cfg.drought_causes)
    scoped["_drought_indemnity"] = np.where(scoped["_is_drought"], scoped["indemnity_amount"], 0.0)

    grouped = scoped.groupby(["GEOID", "commodity_year"], as_index=False).agg(
        drought_indemnity=("_drought_indemnity", "sum"),
        total_indemnity=("indemnity_amount", "sum"),
        col_liability=("liability", "sum"),
    )
    grouped = grouped.rename(columns={"commodity_year": "year"})
    grouped["drought_share"] = _safe_ratio(grouped["drought_indemnity"], grouped["total_indemnity"])
    grouped["year"] = grouped["year"].astype(int)
    return grouped[list(LABEL_COLUMNS)].sort_values(["GEOID", "year"]).reset_index(drop=True)

finalize_targets(df, cfg)

Compute the final targets on the fully-joined benchmark frame.

Uses the true Summary-of-Business total_liability as the drought-loss-ratio denominator when present (removing the loss-experience >1 artifact), else falls back to col_liability. Adds the insured_acre_fraction coverage-bias column when both insured and planted acres are available.

Source code in terraflow/drought/labels.py
def finalize_targets(df: pd.DataFrame, cfg: DroughtConfig) -> pd.DataFrame:
    """Compute the final targets on the fully-joined benchmark frame.

    Uses the true Summary-of-Business ``total_liability`` as the drought-loss-ratio denominator when
    present (removing the loss-experience >1 artifact), else falls back to ``col_liability``. Adds the
    ``insured_acre_fraction`` coverage-bias column when both insured and planted acres are available.
    """
    out = df.copy()
    liability = out["total_liability"] if "total_liability" in out.columns else out["col_liability"]
    out["drought_loss_ratio"] = _safe_ratio(out["drought_indemnity"], liability)
    out["significant_drought_loss"] = out["drought_loss_ratio"] >= cfg.loss_ratio_threshold
    if "insured_acres" in out.columns and "planted_acres" in out.columns:
        planted = out["planted_acres"].to_numpy(dtype=float)
        insured = out["insured_acres"].to_numpy(dtype=float)
        # Missing/withheld planted acres -> unknown coverage (NaN), not a spurious 0.0.
        out["insured_acre_fraction"] = np.divide(insured, planted, out=np.full(len(out), np.nan), where=planted > 0)
    return out

Predictors

predictors

Aggregate flashdry within-season predictors to one vector per (GEOID, crop-year).

The flashdry feature_table.parquet is sub-annual (growing-season, ~biweekly). For an early-warning task we aggregate each base feature over the season up to cutoff_doy — so a model only sees signal available by, e.g., end of July — into {mean, min, max, last} summaries.

Two feature families are tagged so baselines can select them: - climate/vegetation — the 30 deseasonalized *_anom features + NDVI_anom_z - severitydm_gte_d2 / dm_class (USDM drought severity). Aggregating these lets us build a severity-only baseline and quantify how much within-season climate signal adds on top of the (strong) USDM severity indicator for predicting realized drought loss.

aggregate_predictors(feature_table, cfg)

Aggregate within-season predictors up to cfg.cutoff_doy per (GEOID, year).

Returns one row per (GEOID, year) with {mean, min, max, last} of every climate and severity feature, plus n_obs (observations before the cutoff) and n_stress_weeks (vegetation ≤ −1 SD). last is the value at the largest DOY at or before the cutoff.

Source code in terraflow/drought/predictors.py
def aggregate_predictors(feature_table: pd.DataFrame, cfg: DroughtConfig) -> pd.DataFrame:
    """Aggregate within-season predictors up to ``cfg.cutoff_doy`` per (GEOID, year).

    Returns one row per (GEOID, year) with {mean, min, max, last} of every climate and severity
    feature, plus ``n_obs`` (observations before the cutoff) and ``n_stress_weeks`` (vegetation
    ≤ −1 SD). ``last`` is the value at the largest DOY at or before the cutoff.
    """
    df = feature_table[
        (feature_table["doy"] <= cfg.cutoff_doy)
        & feature_table["year"].between(cfg.year_min, cfg.year_max)
        & feature_table["STATEFP"].isin(cfg.states)
    ].copy()
    df = df.sort_values(["GEOID", "year", "doy"])

    bases = list(CLIMATE_FEATURES + SEVERITY_FEATURES)
    agg_spec = {b: list(_STATS) for b in bases}
    grouped = df.groupby(["GEOID", "year"]).agg(agg_spec)
    grouped.columns = [f"{base}_{stat}" for base, stat in grouped.columns]

    extras = df.groupby(["GEOID", "year"]).agg(
        n_obs=("doy", "size"),
        n_stress_weeks=("NDVI_anom_z", lambda s: int((s <= -1.0).sum())),
    )
    out = grouped.join(extras).reset_index()
    out["year"] = out["year"].astype(int)
    return out

Splits

splits

Reproducible evaluation splits for the drought-impact benchmark.

Three complementary regimes, all deterministic from the config (no RNG): - temporal — held-out years (incl. the 2012 extreme + recent 2022/2023) test extrapolation to unseen seasons. - spatial — leave-one-state-out blocks test spatial transfer (guards against spatially autocorrelated leakage; finer lon/lat grids are a follow-up). - loyo — leave-one-year-out folds for a stability estimate across all years.

Split definitions (not enumerated rows) are serialized so the artifact is small and the masks are re-derivable from any benchmark table.

describe_splits(cfg)

JSON-serializable split definition (re-derivable, not enumerated).

Source code in terraflow/drought/splits.py
def describe_splits(cfg: DroughtConfig) -> dict:
    """JSON-serializable split definition (re-derivable, not enumerated)."""
    return {
        "temporal": {
            "test_years": list(cfg.test_years),
            "train_max_year": cfg.train_max_year,
            "rule": "test = year in test_years; train = remaining years <= train_max_year",
        },
        "spatial": {"scheme": "leave-one-state-out", "block_key": "GEOID[:2]"},
        "loyo": {"scheme": "leave-one-year-out", "years": cfg.years},
    }

loyo_folds(df)

Leave-one-year-out folds: (year, train_mask, test_mask).

Source code in terraflow/drought/splits.py
def loyo_folds(df: pd.DataFrame) -> Iterator[tuple[int, np.ndarray, np.ndarray]]:
    """Leave-one-year-out folds: (year, train_mask, test_mask)."""
    for year in sorted(df["year"].unique()):
        test = (df["year"] == year).to_numpy()
        yield int(year), ~test, test

spatial_block_ids(df)

State-level spatial block id (first two GEOID digits = state FIPS).

Source code in terraflow/drought/splits.py
def spatial_block_ids(df: pd.DataFrame) -> pd.Series:
    """State-level spatial block id (first two GEOID digits = state FIPS)."""
    return df["GEOID"].str[:2]

spatial_folds(df)

Leave-one-state-out folds: (state, train_mask, test_mask).

Source code in terraflow/drought/splits.py
def spatial_folds(df: pd.DataFrame) -> Iterator[tuple[str, np.ndarray, np.ndarray]]:
    """Leave-one-state-out folds: (state, train_mask, test_mask)."""
    blocks = spatial_block_ids(df)
    for state in sorted(blocks.unique()):
        test = (blocks == state).to_numpy()
        yield state, ~test, test

temporal_masks(df, cfg)

(train_mask, test_mask) for the official temporal split.

Source code in terraflow/drought/splits.py
def temporal_masks(df: pd.DataFrame, cfg: DroughtConfig) -> tuple[np.ndarray, np.ndarray]:
    """(train_mask, test_mask) for the official temporal split."""
    test = df["year"].isin(cfg.test_years).to_numpy()
    train = (~test) & (df["year"] <= cfg.train_max_year).to_numpy()
    return train, test

Dataset assembly

dataset

Assemble the drought-impact benchmark table and load it back.

Pipeline: parse RMA Cause of Loss → numerator labels → join the true total insured liability (Summary-of-Business coverage file) and NASS planted acres → aggregate flashdry predictors → LEFT-join predictors ⋈ labels on (GEOID, year) → finalize the drought-loss-ratio + binary + coverage targets. County-years present in the predictor panel but absent from Cause of Loss are genuine insured-loss negatives (drought_loss_ratio = 0, not significant) and are retained and filled.

Writes benchmark.parquet + manifest.json (config snapshot + input fingerprints + a deterministic build fingerprint) + splits.json.

assemble_benchmark(cfg, *, write=True, nass_api_key=None)

Build the benchmark table (and, by default, persist artifacts under cfg.output_dir).

Source code in terraflow/drought/dataset.py
def assemble_benchmark(cfg: DroughtConfig, *, write: bool = True, nass_api_key: str | None = None) -> pd.DataFrame:
    """Build the benchmark table (and, by default, persist artifacts under ``cfg.output_dir``)."""
    col = load_col(cfg.rma_dir, cfg.years, states=cfg.states, commodity=cfg.crop)
    labels = build_labels(col, cfg)
    labels["GEOID"] = labels["GEOID"].astype(str)

    feature_table = pd.read_parquet(cfg.feature_table)
    feature_table["GEOID"] = feature_table["GEOID"].astype(str)
    predictors = aggregate_predictors(feature_table, cfg)
    predictors["GEOID"] = predictors["GEOID"].astype(str)

    benchmark = predictors.merge(labels, on=["GEOID", "year"], how="left").fillna(_COL_FILL)

    if cfg.sob_dir is not None:
        sob = load_sob(cfg.sob_dir, cfg.years, states=cfg.states, commodity=cfg.crop)
        sob_agg = aggregate_sob(sob)
        sob_agg["GEOID"] = sob_agg["GEOID"].astype(str)
        benchmark = benchmark.merge(sob_agg, on=["GEOID", "year"], how="left").fillna(_SOB_FILL)

    extra_digests: list[dict] = []
    if cfg.add_coverage:
        key = nass_api_key or os.environ.get("NASS_API_KEY")
        if key:
            nass = fetch_planted_acres(cfg.state_alphas, cfg.crop, key)
            nass["GEOID"] = nass["GEOID"].astype(str)
            benchmark = benchmark.merge(nass, on=["GEOID", "year"], how="left")
            # NASS is a live source (no local file); hash the fetched acreage so revised values
            # invalidate the build fingerprint, preserving the reproducibility contract.
            nass_bytes = nass.sort_values(["GEOID", "year"]).to_csv(index=False).encode("utf-8")
            extra_digests.append(
                {"path": f"nass:quickstats:{cfg.crop}", "sha256": hashlib.sha256(nass_bytes).hexdigest()}
            )

    benchmark = finalize_targets(benchmark, cfg)
    benchmark["significant_drought_loss"] = benchmark["significant_drought_loss"].astype(bool)
    benchmark = benchmark.sort_values(["GEOID", "year"]).reset_index(drop=True)

    if write:
        _write_artifacts(benchmark, cfg, extra_digests=extra_digests)
    return benchmark

build_fingerprint(cfg, input_digests)

Deterministic build fingerprint over the canonical config + input file digests.

Source code in terraflow/drought/dataset.py
def build_fingerprint(cfg: DroughtConfig, input_digests: list[dict]) -> str:
    """Deterministic build fingerprint over the canonical config + input file digests."""
    hasher = hashlib.sha256()
    hasher.update(canonicalize_config(_config_dict(cfg)))
    for d in sorted(input_digests, key=lambda x: x["path"]):
        hasher.update(d["sha256"].encode("utf-8"))
    return hasher.hexdigest()

load_benchmark(output_dir)

Load a previously assembled benchmark.parquet.

Source code in terraflow/drought/dataset.py
def load_benchmark(output_dir: Path) -> pd.DataFrame:
    """Load a previously assembled ``benchmark.parquet``."""
    path = Path(output_dir) / "benchmark.parquet"
    if not path.exists():
        raise FileNotFoundError(f"No benchmark at {path}; run `assemble_benchmark` first.")
    return pd.read_parquet(path)

Baselines & metrics

baselines

Baseline models for the drought-impact benchmark.

Three tiers, to make the leaderboard interpretable: - naive — constant train mean, and per-county historical mean (the bar any model must beat). - severity-only — a model on the USDM severity aggregates alone. USDM D2+ is a strong baseline for loss; the point of isolating it is to quantify how much within-season climate signal adds on top (and that it is available earlier in the season for early warning). - climate ML — Ridge / RandomForest / GradientBoosting on the within-season anomaly features.

All estimators are seeded (random_state=0, n_jobs=1) so the leaderboard is deterministic.

CountyHistoryBaseline

Predict each county's historical (training) mean target, falling back to the global mean.

MeanBaseline

Predict the constant training mean of the target.

feature_columns(which)

Feature column names for a feature set: 'climate', 'severity', or 'all'.

Source code in terraflow/drought/baselines.py
def feature_columns(which: str) -> list[str]:
    """Feature column names for a feature set: 'climate', 'severity', or 'all'."""
    if which == "climate":
        return climate_predictor_columns() + ["n_obs", "n_stress_weeks"]
    if which == "severity":
        return severity_predictor_columns()
    if which == "all":
        return feature_columns("climate") + feature_columns("severity")
    raise ValueError(f"Unknown feature set: {which!r}")

feature_matrix(df, which)

Numeric feature matrix (NaN → 0.0; anomalies are z-scores, so 0 is neutral).

Source code in terraflow/drought/baselines.py
def feature_matrix(df: pd.DataFrame, which: str) -> np.ndarray:
    """Numeric feature matrix (NaN → 0.0; anomalies are z-scores, so 0 is neutral)."""
    cols = feature_columns(which)
    return df[cols].to_numpy(dtype=float, na_value=0.0)

metrics

Evaluation metrics for the drought-impact benchmark.

Regression (continuous drought_loss_ratio): R², RMSE, and Spearman rank correlation — the rank metric is the robust headline because the loss-experience ratio has a heavy tail and can exceed 1 (see :mod:terraflow.drought.labels).

Classification (binary significant_drought_loss): ROC-AUC, average precision (PR-AUC — the positive class is imbalanced, so PR-AUC is the honest headline), and Brier score.

classification_metrics(y_true, y_score)

ROC-AUC, average precision (PR-AUC), Brier. AUC/AP are NaN if only one class present.

Source code in terraflow/drought/metrics.py
def classification_metrics(y_true: np.ndarray, y_score: np.ndarray) -> dict[str, float]:
    """ROC-AUC, average precision (PR-AUC), Brier. AUC/AP are NaN if only one class present."""
    y_true = np.asarray(y_true, dtype=int)
    y_score = np.asarray(y_score, dtype=float)
    single_class = len(np.unique(y_true)) < 2
    return {
        "roc_auc": float("nan") if single_class else float(roc_auc_score(y_true, y_score)),
        "pr_auc": float("nan") if single_class else float(average_precision_score(y_true, y_score)),
        "brier": float(brier_score_loss(y_true, np.clip(y_score, 0.0, 1.0))),
        "positives": int(y_true.sum()),
        "n": int(len(y_true)),
    }

regression_metrics(y_true, y_pred)

R², RMSE, Spearman ρ. Returns NaNs gracefully for degenerate inputs.

Source code in terraflow/drought/metrics.py
def regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
    """R², RMSE, Spearman ρ. Returns NaNs gracefully for degenerate inputs."""
    y_true = np.asarray(y_true, dtype=float)
    y_pred = np.asarray(y_pred, dtype=float)
    rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
    r2 = float(r2_score(y_true, y_pred)) if len(np.unique(y_true)) > 1 else float("nan")
    # Spearman is undefined if either input is constant (e.g. a constant-mean baseline).
    constant = np.unique(y_true).size < 2 or np.unique(y_pred).size < 2
    if len(y_true) <= 2 or constant:
        rho = float("nan")
    else:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            rho = float(spearmanr(y_true, y_pred).statistic)
    return {"r2": r2, "rmse": rmse, "spearman": rho}

Evaluation

evaluate

Run the drought-impact benchmark leaderboard.

Headline = the official temporal split (train on early years, test on held-out years incl. the 2012 extreme). Also reports a spatial leave-one-state-out summary for the climate models. The severity-only baseline is included in both to expose the severity≠impact gap.

Writes evaluate_report.json and leaderboard.csv to the run's output directory.

run_leaderboard(benchmark, cfg, *, write_dir=None)

Compute the full leaderboard; optionally persist report + CSV to write_dir.

Source code in terraflow/drought/evaluate.py
def run_leaderboard(benchmark: pd.DataFrame, cfg: DroughtConfig, *, write_dir: Path | None = None) -> dict:
    """Compute the full leaderboard; optionally persist report + CSV to ``write_dir``."""
    train_mask, test_mask = temporal_masks(benchmark, cfg)
    train, test = benchmark[train_mask], benchmark[test_mask]
    if len(train) == 0 or len(test) == 0:
        raise ValueError("Temporal split produced an empty train or test set; check config years.")

    report = {
        "temporal": _temporal_leaderboard(train, test),
        "spatial_loso": _spatial_summary(benchmark),
        "counts": {"n_train": int(len(train)), "n_test": int(len(test)), "test_years": list(cfg.test_years)},
    }

    if write_dir is not None:
        write_dir = Path(write_dir)
        write_dir.mkdir(parents=True, exist_ok=True)
        (write_dir / "evaluate_report.json").write_text(json.dumps(_json_safe(report), indent=2), encoding="utf-8")
        _leaderboard_frame(report).to_csv(write_dir / "leaderboard.csv", index=False)
    return report