Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion climate_tookit/calculate_hazards/hazards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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")

Expand Down
43 changes: 43 additions & 0 deletions climate_tookit/climate_statistics/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions climate_tookit/compare_periods/periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions tests/test_compare_periods_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions tests/test_hazard_thresholds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions tests/test_statistics_source_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
Loading