first commit
This commit is contained in:
220
backend/app/services/energy.py
Normal file
220
backend/app/services/energy.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core_settings import AppSettings, get_settings
|
||||
from app.models.definitions import BucketPoint, DailyEnergyRecord, MetricDefinition, SeriesPoint
|
||||
from app.services.catalog import MetricCatalog, get_catalog
|
||||
from app.services.influx_http import InfluxHTTPService
|
||||
from app.services.metrics import to_float
|
||||
from app.storage import SQLiteEnergyRepository
|
||||
from app.utils.time import (
|
||||
choose_counter_interval,
|
||||
choose_power_interval,
|
||||
duration_to_seconds,
|
||||
now_local,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnergySample:
|
||||
timestamp: datetime
|
||||
delta_kwh: float
|
||||
|
||||
|
||||
class EnergyService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: AppSettings | None = None,
|
||||
catalog: MetricCatalog | None = None,
|
||||
influx: InfluxHTTPService | None = None,
|
||||
repository: SQLiteEnergyRepository | None = None,
|
||||
) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self.catalog = catalog or get_catalog()
|
||||
self.influx = influx or InfluxHTTPService(self.settings)
|
||||
self.repository = repository or SQLiteEnergyRepository(self.settings.storage["sqlite_path"])
|
||||
self.tz = ZoneInfo(self.settings.timezone)
|
||||
|
||||
def total_for_window(self, start: datetime, end: datetime) -> float:
|
||||
total, _, _ = self.window_total_with_meta(start, end)
|
||||
return total
|
||||
|
||||
def window_total_with_meta(self, start: datetime, end: datetime) -> tuple[float, str, int]:
|
||||
samples, source, observations_count = self._samples_for_window(start, end)
|
||||
return round(sum(sample.delta_kwh for sample in samples), 2), source, observations_count
|
||||
|
||||
def total_for_full_day(self, day: date) -> tuple[float, str, int]:
|
||||
start = datetime.combine(day, time.min, tzinfo=self.tz)
|
||||
end = start + timedelta(days=1)
|
||||
return self.window_total_with_meta(start, end)
|
||||
|
||||
def samples(self, start: datetime, end: datetime) -> list[EnergySample]:
|
||||
samples, _, _ = self._samples_for_window(start, end)
|
||||
return samples
|
||||
|
||||
def daily_records_for_window(
|
||||
self,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
*,
|
||||
persist_missing: bool = True,
|
||||
) -> list[DailyEnergyRecord]:
|
||||
start_local = start.astimezone(self.tz)
|
||||
end_local = end.astimezone(self.tz)
|
||||
if end_local <= start_local:
|
||||
return []
|
||||
|
||||
start_day = start_local.date()
|
||||
end_day = end_local.date()
|
||||
cached = self.repository.fetch_daily_energy(start_day, end_day)
|
||||
today_local = now_local().date()
|
||||
rows: list[DailyEnergyRecord] = []
|
||||
|
||||
current = start_day
|
||||
while current <= end_day:
|
||||
day_start = datetime.combine(current, time.min, tzinfo=self.tz)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
segment_start = max(start_local, day_start)
|
||||
segment_end = min(end_local, day_end)
|
||||
if segment_end <= segment_start:
|
||||
current = current + timedelta(days=1)
|
||||
continue
|
||||
|
||||
is_full_day = segment_start == day_start and segment_end == day_end
|
||||
cached_row = cached.get(current)
|
||||
if is_full_day and cached_row is not None:
|
||||
rows.append(cached_row)
|
||||
else:
|
||||
total, source, observations_count = self.window_total_with_meta(segment_start, segment_end)
|
||||
record = DailyEnergyRecord(
|
||||
day=current,
|
||||
energy_kwh=total,
|
||||
source=source,
|
||||
samples_count=observations_count,
|
||||
)
|
||||
rows.append(record)
|
||||
if is_full_day and persist_missing and current < today_local and observations_count > 0:
|
||||
self.repository.upsert_daily_energy(record)
|
||||
current = current + timedelta(days=1)
|
||||
|
||||
return rows
|
||||
|
||||
def bucketize_daily(self, records: list[DailyEnergyRecord], bucket: str) -> list[BucketPoint]:
|
||||
grouped: dict[str, dict] = defaultdict(lambda: {"value": 0.0, "start": None, "end": None, "label": ""})
|
||||
|
||||
for record in records:
|
||||
start = datetime.combine(record.day, time.min, tzinfo=self.tz)
|
||||
if bucket == "day":
|
||||
bucket_start = start
|
||||
bucket_end = bucket_start + timedelta(days=1)
|
||||
key = bucket_start.strftime("%Y-%m-%d")
|
||||
label = bucket_start.strftime("%d.%m")
|
||||
elif bucket == "week":
|
||||
bucket_start = start - timedelta(days=start.weekday())
|
||||
bucket_end = bucket_start + timedelta(days=7)
|
||||
iso = bucket_start.isocalendar()
|
||||
key = f"{iso.year}-W{iso.week:02d}"
|
||||
label = key
|
||||
elif bucket == "month":
|
||||
bucket_start = start.replace(day=1)
|
||||
if bucket_start.month == 12:
|
||||
bucket_end = bucket_start.replace(year=bucket_start.year + 1, month=1)
|
||||
else:
|
||||
bucket_end = bucket_start.replace(month=bucket_start.month + 1)
|
||||
key = bucket_start.strftime("%Y-%m")
|
||||
label = key
|
||||
elif bucket == "year":
|
||||
bucket_start = start.replace(month=1, day=1)
|
||||
bucket_end = bucket_start.replace(year=bucket_start.year + 1)
|
||||
key = bucket_start.strftime("%Y")
|
||||
label = key
|
||||
else:
|
||||
raise ValueError(f"Unsupported bucket: {bucket}")
|
||||
|
||||
current = grouped[key]
|
||||
current["label"] = label
|
||||
current["value"] += record.energy_kwh
|
||||
current["start"] = bucket_start if current["start"] is None else min(current["start"], bucket_start)
|
||||
current["end"] = bucket_end if current["end"] is None else max(current["end"], bucket_end)
|
||||
|
||||
rows = []
|
||||
for key in sorted(grouped.keys()):
|
||||
item = grouped[key]
|
||||
rows.append(
|
||||
BucketPoint(
|
||||
label=item["label"],
|
||||
start=item["start"],
|
||||
end=item["end"],
|
||||
value=round(item["value"], 2),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
def _samples_for_window(self, start: datetime, end: datetime) -> tuple[list[EnergySample], str, int]:
|
||||
counter_metric = self.catalog.safe_get(self.settings.analytics["production_metric_id"])
|
||||
if counter_metric is not None:
|
||||
samples, observations_count = self._samples_from_counter(counter_metric, start, end)
|
||||
return samples, "counter", observations_count
|
||||
|
||||
power_metric = self.catalog.safe_get(self.settings.analytics["fallback_power_metric_id"])
|
||||
if power_metric is not None:
|
||||
samples, observations_count = self._samples_from_power(power_metric, start, end)
|
||||
return samples, "power_estimated", observations_count
|
||||
|
||||
return [], "unavailable", 0
|
||||
|
||||
def _samples_from_counter(self, metric: MetricDefinition, start: datetime, end: datetime) -> tuple[list[EnergySample], int]:
|
||||
interval = choose_counter_interval(start, end)
|
||||
baseline = self.influx.last_before(metric, start)
|
||||
series = self.influx.grouped_last_series(metric, start, end, interval)
|
||||
|
||||
points: list[SeriesPoint] = []
|
||||
if baseline and baseline.value is not None:
|
||||
points.append(SeriesPoint(timestamp=start, value=baseline.value))
|
||||
else:
|
||||
first_value = next((point.value for point in series if point.value is not None), None)
|
||||
if first_value is not None:
|
||||
points.append(SeriesPoint(timestamp=start, value=first_value))
|
||||
points.extend(series)
|
||||
|
||||
samples: list[EnergySample] = []
|
||||
previous_value = None
|
||||
for point in points:
|
||||
current_value = to_float(point.value)
|
||||
if current_value is None:
|
||||
continue
|
||||
if previous_value is None:
|
||||
previous_value = current_value
|
||||
continue
|
||||
|
||||
delta = current_value - previous_value
|
||||
previous_value = current_value
|
||||
if delta <= 0:
|
||||
continue
|
||||
if point.timestamp < start or point.timestamp > end:
|
||||
continue
|
||||
samples.append(EnergySample(timestamp=point.timestamp, delta_kwh=round(delta, 6)))
|
||||
|
||||
observations_count = sum(1 for point in series if to_float(point.value) is not None)
|
||||
return samples, observations_count
|
||||
|
||||
def _samples_from_power(self, metric: MetricDefinition, start: datetime, end: datetime) -> tuple[list[EnergySample], int]:
|
||||
interval = choose_power_interval(start, end)
|
||||
interval_seconds = duration_to_seconds(interval)
|
||||
points = self.influx.gauge_history(metric, start, end, interval, aggregate="mean")
|
||||
samples: list[EnergySample] = []
|
||||
observations_count = 0
|
||||
for point in points:
|
||||
watts = to_float(point.value)
|
||||
if watts is None:
|
||||
continue
|
||||
observations_count += 1
|
||||
if watts <= 0:
|
||||
continue
|
||||
delta_kwh = watts * (interval_seconds / 3600.0) / 1000.0
|
||||
samples.append(EnergySample(timestamp=point.timestamp, delta_kwh=round(delta_kwh, 6)))
|
||||
return samples, observations_count
|
||||
Reference in New Issue
Block a user