Files
Shinebridge/haos-addon/src/history.py
T
retr0 2456f356b4 v1.8.16: Finanzen-Tab — Festpreis vs. Spot-Vergleich
Täglicher Tarif-Tracking: kWh + EPEX-Ø-Preis werden ab jetzt täglich
gespeichert. Neuer Finanzen-Tab zeigt Balkendiagramm (Festpreis vs.
Spot hypothetisch), Summen-Karten und Empfehlung ob flexibler Tarif
sich lohnen würde.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:29:55 +02:00

205 lines
7.0 KiB
Python

import logging
import sqlite3
import threading
import time
from typing import Dict, List, Optional, Tuple
log = logging.getLogger(__name__)
DB_PATH = "/data/history.db"
RETENTION_DAYS = 7
_lock = threading.Lock()
_conn: sqlite3.Connection | None = None
def _get_conn() -> sqlite3.Connection:
global _conn
if _conn is None:
_conn = sqlite3.connect(DB_PATH, check_same_thread=False)
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA synchronous=NORMAL")
return _conn
def init_db():
with _lock:
c = _get_conn()
c.execute("""
CREATE TABLE IF NOT EXISTS measurements (
inv_id TEXT NOT NULL,
sensor_id TEXT NOT NULL,
ts REAL NOT NULL,
value REAL NOT NULL
)
""")
c.execute("""
CREATE INDEX IF NOT EXISTS idx_inv_sensor_ts
ON measurements(inv_id, sensor_id, ts)
""")
# Periodenstarts: kWh-Zählerstand zu Beginn jeder Abrechungsperiode
c.execute("""
CREATE TABLE IF NOT EXISTS period_starts (
agg_id TEXT NOT NULL,
period_type TEXT NOT NULL,
period_key TEXT NOT NULL,
value REAL NOT NULL,
PRIMARY KEY (agg_id, period_type, period_key)
)
""")
# Tariftage: täglicher Verbrauch + Preisvergleich
c.execute("""
CREATE TABLE IF NOT EXISTS tariff_days (
date TEXT PRIMARY KEY,
kwh REAL NOT NULL,
spot_ct REAL,
fixed_ct REAL NOT NULL,
markup_ct REAL NOT NULL DEFAULT 0
)
""")
c.commit()
cleanup_old()
log.info("History DB initialisiert: %s", DB_PATH)
def period_key(period_type: str, billing_day: int = 1, billing_month: int = 1) -> str:
import datetime
today = datetime.date.today()
if period_type == "monthly":
return today.strftime("%Y-%m")
# Jahresperiode: Beginn = letzter Abrechnungsstichtag
try:
start = datetime.date(today.year, billing_month, billing_day)
except ValueError:
start = datetime.date(today.year, billing_month, 1)
if today < start:
try:
start = datetime.date(today.year - 1, billing_month, billing_day)
except ValueError:
start = datetime.date(today.year - 1, billing_month, 1)
return start.isoformat() # z.B. "2025-04-01"
def save_period_start_if_new(agg_id: str, period_type: str, key: str, current_value: float):
"""Speichert den Startwert nur wenn diese Periode noch nicht existiert."""
with _lock:
c = _get_conn()
c.execute("""
INSERT OR IGNORE INTO period_starts(agg_id, period_type, period_key, value)
VALUES (?, ?, ?, ?)
""", (agg_id, period_type, key, current_value))
c.commit()
def get_period_consumption(agg_id: str, period_type: str, key: str,
current_value: float) -> Optional[float]:
"""Verbrauch seit Periodenbeginn; None wenn noch kein Startwert gespeichert."""
with _lock:
row = _get_conn().execute("""
SELECT value FROM period_starts
WHERE agg_id=? AND period_type=? AND period_key=?
""", (agg_id, period_type, key)).fetchone()
if row is None:
return None
return max(0.0, current_value - row[0])
def write_batch(inv_id: str, ts: float, values: Dict[str, float]):
rows = [(inv_id, sid, ts, val) for sid, val in values.items()]
with _lock:
_get_conn().executemany(
"INSERT INTO measurements(inv_id, sensor_id, ts, value) VALUES(?,?,?,?)",
rows,
)
_get_conn().commit()
def load_recent(inv_id: str, limit: int = 300) -> Dict[str, List[Tuple[float, float]]]:
"""Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start."""
with _lock:
rows = _get_conn().execute("""
SELECT sensor_id, ts, value
FROM (
SELECT sensor_id, ts, value,
ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY ts DESC) AS rn
FROM measurements
WHERE inv_id = ?
)
WHERE rn <= ?
ORDER BY ts ASC
""", (inv_id, limit)).fetchall()
result: Dict[str, List[Tuple[float, float]]] = {}
for sensor_id, ts, value in rows:
result.setdefault(sensor_id, []).append((ts, value))
return result
def query(inv_id: str, sensor_id: str,
from_ts: float, to_ts: float) -> List[Tuple[float, float]]:
"""Zeitfenster-Abfrage für die History-API und den späteren Diagnose-Tab."""
with _lock:
rows = _get_conn().execute("""
SELECT ts, value FROM measurements
WHERE inv_id = ? AND sensor_id = ? AND ts BETWEEN ? AND ?
ORDER BY ts ASC
""", (inv_id, sensor_id, from_ts, to_ts)).fetchall()
return rows
def save_daily_tariff_snapshot(date_iso: str, prev_kwh: float, cur_kwh: float,
spot_ct: Optional[float], fixed_ct: float, markup_ct: float):
"""Speichert den Tagesverbrauch + Preisdaten für einen abgeschlossenen Tag."""
delta = max(0.0, cur_kwh - prev_kwh)
with _lock:
c = _get_conn()
c.execute("""
INSERT OR IGNORE INTO tariff_days(date, kwh, spot_ct, fixed_ct, markup_ct)
VALUES(?, ?, ?, ?, ?)
""", (date_iso, round(delta, 4), spot_ct, fixed_ct, markup_ct))
c.commit()
def get_tariff_days(from_date: str, to_date: str) -> List[Tuple]:
"""Gibt alle Tariftage im Bereich [from_date, to_date] zurück."""
with _lock:
return _get_conn().execute("""
SELECT date, kwh, spot_ct, fixed_ct, markup_ct
FROM tariff_days
WHERE date >= ? AND date <= ?
ORDER BY date ASC
""", (from_date, to_date)).fetchall()
def get_daily_kwh_start(date_iso: str) -> Optional[float]:
"""Gibt den gespeicherten kWh-Tagesstartwert zurück (aus period_starts)."""
with _lock:
row = _get_conn().execute("""
SELECT value FROM period_starts
WHERE agg_id='tariff' AND period_type='daily' AND period_key=?
""", (date_iso,)).fetchone()
return row[0] if row else None
def save_daily_kwh_start(date_iso: str, kwh: float):
"""Speichert den kWh-Stand als Tagesstartwert (einmalig, INSERT OR IGNORE)."""
with _lock:
c = _get_conn()
c.execute("""
INSERT OR IGNORE INTO period_starts(agg_id, period_type, period_key, value)
VALUES('tariff', 'daily', ?, ?)
""", (date_iso, kwh))
c.commit()
def cleanup_old(days: int = RETENTION_DAYS):
cutoff = time.time() - days * 86400
with _lock:
c = _get_conn()
deleted = c.execute(
"DELETE FROM measurements WHERE ts < ?", (cutoff,)
).rowcount
c.commit()
if deleted:
log.info("History: %d alte Einträge gelöscht (>%d Tage)", deleted, days)