api / src /roi_service.py
Eli Safra
Deploy SolarWine API (FastAPI + Docker, port 7860)
938949f
"""
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,
},
}