From c79699e97bfbb6984cb24658ade440d6091f46c8 Mon Sep 17 00:00:00 2001
From: Pete Steward
Date: Wed, 24 Jun 2026 21:17:55 +0300
Subject: [PATCH 1/2] feat: clarify NDWS methodology notes
---
.../climate_statistics/statistics.py | 43 +++++++++++++
climate_tookit/compare_periods/periods.py | 14 +++++
tests/test_compare_periods_baseline.py | 61 +++++++++++++++++++
tests/test_statistics_source_policy.py | 45 ++++++++++++++
4 files changed, 163 insertions(+)
diff --git a/climate_tookit/climate_statistics/statistics.py b/climate_tookit/climate_statistics/statistics.py
index 5ae9fc5..3eb5325 100644
--- a/climate_tookit/climate_statistics/statistics.py
+++ b/climate_tookit/climate_statistics/statistics.py
@@ -2401,6 +2401,31 @@ def print_overall_by_season(seasons: List[Dict]) -> None:
})
_print_indented_table(pd.DataFrame(rows))
+
+def _format_water_balance_methodology_summary(methodology: Optional[Dict[str, Any]]) -> Optional[str]:
+ if not methodology:
+ return None
+ count_window = methodology.get("count_window") or {}
+ mode = count_window.get("applied_mode") or count_window.get("requested_mode") or "n/a"
+ days = count_window.get("counted_days")
+ subseasons = count_window.get("counted_subseasons")
+ parts = [f"mode={mode}"]
+ if _is_num(days):
+ parts.append(f"days={int(days)}")
+ if _is_num(subseasons):
+ parts.append(f"eto_subseasons={int(subseasons)}")
+ return " | ".join(parts)
+
+
+def _water_balance_methodology_warnings(methodology: Optional[Dict[str, Any]]) -> List[str]:
+ if not methodology:
+ return []
+ warnings: List[str] = []
+ for warning in methodology.get("warnings") or []:
+ if warning and warning not in warnings:
+ warnings.append(str(warning))
+ return warnings
+
def print_seasons(seasons: List[Dict]) -> None:
print("\n" + "=" * 70)
print("SEASON STATISTICS")
@@ -2467,6 +2492,15 @@ def print_seasons(seasons: List[Dict]) -> None:
if 'actual_crop_et_mm' in w:
extras.append(f"AET={w['actual_crop_et_mm']} mm")
print(f" crop_model: {' | '.join(extras)}")
+ methodology_line = _format_water_balance_methodology_summary(
+ s.get('water_balance_methodology')
+ )
+ if methodology_line:
+ print(f" method: {methodology_line}")
+ for warning in _water_balance_methodology_warnings(
+ s.get('water_balance_methodology')
+ ):
+ print(f" note: {warning}")
subs = s.get('eto_sub_seasons')
if subs is not None:
@@ -2560,6 +2594,15 @@ def print_ltm_by_season(ltm: Dict[str, Any],
if wb.get('actual_crop_et_mm') is not None:
extras.append(f"AET={wb.get('actual_crop_et_mm')} mm")
print(f" crop_model: {' | '.join(extras)}")
+ methodology_line = _format_water_balance_methodology_summary(
+ w.get('water_balance_methodology')
+ )
+ if methodology_line:
+ print(f" method: {methodology_line}")
+ for warning in _water_balance_methodology_warnings(
+ w.get('water_balance_methodology')
+ ):
+ print(f" note: {warning}")
def print_annual(annual: Dict[str, Dict]) -> None:
print("\n" + "=" * 70)
diff --git a/climate_tookit/compare_periods/periods.py b/climate_tookit/compare_periods/periods.py
index 3109e52..c9cf852 100644
--- a/climate_tookit/compare_periods/periods.py
+++ b/climate_tookit/compare_periods/periods.py
@@ -284,6 +284,18 @@ def _format_methodology_side(label: str, summary: Optional[Dict[str, Any]]) -> O
return f"{label}: mode={mode}" + (f" | {' | '.join(day_bits)}" if day_bits else "")
+def _methodology_warning_lines(methodology: Optional[Dict[str, Any]]) -> List[str]:
+ if not methodology:
+ return []
+ warnings: List[str] = []
+ for label in ("focal", "baseline_avg", "future_avg", "baseline_ltm", "future_ltm"):
+ summary = methodology.get(label) or {}
+ for warning in summary.get("warnings") or []:
+ if warning and warning not in warnings:
+ warnings.append(str(warning))
+ return warnings
+
+
def _print_methodology_summary(methodology: Optional[Dict[str, Any]]) -> None:
if not methodology:
return
@@ -294,6 +306,8 @@ def _print_methodology_summary(methodology: Optional[Dict[str, Any]]) -> None:
parts.append(formatted)
if parts:
print(f" NDWS/WRSI method: {' ; '.join(parts)}")
+ for warning in _methodology_warning_lines(methodology):
+ _print_wrapped("NDWS/WRSI note: ", warning, indent=" ")
def _has_custom_water_balance_metrics(payload: Dict[str, Any]) -> bool:
diff --git a/tests/test_compare_periods_baseline.py b/tests/test_compare_periods_baseline.py
index c0350db..95a9c34 100644
--- a/tests/test_compare_periods_baseline.py
+++ b/tests/test_compare_periods_baseline.py
@@ -2602,6 +2602,67 @@ def test_print_report_renders_water_balance_methodology_summary(self):
self.assertIn("NDWS/WRSI method:", rendered)
self.assertIn("mode=full_window", rendered)
+ def test_print_report_renders_water_balance_methodology_warning(self):
+ payload = {
+ "focal_year": 2019,
+ "baseline_period": "2018-2018",
+ "source": "auto",
+ "fixed_season": "03-01:05-31",
+ "temperature_excluded": False,
+ "raw_climate_summary": {},
+ "overall_statistics": {},
+ "season_statistics": {
+ "windows": [
+ {
+ "window": "03-01:05-31",
+ "season_number": 1,
+ "n_baseline": 1,
+ "n_focal": 1,
+ "water_balance_methodology": {
+ "focal": {
+ "applied_modes": ["full_window"],
+ "counted_days": {"mean": 92.0, "min": 92, "max": 92},
+ "warnings": [
+ "Requested crop-active NDWS window, but perhumid location. Falling back to full fixed window."
+ ],
+ },
+ "baseline_avg": {
+ "applied_modes": ["full_window"],
+ "counted_days": {"mean": 92.0, "min": 92, "max": 92},
+ "warnings": [
+ "Requested crop-active NDWS window, but perhumid location. Falling back to full fixed window."
+ ],
+ },
+ },
+ "diff": {
+ "water_balance": {
+ "NDWS": {
+ "focal": 8.0,
+ "baseline_avg": 10.0,
+ "diff": -2.0,
+ "pct": -20.0,
+ }
+ }
+ },
+ }
+ ]
+ },
+ "annual_summary": {},
+ }
+
+ stdout = StringIO()
+ orig_stdout = sys.stdout
+ try:
+ sys.stdout = stdout
+ cp.print_report(payload)
+ finally:
+ sys.stdout = orig_stdout
+
+ rendered = stdout.getvalue()
+ self.assertIn("NDWS/WRSI note:", rendered)
+ self.assertIn("Requested crop-active NDWS window, but perhumid location.", rendered)
+ self.assertIn("fixed window.", rendered)
+
def test_print_report_renders_calendar_fallback_summary(self):
payload = {
"focal_year": 2020,
diff --git a/tests/test_statistics_source_policy.py b/tests/test_statistics_source_policy.py
index d130cdc..d7020da 100644
--- a/tests/test_statistics_source_policy.py
+++ b/tests/test_statistics_source_policy.py
@@ -1752,6 +1752,51 @@ def test_print_seasons_includes_livestock_thi_block(self):
self.assertIn("mean_thi=75.2", rendered)
self.assertIn("operational defaults | mean-temp+RH THI", rendered)
+ def test_print_seasons_includes_water_balance_methodology_note(self):
+ seasons = [
+ {
+ "year": 2020,
+ "season_number": 1,
+ "onset": "2020-03-01",
+ "cessation": "2020-05-31",
+ "length_days": 92,
+ "precipitation": {"total_mm": 100.0, "max_daily": 20.0, "rainy_days": 10, "intensity": 10.0},
+ "temperature": {
+ "mean_tmax": 28.0,
+ "mean_tmin": 17.0,
+ "mean_tavg": 22.5,
+ "max_tmax": 32.0,
+ "min_tmin": 14.0,
+ },
+ "water_balance": {
+ "total_balance": -10.0,
+ "deficit_days": 30,
+ "surplus_days": 20,
+ "stress_ratio": 0.33,
+ "WRSI": 61.0,
+ "NDWS": 24,
+ },
+ "water_balance_methodology": {
+ "count_window": {
+ "applied_mode": "full_window",
+ "counted_days": 92,
+ "counted_subseasons": 0,
+ },
+ "warnings": [
+ "Requested crop-active NDWS window, but perhumid location. Falling back to full fixed window."
+ ],
+ },
+ }
+ ]
+
+ stdout = StringIO()
+ with mock.patch("sys.stdout", stdout):
+ stats.print_seasons(seasons)
+
+ rendered = stdout.getvalue()
+ self.assertIn("method: mode=full_window | days=92 | eto_subseasons=0", rendered)
+ self.assertIn("note: Requested crop-active NDWS window, but perhumid location.", rendered)
+
def test_ltm_season_summary_preserves_livestock_profile_metadata(self):
season_results = [
{
From 890f85782a2cf2760ff2566680378250f05aea52 Mon Sep 17 00:00:00 2001
From: Pete Steward
Date: Wed, 24 Jun 2026 21:48:06 +0300
Subject: [PATCH 2/2] feat: annotate NDWS threshold caveats
---
climate_tookit/calculate_hazards/hazards.py | 52 +++++++++++++++++-
tests/test_hazard_thresholds.py | 59 +++++++++++++++++++++
2 files changed, 110 insertions(+), 1 deletion(-)
diff --git a/climate_tookit/calculate_hazards/hazards.py b/climate_tookit/calculate_hazards/hazards.py
index cb62d83..2a67a85 100644
--- a/climate_tookit/calculate_hazards/hazards.py
+++ b/climate_tookit/calculate_hazards/hazards.py
@@ -1765,6 +1765,46 @@ def evaluate_hazard_metrics(
}
return hazard_eval
+
+def _annotate_water_balance_hazard_evaluation(
+ hazard_eval: Dict[str, Any],
+ methodology: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+ if not hazard_eval or not methodology:
+ return hazard_eval
+
+ count_window = methodology.get("count_window") or {}
+ analysis_method = methodology.get("analysis_method")
+ applied_mode = count_window.get("applied_mode")
+ warnings = [str(w) for w in (methodology.get("warnings") or []) if w]
+
+ note_parts: List[str] = []
+ confidence = "standard"
+ if analysis_method == "fixed_season" and applied_mode == FULL_WINDOW_WATER_BALANCE:
+ confidence = "caution"
+ note_parts.append(
+ "Fixed-season NDWS/NDWL0 thresholds are being interpreted on full-window counts; shoulder months can inflate stress days."
+ )
+ if warnings:
+ confidence = "caution"
+ note_parts.extend(warnings)
+
+ if not note_parts:
+ return hazard_eval
+
+ note = " ".join(dict.fromkeys(note_parts))
+ annotated = dict(hazard_eval)
+ for hazard_key in ("NDWS", "NDWL0"):
+ item = annotated.get(hazard_key)
+ if not isinstance(item, dict):
+ continue
+ updated = dict(item)
+ updated["interpretation_confidence"] = confidence
+ updated["interpretation_note"] = note
+ updated["count_window_mode"] = applied_mode
+ annotated[hazard_key] = updated
+ return annotated
+
# Long-Term Mean (Baseline) aggregation
_LTM_SCALAR_KEYS = (
'total_precipitation_mm', 'mean_daily_precipitation_mm', 'max_daily_precipitation_mm',
@@ -1839,6 +1879,10 @@ def compute_ltm_baseline(
total = max((a['season_info'].get('total_seasons_per_year', 1) for a in bucket), default=1)
hazard_eval = evaluate_hazard_metrics(agg, thresholds)
+ hazard_eval = _annotate_water_balance_hazard_evaluation(
+ hazard_eval,
+ bucket[0].get('water_balance_methodology'),
+ )
ltm_blocks.append({
'season_number': sn,
@@ -2362,13 +2406,17 @@ def calculate_hazards(
eto_seasons=entry.get("eto_seasons"),
eto_detection_note=entry.get("eto_detection_note"),
)
- hazard_eval = evaluate_hazard_metrics(stats, thresholds)
methodology = build_water_balance_methodology(
entry['season_info'],
soil_parameters,
crop_water_balance,
count_window=count_window,
)
+ hazard_eval = evaluate_hazard_metrics(stats, thresholds)
+ hazard_eval = _annotate_water_balance_hazard_evaluation(
+ hazard_eval,
+ methodology,
+ )
assessments.append({
'crop': crop_name,
'location': {'latitude': lat, 'longitude': lon},
@@ -2747,6 +2795,8 @@ def print_hazard_results(result: Dict[str, Any]) -> None:
f" {spec['label']:<25} {item[spec['value_key']]:>16.2f} {spec['unit']:<6} "
f"[{sym}] {item['status'].replace('_', ' ').upper()}"
)
+ if hazard_key in ('NDWS', 'NDWL0') and item.get("interpretation_note"):
+ print(f" {'':<25} {'':>16} note: {item['interpretation_note']}")
print(f"\n{'='*70}\n")
diff --git a/tests/test_hazard_thresholds.py b/tests/test_hazard_thresholds.py
index 35a2218..3441393 100644
--- a/tests/test_hazard_thresholds.py
+++ b/tests/test_hazard_thresholds.py
@@ -904,6 +904,65 @@ def test_calculate_hazards_fixed_season_reports_perhumid_crop_active_fallback(se
"Perhumid location",
" ".join(methodology["warnings"]),
)
+ ndws_hazard = result["hazard_evaluation"]["NDWS"]
+ self.assertEqual("caution", ndws_hazard["interpretation_confidence"])
+ self.assertIn("Perhumid location", ndws_hazard["interpretation_note"])
+ self.assertEqual("full_window", ndws_hazard["count_window_mode"])
+
+ def test_print_result_renders_ndws_interpretation_note(self):
+ import climate_tookit.calculate_hazards.hazards as hazards
+
+ payload = {
+ "crop": "maize",
+ "location": {"latitude": -1.286, "longitude": 36.817},
+ "season_info": {
+ "year": 2020,
+ "onset_date": "2020-03-01",
+ "cessation_date": "2020-05-31",
+ "length_days": 92,
+ "method": "fixed_season",
+ },
+ "season_statistics": {
+ "total_precipitation_mm": 400.0,
+ "mean_daily_precipitation_mm": 4.35,
+ "max_daily_precipitation_mm": 22.0,
+ "rainy_days": 30,
+ "dry_days": 62,
+ "mean_temperature_c": 24.0,
+ "mean_tmax_c": 29.0,
+ "mean_tmin_c": 19.0,
+ "max_temperature_c": 33.0,
+ "min_temperature_c": 16.0,
+ "NDWS": 22,
+ "NDWL0": 0,
+ },
+ "water_balance_methodology": {
+ "count_window": {
+ "applied_mode": "full_window",
+ },
+ },
+ "hazard_evaluation": {
+ "NDWS": {
+ "status": "severe_stress",
+ "value_days": 22,
+ "interpretation_note": (
+ "Fixed-season NDWS/NDWL0 thresholds are being interpreted on full-window counts; "
+ "shoulder months can inflate stress days."
+ ),
+ }
+ },
+ }
+
+ buf = io.StringIO()
+ orig_stdout = sys.stdout
+ try:
+ sys.stdout = buf
+ hazards.print_hazard_results(payload)
+ finally:
+ sys.stdout = orig_stdout
+
+ rendered = buf.getvalue()
+ self.assertIn("note: Fixed-season NDWS/NDWL0 thresholds are being interpreted", rendered)
def test_calculate_hazards_auto_detect_honors_requested_source(self):
import climate_tookit.calculate_hazards.hazards as hazards