""" ROIService: budget utilisation, intervention statistics, and LER computation. Provides the data layer for the ROI Dashboard tab. """ from __future__ import annotations import logging from dataclasses import dataclass, field from datetime import date, datetime, timedelta from typing import Dict, List, Optional from config.settings import ( MAX_ENERGY_REDUCTION_PCT, TARGET_LER, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Data containers # --------------------------------------------------------------------------- @dataclass class InterventionStats: """Statistics for a single period (day/week/month).""" period_label: str total_slots: int = 0 # number of 15-min slots intervention_slots: int = 0 # slots where offset > 0 avg_offset_deg: float = 0.0 # mean offset across intervention slots max_offset_deg: float = 0.0 energy_sacrificed_kwh: float = 0.0 budget_allocated_kwh: float = 0.0 budget_utilisation_pct: float = 0.0 # sacrificed / allocated * 100 @property def intervention_rate_pct(self) -> float: if self.total_slots == 0: return 0.0 return self.intervention_slots / self.total_slots * 100 @dataclass class LERResult: """Land Equivalent Ratio computation.""" energy_fraction: float # E_agri / E_mono (agrivoltaic / monoculture PV) crop_fraction: float # Y_agri / Y_mono (agrivoltaic / monoculture vineyard) ler: float # energy_fraction + crop_fraction meets_target: bool = False def summary(self) -> str: status = "MEETS" if self.meets_target else "BELOW" return ( f"LER = {self.ler:.2f} ({status} target {TARGET_LER:.1f}): " f"energy {self.energy_fraction:.2f} + crop {self.crop_fraction:.2f}" ) @dataclass class BudgetStatus: """Current budget utilisation snapshot.""" annual_budget_kwh: float annual_spent_kwh: float annual_remaining_kwh: float monthly_budget_kwh: float = 0.0 monthly_spent_kwh: float = 0.0 weekly_budget_kwh: float = 0.0 weekly_spent_kwh: float = 0.0 daily_budget_kwh: float = 0.0 daily_spent_kwh: float = 0.0 @property def annual_utilisation_pct(self) -> float: if self.annual_budget_kwh == 0: return 0.0 return self.annual_spent_kwh / self.annual_budget_kwh * 100 def is_over_budget(self) -> bool: return self.annual_spent_kwh > self.annual_budget_kwh # --------------------------------------------------------------------------- # ROI Service # --------------------------------------------------------------------------- class ROIService: """Compute budget utilisation, intervention stats, and LER from tick logs. Parameters ---------- annual_generation_kwh : float Expected total annual PV generation (kWh). Default: 48 kW × 1800 peak-sun-hours ≈ 86,400 kWh. max_reduction_pct : float Maximum energy sacrifice ceiling (%). """ def __init__( self, annual_generation_kwh: float = 86_400.0, max_reduction_pct: float = MAX_ENERGY_REDUCTION_PCT, ): self.annual_gen = annual_generation_kwh self.annual_budget = annual_generation_kwh * max_reduction_pct / 100.0 self._tick_log: List[dict] = [] def load_tick_log(self, tick_log: List[dict]) -> None: """Load a tick log (list of TickResult.to_dict() entries).""" self._tick_log = list(tick_log) logger.info("Loaded %d tick entries", len(self._tick_log)) # ------------------------------------------------------------------ # Budget status # ------------------------------------------------------------------ def get_budget_status( self, target_date: Optional[date] = None, ) -> BudgetStatus: """Compute current budget utilisation from the tick log.""" today = target_date or date.today() year = today.year annual_spent = 0.0 monthly_spent = 0.0 weekly_spent = 0.0 daily_spent = 0.0 week_start = today - timedelta(days=today.weekday()) for tick in self._tick_log: cost = tick.get("energy_cost_kwh", 0.0) or 0.0 ts = tick.get("timestamp", "") try: if isinstance(ts, str): tick_date = datetime.fromisoformat(ts).date() elif isinstance(ts, datetime): tick_date = ts.date() else: continue except (ValueError, AttributeError): continue if tick_date.year == year: annual_spent += cost if tick_date.year == year and tick_date.month == today.month: monthly_spent += cost if tick_date >= week_start: weekly_spent += cost if tick_date == today: daily_spent += cost return BudgetStatus( annual_budget_kwh=self.annual_budget, annual_spent_kwh=annual_spent, annual_remaining_kwh=max(0, self.annual_budget - annual_spent), monthly_spent_kwh=monthly_spent, weekly_spent_kwh=weekly_spent, daily_spent_kwh=daily_spent, ) # ------------------------------------------------------------------ # Intervention statistics # ------------------------------------------------------------------ def compute_intervention_stats( self, start_date: Optional[date] = None, end_date: Optional[date] = None, label: str = "period", ) -> InterventionStats: """Compute intervention statistics for a date range.""" stats = InterventionStats(period_label=label) for tick in self._tick_log: ts = tick.get("timestamp", "") try: if isinstance(ts, str): tick_date = datetime.fromisoformat(ts).date() elif isinstance(ts, datetime): tick_date = ts.date() else: continue except (ValueError, AttributeError): continue if start_date and tick_date < start_date: continue if end_date and tick_date > end_date: continue stats.total_slots += 1 offset = tick.get("plan_offset_deg", 0.0) or 0.0 if offset > 0: stats.intervention_slots += 1 stats.max_offset_deg = max(stats.max_offset_deg, offset) stats.energy_sacrificed_kwh += tick.get("energy_cost_kwh", 0.0) or 0.0 if stats.intervention_slots > 0: # Recompute average from individual entries total_offset = sum( (t.get("plan_offset_deg", 0.0) or 0.0) for t in self._tick_log if (t.get("plan_offset_deg", 0.0) or 0.0) > 0 ) stats.avg_offset_deg = total_offset / stats.intervention_slots return stats # ------------------------------------------------------------------ # LER computation # ------------------------------------------------------------------ def compute_ler( self, actual_energy_kwh: float, mono_energy_kwh: Optional[float] = None, actual_crop_yield: float = 1.0, mono_crop_yield: float = 1.0, ) -> LERResult: """Compute Land Equivalent Ratio. LER = (E_agri / E_mono) + (Y_agri / Y_mono) LER > 1.0 means the combined system is more productive than growing either crop alone. Parameters ---------- actual_energy_kwh : float Actual PV generation under agrivoltaic operation. mono_energy_kwh : float, optional Theoretical generation without any shading interventions. Defaults to annual_generation_kwh. actual_crop_yield : float Agrivoltaic crop yield (any unit, must match mono). mono_crop_yield : float Monoculture crop yield (same unit). """ e_mono = mono_energy_kwh or self.annual_gen e_frac = actual_energy_kwh / e_mono if e_mono > 0 else 0.0 c_frac = actual_crop_yield / mono_crop_yield if mono_crop_yield > 0 else 0.0 ler = e_frac + c_frac return LERResult( energy_fraction=e_frac, crop_fraction=c_frac, ler=ler, meets_target=ler >= TARGET_LER, ) # ------------------------------------------------------------------ # Summary # ------------------------------------------------------------------ def summary(self, target_date: Optional[date] = None) -> dict: """Return a combined summary dict for the dashboard.""" budget = self.get_budget_status(target_date) stats = self.compute_intervention_stats(label="all_time") return { "budget": { "annual_budget_kwh": budget.annual_budget_kwh, "annual_spent_kwh": budget.annual_spent_kwh, "annual_remaining_kwh": budget.annual_remaining_kwh, "utilisation_pct": budget.annual_utilisation_pct, "over_budget": budget.is_over_budget(), }, "interventions": { "total_slots": stats.total_slots, "intervention_slots": stats.intervention_slots, "intervention_rate_pct": stats.intervention_rate_pct, "avg_offset_deg": stats.avg_offset_deg, "max_offset_deg": stats.max_offset_deg, "energy_sacrificed_kwh": stats.energy_sacrificed_kwh, }, }