141 lines
5.9 KiB
Python
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"],
|
|
}
|