second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -0,0 +1,249 @@
"""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