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()