Files
ems/backend/app/notifications_logic.py
Dusan Vojacek 9f4126946d second version
2026-04-03 14:23:16 +02:00

250 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pravidla pro GET /sites/{id}/notifications (ceny, EV, predikce záporných cen)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from typing import Any, Literal
from zoneinfo import ZoneInfo
PRAGUE = ZoneInfo("Europe/Prague")
NotificationLevel = Literal["success", "info", "warning", "error"]
NotificationAction = Literal["connect_ev", "replan", "import_prices", "switch_auto"]
@dataclass(frozen=True)
class PriceSlot:
interval_start: datetime
buy: float
sell: float
@dataclass(frozen=True)
class EvSessionRow:
id: int
charger_id: int
energy_delivered_wh: float
target_soc_pct: float | None
session_start: datetime
battery_capacity_kwh: float | None
make: str | None
model: str | None
default_target_soc_pct: float | None
charger_code: str
soc_at_connect_pct: float | None
@dataclass(frozen=True)
class NegWindowRow:
predicted_date: date
window_start_hour: int
window_end_hour: int
probability_pct: int
def _num(v: Any) -> float | None:
if v is None:
return None
if isinstance(v, Decimal):
return float(v)
return float(v)
def _ev_connect_hint(ev_sessions: list[EvSessionRow], current_price: float, avg_buy: float | None) -> str:
if ev_sessions:
return ""
kwh = 30.0
if current_price < 0:
return f"Připojíš-li Teslu, dostaneš zaplaceno za ~{kwh * abs(current_price):.0f} Kč za 30 kWh."
if avg_buy is None:
return ""
savings = avg_buy - current_price
return f"Připojíš-li Teslu, ušetříš ~{kwh * max(0, savings):.0f} Kč."
def _estimate_ev_soc(s: EvSessionRow) -> float:
cap = s.battery_capacity_kwh
delivered_kwh = (s.energy_delivered_wh or 0) / 1000.0
if s.soc_at_connect_pct is not None and cap and cap > 0:
return min(95.0, float(s.soc_at_connect_pct) + (delivered_kwh / cap) * 100.0)
if cap and cap > 0 and s.energy_delivered_wh:
return min(95.0, (delivered_kwh / cap) * 100.0 + 20.0)
return 50.0
def _estimate_needed_kwh(soc_pct: float, battery_kwh: float, ev_sessions: list[EvSessionRow]) -> float:
bat_needed = max(0.0, (80.0 - soc_pct) / 100.0 * battery_kwh)
ev_needed = 0.0
for s in ev_sessions:
cap = s.battery_capacity_kwh or 60.0
tgt = (s.target_soc_pct if s.target_soc_pct is not None else None) or (
s.default_target_soc_pct if s.default_target_soc_pct is not None else 80.0
)
delivered = (s.energy_delivered_wh or 0) / 1000.0
want = max(0.0, (tgt / 100.0) * cap - delivered)
ev_needed += want
return bat_needed + ev_needed
def _estimate_ev_free_kwh(s: EvSessionRow) -> float:
cap = s.battery_capacity_kwh or 60.0
delivered = (s.energy_delivered_wh or 0) / 1000.0
return max(0.0, cap * 0.95 - delivered)
def _tesla_potential_kwh(ev_sessions: list[EvSessionRow]) -> float:
"""Odhad „kolik lze ještě dobít“ pro typickou Teslu, pokud není připojena."""
if ev_sessions:
return 0.0
return 55.0
def _date_label(d: date) -> str:
today = datetime.now(PRAGUE).date()
if d == today:
return "dnes"
if d == today + timedelta(days=1):
return "zítra"
return f"{d.day}. {d.month}."
def _hours_until(predicted_date: date, start_hour: int) -> float:
start_local = datetime.combine(predicted_date, time(hour=start_hour, minute=0), tzinfo=PRAGUE)
now = datetime.now(PRAGUE)
delta = start_local - now
return max(0.0, delta.total_seconds() / 3600.0)
def build_smart_notifications(
*,
prices: list[PriceSlot],
avg_buy: float | None,
soc_pct: float | None,
battery_kwh: float | None,
ev_sessions: list[EvSessionRow],
neg_windows: list[NegWindowRow],
mode: str,
sell_price_now: float | None,
) -> list[dict[str, Any]]:
notifications: list[dict[str, Any]] = []
soc = float(soc_pct) if soc_pct is not None else 50.0
bat_kwh = float(battery_kwh) if battery_kwh is not None and battery_kwh > 0 else 10.0
current_buy = prices[0].buy if prices else None
current_sell = prices[0].sell if prices else None
# 1. Záporná cena právě teď
if current_buy is not None and current_buy < 0:
bat_free_kwh = (100.0 - soc) / 100.0 * bat_kwh
ev_hint = _ev_connect_hint(ev_sessions, current_buy, avg_buy)
notifications.append(
{
"id": "neg_price_now",
"level": "success",
"title": f"Záporné ceny právě teď ({current_buy:.3f} Kč/kWh)",
"body": (
f"Dostaneš zaplaceno za každý odebraný kWh. "
f"Baterie může pojmout ještě {bat_free_kwh:.1f} kWh. "
+ ev_hint
),
"eta_minutes": 0,
"action": "connect_ev" if not ev_sessions else None,
}
)
# 2. Levná cena v příštích 6 h
avg_ok = avg_buy is not None and avg_buy > 0
if prices and avg_ok and not (current_buy is not None and current_buy < 0):
horizon = prices[:24]
cheap_slots = [p for p in horizon if p.buy < avg_buy * 0.60]
if cheap_slots:
cheapest = min(cheap_slots, key=lambda p: p.buy)
now_utc = datetime.now(timezone.utc)
istart = cheapest.interval_start
if istart.tzinfo is None:
istart = istart.replace(tzinfo=timezone.utc)
eta_min = int((istart - now_utc).total_seconds() / 60)
eta_min = max(0, eta_min)
savings_per_kwh = avg_buy - cheapest.buy
ev_plug_useful = (not ev_sessions) or any(_estimate_ev_soc(s) < 60.0 for s in ev_sessions)
bat_low = soc < 70.0
if ev_plug_useful or bat_low:
needed_kwh = _estimate_needed_kwh(soc, bat_kwh, ev_sessions)
savings_czk = needed_kwh * max(0.0, savings_per_kwh)
extra_body = ""
if ev_plug_useful and not ev_sessions:
extra_body = " Připoj auto před tímto oknem."
notifications.append(
{
"id": "cheap_price_soon",
"level": "info",
"title": f"Levná elektřina za {eta_min} min ({cheapest.buy:.3f} Kč/kWh)",
"body": (
f"Cena bude o {savings_per_kwh:.2f} Kč/kWh nižší než průměr. "
f"Potenciální úspora: ~{savings_czk:.0f} Kč."
+ extra_body
),
"eta_minutes": eta_min,
"action": "connect_ev" if ev_plug_useful and not ev_sessions else None,
}
)
# 3. Predikované záporné ceny
for window in neg_windows[:2]:
if any(n["id"] == "neg_price_now" for n in notifications):
continue
date_label = _date_label(window.predicted_date)
window_str = f"{window.window_start_hour:02d}:00{window.window_end_hour:02d}:00"
hours_until = _hours_until(window.predicted_date, window.window_start_hour)
bat_free = (100.0 - soc) / 100.0 * bat_kwh
ev_free = sum(_estimate_ev_free_kwh(s) for s in ev_sessions)
total_free = bat_free + ev_free
tesla_kwh = _tesla_potential_kwh(ev_sessions)
ev_hint = (
f" Připojíš-li Teslu, lze dobít až {tesla_kwh:.0f} kWh navíc." if not ev_sessions else ""
)
lvl: NotificationLevel = "success" if window.probability_pct >= 70 else "info"
notifications.append(
{
"id": f"neg_pred_{window.predicted_date}_{window.window_start_hour}",
"level": lvl,
"title": (
f"Záporné ceny {date_label} {window_str} ({window.probability_pct}% jistota)"
),
"body": (
f"Solver naplánuje max. odběr ze sítě. Lze dobít ~{total_free:.0f} kWh zdarma."
+ ev_hint
),
"eta_minutes": int(hours_until * 60),
"action": "connect_ev" if not ev_sessions else None,
}
)
# 4. Manuální režim + drahá cena + plná baterie
mode_u = (mode or "").strip().upper()
sell = sell_price_now if sell_price_now is not None else (current_sell if current_sell is not None else None)
if mode_u != "AUTO" and avg_ok and sell is not None and sell > avg_buy * 1.30 and soc > 70.0:
pct_above = ((sell / avg_buy) - 1.0) * 100.0 if avg_buy else 0.0
notifications.append(
{
"id": "manual_expensive",
"level": "warning",
"title": "Drahá elektřina systém není v AUTO",
"body": (
f"Cena prodeje {sell:.3f} Kč/kWh (+{pct_above:.0f}% nad průměr). "
f"Baterie {soc:.0f}%. Přepnutím na AUTO solver využije cenové okno."
),
"eta_minutes": None,
"action": "switch_auto",
}
)
priority = {"error": 0, "success": 1, "warning": 2, "info": 3}
notifications.sort(key=lambda n: priority.get(n["level"], 9))
return notifications