Feature: Flexibler Stromtarif (Börsenstrom) in Einstellungen + Periodenabrechnung (v1.8.1)

- Tarifart: Festpreis / Flexibel (Börsenstrom) — Radio-Toggle in Einstellungen
- Flexibel: Aufschlag (ct/kWh) + Land (DE/AT) konfigurierbar
- /api/period-energy: bei tariff_type=spot → historische aWATTar-Preise, 1h Cache
- Kostenrechnung Monat/Jahr: Ø Börsenpreis + Aufschlag statt Festpreis
- Periodenkarte zeigt "Ø X.X + Y.Y ct/kWh" bei Börsentarif
- Spot-Chart-Toggle in Einstellungen (spot_chart true/false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-29 08:27:55 +02:00
parent 526e802c74
commit ec26782765
3 changed files with 135 additions and 29 deletions
+68 -11
View File
@@ -90,6 +90,10 @@ def _defaults() -> Dict[str, Any]:
"price_export": 0.08,
"billing_day": 1,
"billing_month": 1,
"tariff_type": "fixed",
"spot_country": "de",
"spot_markup": 0.0,
"spot_chart": True,
"inverters": [],
}
@@ -122,6 +126,10 @@ def save_config():
"price_export": State.mqtt_cfg.get("price_export", 0.08),
"billing_day": State.mqtt_cfg.get("billing_day", 1),
"billing_month": State.mqtt_cfg.get("billing_month", 1),
"tariff_type": State.mqtt_cfg.get("tariff_type", "fixed"),
"spot_country": State.mqtt_cfg.get("spot_country", "de"),
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
"spot_chart": State.mqtt_cfg.get("spot_chart", True),
"inverters": State.inverters_cfg,
}
with open(CONFIG_PATH, "w") as f:
@@ -346,12 +354,17 @@ def api_save_config():
State.mqtt_cfg[k] = data[k]
if data.get("mqtt_pass"):
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
for k in ("price_import", "price_export"):
for k in ("price_import", "price_export", "spot_markup"):
if k in data:
State.mqtt_cfg[k] = float(data[k])
for k in ("billing_day", "billing_month"):
if k in data:
State.mqtt_cfg[k] = int(data[k])
for k in ("tariff_type", "spot_country"):
if k in data:
State.mqtt_cfg[k] = str(data[k])
if "spot_chart" in data:
State.mqtt_cfg["spot_chart"] = bool(data["spot_chart"])
save_config()
threading.Thread(target=_restart_all, daemon=True).start()
return jsonify({"ok": True})
@@ -365,29 +378,34 @@ def api_period_energy():
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
billing_month = int(State.mqtt_cfg.get("billing_month", 1))
tariff_type = str(State.mqtt_cfg.get("tariff_type", "fixed"))
spot_country = str(State.mqtt_cfg.get("spot_country", "de"))
spot_markup = float(State.mqtt_cfg.get("spot_markup", 0.0)) # ct/kWh
result = {
"price_import": price_import,
"price_export": price_export,
"billing_day": billing_day,
"billing_month": billing_month,
"tariff_type": tariff_type,
"spot_chart": bool(State.mqtt_cfg.get("spot_chart", True)),
}
MONTHS_DE = ["","Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]
now_ts = time.time()
for period_type in ("monthly", "yearly"):
key = history.period_key(period_type, billing_day, billing_month)
entry = {}
# Lesbare Beschriftung
# Perioden-Startzeitpunkt für Spot-Preisabfrage
if period_type == "monthly":
d = datetime.date.fromisoformat(key + "-01")
entry["label"] = f"{MONTHS_DE[d.month]} {d.year}"
pd = datetime.date.fromisoformat(key + "-01")
entry["label"] = f"{MONTHS_DE[pd.month]} {pd.year}"
else:
d = datetime.date.fromisoformat(key)
end = datetime.date(d.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \
else datetime.date(d.year + 1, 1, 1)
entry["label"] = f"{d.strftime('%d.%m.%Y')} {(end - datetime.timedelta(days=1)).strftime('%d.%m.%Y')}"
pd = datetime.date.fromisoformat(key)
end_d = datetime.date(pd.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \
else datetime.date(pd.year + 1, 1, 1)
entry["label"] = f"{pd.strftime('%d.%m.%Y')} {(end_d - datetime.timedelta(days=1)).strftime('%d.%m.%Y')}"
pd_start_ts = datetime.datetime.combine(pd, datetime.time.min).timestamp()
for agg_id in ("grid_import_kwh", "grid_export_kwh"):
cur = agg.get(agg_id)
@@ -397,10 +415,24 @@ def api_period_energy():
val = history.get_period_consumption(agg_id, period_type, key, cur)
if val is not None:
entry[agg_id] = round(val, 2)
# Kostenberechnung: Festpreis oder Börsenpreis
if "grid_import_kwh" in entry:
entry["import_cost"] = round(entry["grid_import_kwh"] * price_import, 2)
if tariff_type == "spot":
avg_ct = _get_avg_spot_price(pd_start_ts, now_ts, spot_country)
if avg_ct is not None:
eff_price = (avg_ct + spot_markup) / 100 # ct/kWh → €/kWh
entry["import_cost"] = round(entry["grid_import_kwh"] * eff_price, 2)
entry["spot_avg_ct"] = avg_ct
entry["spot_markup_ct"] = spot_markup
entry["effective_price"]= round(eff_price, 4)
else:
entry["import_cost"] = round(entry["grid_import_kwh"] * price_import, 2)
else:
entry["import_cost"] = round(entry["grid_import_kwh"] * price_import, 2)
if "grid_export_kwh" in entry:
entry["export_revenue"] = round(entry["grid_export_kwh"] * price_export, 2)
result[period_type] = entry
return jsonify(result)
@@ -545,6 +577,31 @@ def api_import_config():
threading.Thread(target=_restart_all, daemon=True).start()
return jsonify({"ok": True, "inverters": len(inverters)})
_hist_spot_cache: Dict[str, Any] = {} # key → {"ts", "avg_ct"}
def _get_avg_spot_price(start_ts: float, end_ts: float, country: str = "de") -> Optional[float]:
"""Durchschnittlicher EPEX-SPOT-Preis (ct/kWh) für einen Zeitraum. None bei Fehler."""
import urllib.request as _ur
cache_key = f"{country}-{int(start_ts//3600)}-{int(end_ts//3600)}"
now = time.time()
cached = _hist_spot_cache.get(cache_key)
if cached and now - cached["ts"] < 3600:
return cached["avg_ct"]
try:
url = (f"https://api.awattar.{country}/v1/marketdata"
f"?start={int(start_ts*1000)}&end={int(end_ts*1000)}")
with _ur.urlopen(url, timeout=10) as r:
raw = json.loads(r.read())
prices = [d["marketprice"] / 10 for d in raw.get("data", [])]
if not prices:
return None
avg = round(sum(prices) / len(prices), 2)
_hist_spot_cache[cache_key] = {"ts": now, "avg_ct": avg}
return avg
except Exception as e:
log.warning("Historischer Spot-Preis Fehler: %s", e)
return None
_spot_cache: Dict[str, Any] = {"ts": 0.0, "data": []}
_SPOT_TTL = 900 # 15 Minuten