Files
solar-pv-dashboard/backend/app/services/analytics.py
Mateusz Gruszczyński c5cc2efbac first commit
2026-03-23 15:56:18 +01:00

141 lines
5.9 KiB
Python

from __future__ import annotations
from app.core_settings import AppSettings, get_settings
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
from app.utils.time import resolve_window, shift_window
class AnalyticsService:
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 production(
self,
range_key: str | None = None,
bucket: str | None = None,
compare_mode: str = "none",
start: str | None = None,
end: str | None = None,
compare_ranges: list[dict] | None = None,
) -> dict:
bucket = bucket or self.settings.analytics["default_bucket"]
if bucket not in self.settings.analytics["bucket_labels"]:
raise ValueError(f"Unsupported bucket: {bucket}")
window = resolve_window(range_key=range_key, start=start, end=end)
current_days = self.energy.daily_records_for_window(window.start, window.end, persist_missing=True)
current = self.energy.bucketize_daily(current_days, bucket)
total = round(sum(item.value for item in current), 2)
comparison = []
comparison_total = None
comparison_delta_pct = None
comparisons = []
if compare_mode == "custom_multi":
for index, item in enumerate(compare_ranges or []):
compare_start = item.get("start")
compare_end = item.get("end")
if not compare_start or not compare_end:
continue
compare_window = resolve_window(start=compare_start, end=compare_end)
comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True)
comparison_series = self.energy.bucketize_daily(comparison_days, bucket)
comparison_total_value = round(sum(point.value for point in comparison_series), 2)
comparisons.append({
"key": item.get("key") or f"custom_{index + 1}",
"label": item.get("label") or f"Custom {index + 1}",
"start": compare_window.start,
"end": compare_window.end,
"total": comparison_total_value,
"delta_pct": compare_delta_pct(total, comparison_total_value),
"points": comparison_series,
})
if comparisons:
comparison = comparisons[0]["points"]
comparison_total = comparisons[0]["total"]
comparison_delta_pct = comparisons[0]["delta_pct"]
elif compare_mode != "none":
compare_window = shift_window(window, compare_mode)
comparison_days = self.energy.daily_records_for_window(compare_window.start, compare_window.end, persist_missing=True)
comparison = self.energy.bucketize_daily(comparison_days, bucket)
comparison_total = round(sum(item.value for item in comparison), 2)
comparison_delta_pct = compare_delta_pct(total, comparison_total)
comparisons.append({
"key": compare_mode,
"label": compare_mode,
"start": compare_window.start,
"end": compare_window.end,
"total": comparison_total,
"delta_pct": comparison_delta_pct,
"points": comparison,
})
average_bucket = round(total / len(current), 2) if current else 0.0
best_bucket = max(current, key=lambda item: item.value, default=None)
return {
"unit": "kWh",
"bucket": bucket,
"compare_mode": compare_mode,
"current": current,
"comparison": comparison,
"comparisons": comparisons,
"summary": {
"total": total,
"unit": "kWh",
"average_bucket": average_bucket,
"best_bucket_label": best_bucket.label if best_bucket else "",
"best_bucket_value": best_bucket.value if best_bucket else 0.0,
"co2_saved_kg": round(total * self.settings.co2_factor, 2),
"comparison_total": comparison_total,
"comparison_delta_pct": comparison_delta_pct,
},
"meta": {
"window": {
"start": window.start,
"end": window.end,
"range_key": window.key,
},
"source": "sqlite_cache_plus_live_influx",
},
}
def distribution(
self,
range_key: str | None = None,
bucket: str | None = None,
start: str | None = None,
end: str | None = None,
) -> dict:
payload = self.production(range_key=range_key, bucket=bucket, compare_mode="none", start=start, end=end)
current = payload["current"]
total = round(sum(item.value for item in current), 2)
denominator = total or 1.0
return {
"unit": payload["unit"],
"bucket": payload["bucket"],
"total": total,
"slices": [
{
"label": item.label,
"value": item.value,
"share": round((item.value / denominator) * 100.0, 2),
}
for item in current
if item.value > 0
],
"meta": payload["meta"],
}