Download src/roi_service.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 9.85 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/src/roi_service.py
- Command line
-
hf download hf://spaces/SolarWine/api/src/roi_service.py
-
curl -L -o roi_service.py https://huggingface.co/spaces/SolarWine/api/resolve/main/src/roi_service.py
9.85 kB
| """ | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| def intervention_rate_pct(self) -> float: | |
| if self.total_slots == 0: | |
| return 0.0 | |
| return self.intervention_slots / self.total_slots * 100 | |
| 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}" | |
| ) | |
| 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 | |
| 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, | |
| }, | |
| } | |