From af4a03546c72b72021b9b322b73b4b5e8aa581d6 Mon Sep 17 00:00:00 2001
From: Pete Steward
Date: Wed, 24 Jun 2026 22:26:15 +0300
Subject: [PATCH] feat: add phase-1 human heat helpers
---
README.md | 25 ++
.../issues/issue_human_heat_propagation.md | 46 +++
.../issue_human_heat_stress_planning.md | 118 +++++++
climate_tookit/climatology/__init__.py | 12 +
.../climatology/human_heat_stress.py | 294 ++++++++++++++++++
docs/human_heat_workflow.md | 88 ++++++
tests/test_human_heat_stress.py | 95 ++++++
7 files changed, 678 insertions(+)
create mode 100644 analysis/issues/issue_human_heat_propagation.md
create mode 100644 analysis/issues/issue_human_heat_stress_planning.md
create mode 100644 climate_tookit/climatology/human_heat_stress.py
create mode 100644 docs/human_heat_workflow.md
create mode 100644 tests/test_human_heat_stress.py
diff --git a/README.md b/README.md
index c7a7383..1dc0881 100644
--- a/README.md
+++ b/README.md
@@ -312,6 +312,31 @@ Dedicated method guide:
- [docs/thi_workflow.md](docs/thi_workflow.md)
+Human heat first-pass note:
+
+- toolkit now also exposes xclim-backed `humidex` helper functions for people
+- this is phase-1 continuous metric support, not full human hazard
+ classification
+- `WBGT` and `UTCI` are intentionally deferred until wind/radiation support is
+ more coherent across intended workflows
+
+Python users can inspect current human-heat choice directly:
+
+```python
+from climate_tookit.climatology import (
+ compute_daily_humidex,
+ describe_human_heat_method,
+ describe_human_heat_source_support,
+)
+
+print(describe_human_heat_method()["metric"])
+print(describe_human_heat_source_support())
+```
+
+Dedicated method guide:
+
+- [docs/human_heat_workflow.md](docs/human_heat_workflow.md)
+
Example:
```bash
diff --git a/analysis/issues/issue_human_heat_propagation.md b/analysis/issues/issue_human_heat_propagation.md
new file mode 100644
index 0000000..183dcbe
--- /dev/null
+++ b/analysis/issues/issue_human_heat_propagation.md
@@ -0,0 +1,46 @@
+## Summary
+
+Propagate phase-1 human heat metric support through package workflows.
+
+Current state:
+- `climate_tookit.climatology.human_heat_stress` now provides xclim-backed
+ `humidex` helpers
+- method choice and source audit resolved in `#91`
+
+Missing:
+- `climate_statistics` summaries
+- `compare_periods` outputs
+- `calculate_hazards` decision on whether generic human heat classes belong in
+ hazard workflow yet
+
+## Scope
+
+Use `humidex` as current human heat metric. Do not reopen `WBGT` / `UTCI`
+selection here.
+
+## Tasks
+
+1. `climate_statistics`
+- add optional human heat summary block when humidity or dewpoint-backed inputs
+ exist
+- keep output explicit that this is continuous metric support
+
+2. `compare_periods`
+- add human heat summary in baseline/focal/future comparisons where source
+ support exists
+
+3. `calculate_hazards`
+- decide whether phase-1 stops at continuous metric
+- if any bands are added, label them as generic screening, not definitive
+ occupational-health thresholds
+
+4. docs/tests
+- update docs and CLI examples
+- add regression tests across supported and unsupported source patterns
+
+## Acceptance criteria
+
+- human heat summary available in at least `climate_statistics`
+- compare-period reporting path documented or implemented
+- hazard-layer stance explicit
+- no false claim that toolkit supports full `WBGT`/`UTCI` workflow
diff --git a/analysis/issues/issue_human_heat_stress_planning.md b/analysis/issues/issue_human_heat_stress_planning.md
new file mode 100644
index 0000000..a9c8bdc
--- /dev/null
+++ b/analysis/issues/issue_human_heat_stress_planning.md
@@ -0,0 +1,118 @@
+## Issue #91 human heat-stress method note
+
+## Decision
+
+Phase 1 human heat metric should be `humidex`, implemented through `xclim`.
+
+Phase 1 scope:
+- continuous metric support only
+- no hazard-band semantics yet
+- no claim that `humidex` is globally best physiological metric
+
+Reason:
+- package can support `humidex` from daily mean temperature plus humidity or
+ dewpoint
+- this works across current historical source paths better than `WBGT` or
+ `UTCI`
+- future-path support is possible where `nex_gddp` has `hurs`
+
+## Candidate review
+
+### `humidex`
+
+Selected first.
+
+Pros:
+- available in `xclim`
+- accepts dewpoint or relative humidity path
+- fits current package input coverage
+- feasible for:
+ - `agera_5`
+ - `nasa_power`
+ - humidity-enabled stations / custom uploads
+ - conditional `nex_gddp` runs with `hurs`
+
+### `heat_index`
+
+Not selected as first default.
+
+Reason:
+- `xclim` supports it, but notes validity only for temperatures above `20C`
+- equation assumes instantaneous temperature and humidity, while toolkit mostly
+ works with daily summaries
+- less attractive as first global default for mixed tropical / highland /
+ Andean workflows
+
+### `UTCI`
+
+Deferred.
+
+Reason:
+- `xclim` supports it
+- operational workflow needs temperature, humidity, wind, and mean radiant
+ temperature or radiation terms
+- current package does not have coherent future-path support for those broader
+ inputs
+
+### `WBGT`
+
+Deferred.
+
+Reason:
+- broader radiation / wind treatment needed for operational use
+- current package source coverage is not coherent enough for first-pass future
+ workflow
+
+## Source audit
+
+### Strong first-pass support
+
+- `agera_5`
+ - humidity available through current dewpoint + air-temperature derivation
+- `nasa_power`
+ - humidity available in current fetch path
+- `ghcn_daily`, `gsod`, `custom_station`
+ - supported when humidity / dewpoint fields exist
+
+### Conditional support
+
+- `nex_gddp`
+ - usable for `humidex` only when Earth Engine `hurs` exists for selected
+ model / scenario / period
+
+### Weak or unsupported
+
+- `era_5`
+ - current runtime path does not expose operational humidity support clearly
+- `chirps_v2`, `chirps_v3_daily_rnl`, `imerg`, `tamsat`
+ - no human heat metric support as standalone sources
+- `chirts`
+ - temperature-only path, insufficient for `humidex`
+
+## Package direction
+
+Immediate implementation slice:
+1. add `climate_tookit.climatology.human_heat_stress`
+2. expose `compute_daily_humidex()` helper
+3. expose source-support + method-description helpers
+4. add tests
+
+Later propagation:
+1. `climate_statistics`
+2. `compare_periods`
+3. `calculate_hazards`
+
+## References used for decision
+
+- local `xclim` runtime/docstrings:
+ - `humidex(tas, tdps=None, hurs=None)`
+ - `heat_index(tas, hurs)` with validity note above `20C`
+ - `universal_thermal_climate_index(tas, hurs, sfcWind, mrt|radiation...)`
+- toolkit source audit in current repo
+
+## Closing logic
+
+`#91` can close once:
+- method choice accepted
+- helper lands
+- remaining propagation work split into implementation follow-up if needed
diff --git a/climate_tookit/climatology/__init__.py b/climate_tookit/climatology/__init__.py
index 5d2244c..d78ef28 100644
--- a/climate_tookit/climatology/__init__.py
+++ b/climate_tookit/climatology/__init__.py
@@ -11,10 +11,13 @@
__all__ = [
"build_thi_hazard_thresholds",
"classify_thi_values",
+ "compute_daily_humidex",
"compute_monthly_spei",
"compute_monthly_spi",
"compute_daily_thi",
"compute_daily_vpd",
+ "describe_human_heat_method",
+ "describe_human_heat_source_support",
"describe_thi_method",
"DEFAULT_LIVESTOCK_CLIMATE_PROFILE",
"DEFAULT_LIVESTOCK_TYPE",
@@ -24,6 +27,7 @@
"prepare_monthly_climatic_water_balance",
"prepare_monthly_precipitation_totals",
"resolve_thi_profile",
+ "summarize_humidex_period",
"summarize_vpd_period",
"summarize_thi_periods",
"assess_xclim_precip_annual_readiness",
@@ -38,6 +42,14 @@
def __getattr__(name: str):
+ if name in {
+ "compute_daily_humidex",
+ "describe_human_heat_method",
+ "describe_human_heat_source_support",
+ "summarize_humidex_period",
+ }:
+ module = import_module(".human_heat_stress", __name__)
+ return getattr(module, name)
if name in {
"build_thi_hazard_thresholds",
"classify_thi_values",
diff --git a/climate_tookit/climatology/human_heat_stress.py b/climate_tookit/climatology/human_heat_stress.py
new file mode 100644
index 0000000..8c21c72
--- /dev/null
+++ b/climate_tookit/climatology/human_heat_stress.py
@@ -0,0 +1,294 @@
+"""Human heat-stress helpers.
+
+Phase 1 chooses xclim-backed Humidex as first operational human heat metric.
+
+Why Humidex first:
+- works from mean temperature plus humidity or dewpoint
+- aligns with current package source coverage better than WBGT / UTCI
+- available through xclim
+
+Why not WBGT / UTCI first:
+- both need broader input support, especially wind and radiation / mean radiant
+ temperature, than package currently has across future workflows
+- current NEX-GDDP path can provide temperature plus conditional humidity, but
+ not coherent wind / radiation support for operational human heat metrics
+"""
+
+from __future__ import annotations
+
+from importlib.util import find_spec
+from typing import Any, Optional, Sequence
+
+import pandas as pd
+
+XCLIM_AVAILABLE = bool(find_spec("xarray")) and bool(find_spec("xclim"))
+
+_TMEAN_COLS = ("mean_temperature", "tavg", "tmean", "temperature", "temp", "tas")
+_TMAX_COLS = ("max_temperature", "tmax", "tasmax")
+_TMIN_COLS = ("min_temperature", "tmin", "tasmin")
+_HUMIDITY_COLS = ("humidity", "relative_humidity", "hurs", "RHAV", "rh")
+_DEWPOINT_COLS = (
+ "dewpoint",
+ "dewpoint_temperature",
+ "dewpoint_temperature_2m",
+ "tdew",
+ "tdps",
+)
+
+_HUMAN_HEAT_SOURCE_SUPPORT = {
+ "agera_5": (
+ "supported: humidity available via current dewpoint + air-temperature derivation; "
+ "best first-pass historical source"
+ ),
+ "nasa_power": "supported: humidity available in current POWER fetch path",
+ "ghcn_daily": "supported when RHAV humidity field exists for chosen station and window",
+ "gsod": "supported when humidity field exists for chosen station and window",
+ "custom_station": "supported when uploaded file includes humidity / RH or dewpoint column",
+ "nex_gddp": (
+ "conditionally supported for humidex only: can use hurs when present, but some GEE "
+ "model/scenario combinations lack that band"
+ ),
+ "era_5": (
+ "uncertain: current toolkit ERA5 fetch configuration does not expose operational humidity "
+ "support for human heat workflow"
+ ),
+ "chirps_v2": "not supported: precipitation-only source",
+ "chirps_v3_daily_rnl": "not supported: precipitation-only source",
+ "imerg": "not supported: precipitation-only source",
+ "tamsat": "not supported: precipitation-only source",
+ "chirts": "not supported: temperature-only source",
+}
+
+
+def _ensure_xclim():
+ if not XCLIM_AVAILABLE:
+ raise RuntimeError("xclim is not installed in this environment.")
+ import xarray as xr # local import to avoid import-time side effects
+ import xclim.indices as xci
+
+ return xr, xci
+
+
+def _detect_column(frame: pd.DataFrame, candidates: Sequence[str]) -> Optional[str]:
+ return next((column for column in candidates if column in frame.columns), None)
+
+
+def _to_numeric_celsius(series: pd.Series) -> pd.Series:
+ values = pd.to_numeric(series, errors="coerce")
+ clean = values.dropna()
+ if not clean.empty and float(clean.mean()) > 100.0:
+ return values - 273.15
+ return values
+
+
+def _build_data_array(dates: pd.Series, values: pd.Series, *, units: str):
+ xr, _ = _ensure_xclim()
+ return xr.DataArray(
+ pd.to_numeric(values, errors="coerce").astype(float).to_numpy(),
+ coords={"time": pd.to_datetime(dates).to_numpy()},
+ dims="time",
+ attrs={"units": units},
+ )
+
+
+def _resolve_temperature(
+ frame: pd.DataFrame,
+ *,
+ tmean_col: Optional[str] = None,
+ tmax_col: Optional[str] = None,
+ tmin_col: Optional[str] = None,
+) -> tuple[pd.Series, str]:
+ mean_column = tmean_col or _detect_column(frame, _TMEAN_COLS)
+ if mean_column:
+ return _to_numeric_celsius(frame[mean_column]), mean_column
+
+ max_column = tmax_col or _detect_column(frame, _TMAX_COLS)
+ min_column = tmin_col or _detect_column(frame, _TMIN_COLS)
+ if max_column and min_column:
+ tmax = _to_numeric_celsius(frame[max_column])
+ tmin = _to_numeric_celsius(frame[min_column])
+ return (tmax + tmin) / 2.0, "derived_from_tmax_tmin"
+
+ raise ValueError(
+ "Human heat workflow needs mean temperature or both tmax and tmin."
+ )
+
+
+def _resolve_humidity(
+ frame: pd.DataFrame,
+ *,
+ humidity_col: Optional[str] = None,
+) -> tuple[pd.Series, str]:
+ column = humidity_col or _detect_column(frame, _HUMIDITY_COLS)
+ if not column:
+ raise ValueError("Missing required humidity column for humidex workflow.")
+ humidity = pd.to_numeric(frame[column], errors="coerce")
+ valid = humidity.between(0.0, 100.0) | humidity.isna()
+ if not bool(valid.all()):
+ raise ValueError("Humidity values for humidex must be between 0 and 100 percent.")
+ return humidity, column
+
+
+def _resolve_dewpoint(
+ frame: pd.DataFrame,
+ *,
+ dewpoint_col: Optional[str] = None,
+) -> tuple[pd.Series, str]:
+ column = dewpoint_col or _detect_column(frame, _DEWPOINT_COLS)
+ if not column:
+ raise ValueError("Missing required dewpoint column for humidex workflow.")
+ return _to_numeric_celsius(frame[column]), column
+
+
+def compute_daily_humidex(
+ frame: pd.DataFrame,
+ *,
+ date_col: str = "date",
+ method: str = "auto",
+ humidity_col: Optional[str] = None,
+ dewpoint_col: Optional[str] = None,
+ tmean_col: Optional[str] = None,
+ tmax_col: Optional[str] = None,
+ tmin_col: Optional[str] = None,
+) -> pd.DataFrame:
+ """Compute daily Humidex using xclim.
+
+ Auto mode prefers dewpoint when available, else relative humidity.
+ """
+ if date_col not in frame.columns:
+ raise ValueError(f"Missing required date column: {date_col}")
+
+ _, xci = _ensure_xclim()
+ out = frame.copy()
+ out[date_col] = pd.to_datetime(out[date_col])
+ out = out.sort_values(date_col).reset_index(drop=True)
+
+ resolved_method = str(method or "auto").strip().lower()
+ if resolved_method == "auto":
+ if dewpoint_col or _detect_column(out, _DEWPOINT_COLS):
+ resolved_method = "dewpoint"
+ elif humidity_col or _detect_column(out, _HUMIDITY_COLS):
+ resolved_method = "relative_humidity"
+ else:
+ raise ValueError(
+ "Humidex auto mode needs humidity or dewpoint inputs."
+ )
+
+ temperature_c, temperature_source = _resolve_temperature(
+ out,
+ tmean_col=tmean_col,
+ tmax_col=tmax_col,
+ tmin_col=tmin_col,
+ )
+ tas_da = _build_data_array(out[date_col], temperature_c, units="degC")
+ metadata: dict[str, Any] = {
+ "backend": "xclim",
+ "metric": "humidex",
+ "temperature_source": temperature_source,
+ "phase1_scope": "continuous_metric_only",
+ }
+
+ if resolved_method == "dewpoint":
+ dewpoint_c, dewpoint_source = _resolve_dewpoint(out, dewpoint_col=dewpoint_col)
+ dew_da = _build_data_array(out[date_col], dewpoint_c, units="degC")
+ humidex = xci.humidex(tas_da, tdps=dew_da)
+ metadata.update(
+ {
+ "path": "dewpoint",
+ "dewpoint_source_column": dewpoint_source,
+ }
+ )
+ elif resolved_method == "relative_humidity":
+ humidity, humidity_source = _resolve_humidity(out, humidity_col=humidity_col)
+ hurs_da = _build_data_array(out[date_col], humidity, units="%")
+ humidex = xci.humidex(tas_da, hurs=hurs_da)
+ metadata.update(
+ {
+ "path": "relative_humidity",
+ "humidity_source_column": humidity_source,
+ }
+ )
+ else:
+ raise ValueError(
+ "Unsupported humidex method. Use auto, dewpoint, or relative_humidity."
+ )
+
+ out["temperature_c"] = temperature_c
+ out["humidex"] = pd.Series(humidex.values, index=out.index, dtype=float)
+ out.attrs["human_heat_metadata"] = metadata
+ return out
+
+
+def summarize_humidex_period(frame: pd.DataFrame, **kwargs) -> Optional[dict[str, Any]]:
+ try:
+ humidex_df = compute_daily_humidex(frame, **kwargs)
+ except (RuntimeError, ValueError):
+ return None
+
+ if "humidex" not in humidex_df.columns or humidex_df["humidex"].notna().sum() == 0:
+ return None
+
+ return {
+ "mean_humidex": round(float(humidex_df["humidex"].mean()), 3),
+ "max_humidex": round(float(humidex_df["humidex"].max()), 3),
+ "method": humidex_df.attrs.get("human_heat_metadata", {}).get("path"),
+ "phase1_scope": "continuous_metric_only",
+ }
+
+
+def describe_human_heat_source_support() -> dict[str, str]:
+ """Return current source-support notes for phase 1 human heat metric."""
+ return dict(_HUMAN_HEAT_SOURCE_SUPPORT)
+
+
+def describe_human_heat_method() -> dict[str, Any]:
+ """Return phase 1 human heat method choice and rationale."""
+ return {
+ "metric": "humidex",
+ "backend": "xclim",
+ "phase1_status": "selected_for_initial_support",
+ "phase1_scope": "continuous_metric_only",
+ "default_daily_workflow": "daily mean temperature plus humidity or dewpoint",
+ "source_support": describe_human_heat_source_support(),
+ "candidate_review": {
+ "humidex": (
+ "chosen for first pass because xclim supports it directly and package can "
+ "supply required inputs across historical workflows and conditional future humidity paths"
+ ),
+ "heat_index": (
+ "not chosen as phase-1 default because xclim notes validity only above 20C and "
+ "equation assumes instantaneous temperature and humidity values"
+ ),
+ "utci": (
+ "deferred because operational workflow needs wind plus mean-radiant-temperature or "
+ "radiation inputs not coherently available across current future source paths"
+ ),
+ "wbgt": (
+ "deferred because operational workflow needs broader radiation / wind treatment than "
+ "current package future workflows support"
+ ),
+ },
+ "input_requirements": {
+ "temperature": "mean temperature, or derived mean from tmax+tmin",
+ "moisture": "relative humidity in percent or dewpoint temperature",
+ },
+ "interpretation_caveats": [
+ "Phase 1 provides continuous human heat metric support, not hazard-band semantics.",
+ "Humidex is more feasible than UTCI/WBGT for current package source coverage, not necessarily universally superior physiologically.",
+ "Future-path support depends on humidity availability; current NEX-GDDP path remains conditional on hurs presence.",
+ ],
+ "next_steps": [
+ "review whether phase 2 should add generic humidex screening classes or skip directly to UTCI/WBGT where inputs allow",
+ "add package-surface integration into climate_statistics, compare_periods, and hazards after method choice is accepted",
+ "revisit UTCI / WBGT when coherent wind and radiation support exists across intended workflows",
+ ],
+ }
+
+
+__all__ = [
+ "XCLIM_AVAILABLE",
+ "compute_daily_humidex",
+ "describe_human_heat_method",
+ "describe_human_heat_source_support",
+ "summarize_humidex_period",
+]
diff --git a/docs/human_heat_workflow.md b/docs/human_heat_workflow.md
new file mode 100644
index 0000000..d6fb630
--- /dev/null
+++ b/docs/human_heat_workflow.md
@@ -0,0 +1,88 @@
+# Human Heat Workflow
+
+Current first-pass human heat support uses `Humidex` through `xclim`.
+
+This is intentionally narrower than full occupational or outdoor-thermal
+assessment workflows.
+
+## Phase 1 choice
+
+Selected metric:
+- `humidex`
+
+Deferred:
+- `WBGT`
+- `UTCI`
+- package-level hazard classes for people
+
+## Why `humidex` first
+
+- needs only temperature plus humidity or dewpoint
+- available in current toolkit `xclim` stack
+- matches current source coverage better than `WBGT` or `UTCI`
+- can work on some future `nex_gddp` runs when `hurs` is available
+
+## Why not `WBGT` first
+
+`WBGT` is important, but current toolkit does not yet have coherent operational
+support for broader wind / radiation treatment across intended historical and
+future workflows.
+
+## Why not `UTCI` first
+
+`UTCI` is more physically rich, but first-pass package support is constrained by
+input requirements:
+- temperature
+- humidity
+- wind
+- mean radiant temperature or radiation terms
+
+Current package can support those inputs for some historical sources, but not
+coherently across intended future workflows.
+
+## Source support
+
+Strong first-pass support:
+- `agera_5`
+- `nasa_power`
+- `ghcn_daily` when humidity exists
+- `gsod` when humidity exists
+- `custom_station` when humidity or dewpoint exists
+
+Conditional support:
+- `nex_gddp`
+ - only when Earth Engine `hurs` exists for selected model / scenario / period
+
+Not currently suitable:
+- `era_5`
+ - current runtime path does not expose operational humidity support clearly
+- `chirps_v2`
+- `chirps_v3_daily_rnl`
+- `imerg`
+- `tamsat`
+- `chirts`
+
+## Current helper surface
+
+Python helpers:
+- `climate_tookit.climatology.compute_daily_humidex`
+- `climate_tookit.climatology.summarize_humidex_period`
+- `climate_tookit.climatology.describe_human_heat_method`
+- `climate_tookit.climatology.describe_human_heat_source_support`
+
+## Current limits
+
+- no hazard-band semantics yet
+- no `climate_statistics` / `compare_periods` propagation yet
+- no `calculate_hazards` integration yet
+- no claim that `humidex` is globally best human heat metric
+
+## Likely next steps
+
+1. propagate continuous `humidex` summaries into `climate_statistics`
+2. propagate into `compare_periods`
+3. decide whether phase 2 uses:
+ - generic `humidex` screening classes
+ - `WBGT`
+ - `UTCI`
+ - some combination by source capability
diff --git a/tests/test_human_heat_stress.py b/tests/test_human_heat_stress.py
new file mode 100644
index 0000000..94e99ae
--- /dev/null
+++ b/tests/test_human_heat_stress.py
@@ -0,0 +1,95 @@
+import unittest
+
+import pandas as pd
+
+from climate_tookit.climatology import (
+ compute_daily_humidex,
+ describe_human_heat_method,
+ describe_human_heat_source_support,
+ summarize_humidex_period,
+)
+
+
+class HumanHeatStressTests(unittest.TestCase):
+ def test_compute_daily_humidex_uses_dewpoint_path_when_available(self):
+ frame = pd.DataFrame(
+ {
+ "date": pd.to_datetime(["2020-01-01", "2020-01-02"]),
+ "mean_temperature": [30.0, 30.0],
+ "dewpoint_temperature": [24.0, 18.0],
+ }
+ )
+
+ result = compute_daily_humidex(frame)
+
+ self.assertIn("humidex", result.columns)
+ self.assertGreater(result.loc[0, "humidex"], result.loc[1, "humidex"])
+ self.assertEqual("dewpoint", result.attrs["human_heat_metadata"]["path"])
+
+ def test_compute_daily_humidex_uses_relative_humidity_path(self):
+ frame = pd.DataFrame(
+ {
+ "date": pd.to_datetime(["2020-01-01", "2020-01-02"]),
+ "tmax": [32.0, 32.0],
+ "tmin": [24.0, 24.0],
+ "humidity": [80.0, 40.0],
+ }
+ )
+
+ result = compute_daily_humidex(frame)
+
+ self.assertAlmostEqual(28.0, result.loc[0, "temperature_c"], places=6)
+ self.assertGreater(result.loc[0, "humidex"], result.loc[1, "humidex"])
+ self.assertEqual("relative_humidity", result.attrs["human_heat_metadata"]["path"])
+ self.assertEqual("derived_from_tmax_tmin", result.attrs["human_heat_metadata"]["temperature_source"])
+
+ def test_compute_daily_humidex_auto_requires_moisture_input(self):
+ frame = pd.DataFrame(
+ {
+ "date": pd.to_datetime(["2020-01-01"]),
+ "tmax": [32.0],
+ "tmin": [24.0],
+ }
+ )
+
+ with self.assertRaisesRegex(ValueError, "humidity or dewpoint inputs"):
+ compute_daily_humidex(frame)
+
+ def test_summarize_humidex_period_reports_mean_and_max(self):
+ frame = pd.DataFrame(
+ {
+ "date": pd.to_datetime(["2020-01-01", "2020-01-02", "2020-01-03"]),
+ "mean_temperature": [30.0, 31.0, 29.0],
+ "humidity": [70.0, 75.0, 65.0],
+ }
+ )
+
+ result = summarize_humidex_period(frame)
+
+ self.assertIn("mean_humidex", result)
+ self.assertIn("max_humidex", result)
+ self.assertEqual("relative_humidity", result["method"])
+ self.assertEqual("continuous_metric_only", result["phase1_scope"])
+
+ def test_describe_human_heat_source_support_tracks_future_gap(self):
+ support = describe_human_heat_source_support()
+
+ self.assertIn("agera_5", support)
+ self.assertIn("nex_gddp", support)
+ self.assertIn("conditionally supported", support["nex_gddp"])
+ self.assertIn("not supported", support["chirps_v3_daily_rnl"])
+
+ def test_describe_human_heat_method_exposes_metric_choice(self):
+ method = describe_human_heat_method()
+
+ self.assertEqual("humidex", method["metric"])
+ self.assertEqual("xclim", method["backend"])
+ self.assertEqual("continuous_metric_only", method["phase1_scope"])
+ self.assertIn("heat_index", method["candidate_review"])
+ self.assertIn("utci", method["candidate_review"])
+ self.assertIn("wbgt", method["candidate_review"])
+ self.assertIn("nasa_power", method["source_support"])
+
+
+if __name__ == "__main__":
+ unittest.main()