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