first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-23 15:56:18 +01:00
commit c5cc2efbac
106 changed files with 10254 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
from datetime import datetime, timedelta
from app.core_settings import AppSettings, get_settings
from app.models.definitions import HeroCard, SnapshotGroupRow, SnapshotPayload
from app.services.catalog import MetricCatalog, get_catalog
from app.services.energy import EnergyService
from app.services.influx_http import InfluxHTTPService
from app.services.metrics import compare_delta_pct, custom_metric_value, metric_value, to_float
from app.utils.time import choose_power_interval, now_local, resolve_window, start_of_local_day
class RealtimeService:
def __init__(
self,
settings: AppSettings | None = None,
catalog: MetricCatalog | None = None,
influx: InfluxHTTPService | None = None,
energy: EnergyService | None = None,
) -> None:
self.settings = settings or get_settings()
self.catalog = catalog or get_catalog()
self.influx = influx or InfluxHTTPService(self.settings)
self.energy = energy or EnergyService(self.settings, self.catalog, self.influx)
def snapshot(self) -> SnapshotPayload:
now = now_local()
today_start = start_of_local_day(now)
yesterday_start = today_start - timedelta(days=1)
metric_ids = {"ac_power", "energy_total", "inverter_temp"}
for group in self.settings.strings:
metric_ids.update(group.get("metrics", {}).values())
metrics = [self.catalog.get(metric_id) for metric_id in metric_ids if self.catalog.safe_get(metric_id)]
latest = self.influx.latest_values(metrics)
ac_power = to_float(_value(latest, "ac_power"))
total_dc_power = round(
sum(
to_float(_value(latest, group.get("metrics", {}).get("power", ""))) or 0.0
for group in self.settings.strings
),
0,
)
energy_today = self.energy.total_for_window(today_start, now)
energy_yesterday = self.energy.total_for_window(yesterday_start, today_start)
total_energy = to_float(_value(latest, "energy_total"))
inverter_temp = to_float(_value(latest, "inverter_temp"))
hero_cards = [
self._hero_card("ac_power", ac_power, subtitle="Aktualna moc AC"),
self._hero_card("dc_power_total", total_dc_power, label="Moc DC laczna", unit="W", subtitle="Suma stringow DC"),
self._hero_card("energy_today", energy_today, label="Energia dzis", unit="kWh", subtitle="Liczona z danych Influx"),
self._hero_card("energy_total", total_energy, label="Energia laczna", unit="kWh", subtitle="Licznik calkowity"),
]
if inverter_temp is not None:
hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Temp. falownika", unit="°C", subtitle="Sensor opcjonalny"))
kpis = {
"energy_today": custom_metric_value("energy_today", "Energia dzis", energy_today, unit="kWh", precision=2, status="ok"),
"energy_yesterday": custom_metric_value("energy_yesterday", "Energia wczoraj", energy_yesterday, unit="kWh", precision=2, status="ok"),
"energy_total": custom_metric_value(
"energy_total",
"Energia laczna",
total_energy,
unit="kWh",
precision=2,
timestamp=_timestamp(latest, "energy_total"),
status="ok",
),
"dc_power_total": custom_metric_value("dc_power_total", "Moc DC laczna", total_dc_power, unit="W", precision=0, status="ok"),
}
comparison = compare_delta_pct(energy_today, energy_yesterday)
if comparison is not None:
kpis["today_vs_yesterday"] = custom_metric_value(
"today_vs_yesterday",
"Dzis vs wczoraj",
comparison,
unit="%",
precision=2,
status="ok" if comparison >= 0 else "warn",
)
strings = self._build_string_rows(latest)
status = []
if self.catalog.safe_get("inverter_temp"):
status.append(
metric_value(
self.catalog.get("inverter_temp"),
inverter_temp,
timestamp=_timestamp(latest, "inverter_temp"),
)
)
status.append(
custom_metric_value(
"data_refresh",
"Ostatni odczyt energii",
_timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None,
status="ok" if _timestamp(latest, "energy_total") else "neutral",
kind="text",
)
)
updated_at = _max_timestamp(latest.values())
return SnapshotPayload(
updated_at=updated_at,
hero_cards=hero_cards,
kpis=kpis,
strings=strings,
phases=[],
status=status,
faults=[],
)
def history(self, range_key: str | None = None, start: str | None = None, end: str | None = None, metric_ids: list[str] | None = None) -> dict:
window = resolve_window(range_key=range_key or self.settings.realtime["history_default_range"], start=start, end=end)
interval = choose_power_interval(window.start, window.end)
series = []
selected = set(metric_ids or [])
def include(metric_id: str) -> bool:
return not selected or metric_id in selected
ac_metric = self.catalog.safe_get("ac_power")
if ac_metric is not None and include("ac_power"):
series.append(
{
"metric_id": ac_metric.id,
"label": ac_metric.label,
"unit": ac_metric.unit,
"points": self.influx.gauge_history(ac_metric, window.start, window.end, interval=interval, aggregate="mean"),
}
)
for group in self.settings.strings:
for slot, metric_id in group.get("metrics", {}).items():
if not metric_id or not self.catalog.safe_get(metric_id) or not include(metric_id):
continue
metric = self.catalog.get(metric_id)
series.append(
{
"metric_id": metric.id,
"label": metric.label if slot != "power" else group["label"],
"unit": metric.unit,
"points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"),
}
)
temp_metric = self.catalog.safe_get("inverter_temp")
if temp_metric is not None and include("inverter_temp"):
temp_points = self.influx.gauge_history(temp_metric, window.start, window.end, interval=interval, aggregate="mean")
last_value = None
filled = []
for point in temp_points:
value = point.value if point.value is not None else last_value
if point.value is not None:
last_value = point.value
filled.append({"timestamp": point.timestamp, "value": value})
series.append(
{
"metric_id": temp_metric.id,
"label": temp_metric.label,
"unit": temp_metric.unit,
"points": filled,
}
)
return {
"range_key": window.key,
"start": window.start,
"end": window.end,
"series": series,
}
def _hero_card(self, metric_id: str, value, *, label: str | None = None, unit: str | None = None, subtitle: str = "") -> HeroCard:
accent = "slate"
numeric = to_float(value)
if metric_id == "inverter_temp":
if numeric is not None and numeric < 55:
accent = "emerald"
elif numeric is not None and numeric < 70:
accent = "amber"
elif numeric is not None:
accent = "rose"
else:
accent = "emerald" if numeric not in (None, 0) else "slate"
resolved_label = label or (self.catalog.get(metric_id).label if self.catalog.safe_get(metric_id) else metric_id)
resolved_unit = unit or (self.catalog.get(metric_id).unit if self.catalog.safe_get(metric_id) else "")
return HeroCard(
metric_id=metric_id,
label=resolved_label,
value=value,
unit=resolved_unit,
accent=accent,
subtitle=subtitle,
)
def _build_string_rows(self, latest: dict) -> list[SnapshotGroupRow]:
rows = []
for group in self.settings.strings:
values = {}
for slot, metric_id in group.get("metrics", {}).items():
metric = self.catalog.safe_get(metric_id)
if metric is None:
continue
values[slot] = metric_value(metric, _value(latest, metric_id), timestamp=_timestamp(latest, metric_id))
rows.append(SnapshotGroupRow(id=group["id"], label=group["label"], values=values, meta={}))
return rows
def _value(latest: dict, metric_id: str):
payload = latest.get(metric_id) or {}
return payload.get("value")
def _timestamp(latest: dict, metric_id: str):
payload = latest.get(metric_id) or {}
return payload.get("timestamp")
def _max_timestamp(items) -> datetime | None:
timestamps = [item.get("timestamp") for item in items if item.get("timestamp") is not None]
return max(timestamps) if timestamps else None