from __future__ import annotations from dataclasses import dataclass from datetime import date, timedelta from typing import Any DEFAULT_GROWTH_PROFILE = { "base_temperature": 10.0, "required_gdd_for_maturity": 1200.0, "stage_thresholds": { "flowering": 500.0, "fruiting": 850.0, }, "current_cumulative_gdd": 0.0, } @dataclass class HarvestPrediction: current_cumulative_gdd: float required_gdd_for_maturity: float remaining_gdd: float estimated_days_to_harvest: int predicted_harvest_date: str predicted_harvest_window: dict[str, str] daily_gdd_forecast: list[dict[str, float | str]] active_stage: str | None def resolve_growth_profile(plant: Any | None) -> dict: profile = getattr(plant, "growth_profile", None) or {} stage_thresholds = { **DEFAULT_GROWTH_PROFILE["stage_thresholds"], **profile.get("stage_thresholds", {}), } return { **DEFAULT_GROWTH_PROFILE, **profile, "stage_thresholds": stage_thresholds, } def calculate_daily_gdd(tmax: float, tmin: float, tbase: float) -> float: mean_temp = (tmax + tmin) / 2.0 return round(max(mean_temp - tbase, 0.0), 3) def determine_active_stage(current_cumulative_gdd: float, stage_thresholds: dict[str, float]) -> str | None: active_stage = None for stage, threshold in sorted(stage_thresholds.items(), key=lambda item: item[1]): if current_cumulative_gdd >= float(threshold): active_stage = stage return active_stage def predict_harvest_from_forecasts( forecasts: list[Any], plant: Any | None, ) -> HarvestPrediction: profile = resolve_growth_profile(plant) base_temperature = float(profile.get("base_temperature", DEFAULT_GROWTH_PROFILE["base_temperature"])) required_gdd = float(profile.get("required_gdd_for_maturity", DEFAULT_GROWTH_PROFILE["required_gdd_for_maturity"])) current_cumulative_gdd = float(profile.get("current_cumulative_gdd", DEFAULT_GROWTH_PROFILE["current_cumulative_gdd"])) cumulative_gdd = current_cumulative_gdd daily_forecast: list[dict[str, float | str]] = [] estimated_date = forecasts[-1].forecast_date if forecasts else date.today() for forecast in forecasts: tmax = float(getattr(forecast, "temperature_max", None) or getattr(forecast, "temperature_mean", 0.0)) tmin = float(getattr(forecast, "temperature_min", None) or getattr(forecast, "temperature_mean", 0.0)) daily_gdd = calculate_daily_gdd(tmax=tmax, tmin=tmin, tbase=base_temperature) cumulative_gdd += daily_gdd daily_forecast.append( { "date": forecast.forecast_date.isoformat(), "gdd": daily_gdd, "cumulative_gdd": round(cumulative_gdd, 3), } ) if cumulative_gdd >= required_gdd: estimated_date = forecast.forecast_date break else: remaining_gdd_after_forecast = max(required_gdd - cumulative_gdd, 0.0) avg_gdd = sum(item["gdd"] for item in daily_forecast) / len(daily_forecast) if daily_forecast else 0.0 extra_days = int(remaining_gdd_after_forecast / avg_gdd) + (1 if avg_gdd > 0 and remaining_gdd_after_forecast > 0 else 0) estimated_date = estimated_date + timedelta(days=max(extra_days, 0)) remaining_gdd = max(required_gdd - current_cumulative_gdd, 0.0) estimated_days = max((estimated_date - date.today()).days, 0) active_stage = determine_active_stage(current_cumulative_gdd, profile.get("stage_thresholds", {})) return HarvestPrediction( current_cumulative_gdd=round(current_cumulative_gdd, 3), required_gdd_for_maturity=round(required_gdd, 3), remaining_gdd=round(remaining_gdd, 3), estimated_days_to_harvest=estimated_days, predicted_harvest_date=estimated_date.isoformat(), predicted_harvest_window={ "start": (estimated_date - timedelta(days=3)).isoformat(), "end": (estimated_date + timedelta(days=3)).isoformat(), }, daily_gdd_forecast=daily_forecast, active_stage=active_stage, )