revival zur Nutzung von Wärme für grosse PV Anlagen

This commit is contained in:
Michael Stangl
2026-06-15 11:27:14 +02:00
parent 0a3972a9b4
commit 6d3d7bf55c
2 changed files with 398 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""Revival grosser PV-Anlagen: Strom als Waerme weiterverkaufen.
Berechnet fuer eine gegebene PV-Anlagengroesse (kWp), welche Waermemenge
ueber eine Waermepumpe produziert werden kann und welche Leistung
Waermepumpe und Waermetauscher haben muessen, um den taeglichen PV-Strom
vollstaendig in Waerme umzuwandeln.
Aufruf:
python revival.py 100
python revival.py 250 --config cfg/config.json -v
python revival.py 500 --sonnenstunden 5.5
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
from pathlib import Path
def t(wert: float, nachkomma: int = 0) -> str:
"""Zahl mit Hochkomma als Tausendertrenner formatieren."""
if nachkomma > 0:
s = f"{wert:,.{nachkomma}f}"
else:
s = f"{wert:,.0f}"
return s.replace(",", "'")
KJ_PER_KWH = 3600.0
TAGE_PRO_JAHR = 365.0
PROJECT_ROOT = Path(__file__).parent.parent
MONATSTAGE = {
"Jan": 31, "Feb": 28, "Mae": 31, "Apr": 30, "Mai": 31, "Jun": 30,
"Jul": 31, "Aug": 31, "Sep": 30, "Okt": 31, "Nov": 30, "Dez": 31,
}
def lade_config(pfad: Path) -> dict:
with pfad.open(encoding="utf-8") as f:
return json.load(f)
def monatsberechnung(kwp: float, cfg: dict) -> dict:
"""Monatliche PV-Erzeugung, WP-Strom und Waermeproduktion (Luft + Wasser)."""
pv = cfg["pv"]
cop_luft = cfg["waermepumpe"]["cop"]
cop_wasser = cfg["waermepumpe"].get("cop_wasser", 4.0)
monats = pv["monatsertrag_kWh_pro_kWp"]
detail = {}
jahres_pv = 0.0
jahres_waerme_luft = 0.0
jahres_waerme_wasser = 0.0
for monat, tage in MONATSTAGE.items():
pv_monat_kWh = monats[monat] * kwp
pv_tag_kWh = pv_monat_kWh / tage
wl_monat = pv_monat_kWh * cop_luft
ww_monat = pv_monat_kWh * cop_wasser
jahres_pv += pv_monat_kWh
jahres_waerme_luft += wl_monat
jahres_waerme_wasser += ww_monat
detail[monat] = {
"tage": tage,
"pv_kWh": pv_monat_kWh,
"pv_kWh_tag": pv_tag_kWh,
"waerme_luft_kWh": wl_monat,
"waerme_luft_kWh_tag": wl_monat / tage,
"waerme_wasser_kWh": ww_monat,
"waerme_wasser_kWh_tag": ww_monat / tage,
}
return {
"monate": detail,
"jahres_pv_kWh": jahres_pv,
"jahres_waerme_luft_kWh": jahres_waerme_luft,
"jahres_waerme_wasser_kWh": jahres_waerme_wasser,
"cop_luft": cop_luft,
"cop_wasser": cop_wasser,
}
def spitzenmonat(monate: dict) -> tuple[str, dict]:
"""Monat mit der hoechsten taeglichen PV-Erzeugung."""
return max(monate.items(), key=lambda x: x[1]["pv_kWh_tag"])
def waermepumpe_auslegung(kwp: float, cfg: dict, sonnenstunden: float) -> dict:
"""Erforderliche WP-Leistung um den PV-Strom tagsueberaufzunehmen.
sonnenstunden: aequivalente Volllaststunden Sonne pro Tag im Spitzenmonat.
Die WP muss den gesamten Tagesstrom in diesen Stunden verarbeiten koennen.
"""
pv = cfg["pv"]
cop = cfg["waermepumpe"]["cop"]
# Spitzenmonat: maximale taegliche Erzeugung
monats = pv["monatsertrag_kWh_pro_kWp"]
max_monat, max_ertrag = max(
((m, monats[m] / MONATSTAGE[m]) for m in MONATSTAGE),
key=lambda x: x[1],
)
pv_peak_tag_kWh = max_ertrag * kwp
# WP muss diese Energie in den Sonnenstunden aufnehmen
wp_el_kW = pv_peak_tag_kWh / sonnenstunden
wp_therm_kW = wp_el_kW * cop
wp_kaelte_kW = wp_therm_kW - wp_el_kW # Verdampferseite
return {
"spitzenmonat": max_monat,
"pv_peak_tag_kWh": pv_peak_tag_kWh,
"sonnenstunden": sonnenstunden,
"wp_elektrisch_kW": wp_el_kW,
"wp_thermisch_kW": wp_therm_kW,
"wp_kaelte_kW": wp_kaelte_kW,
}
def waermetauscher_auslegung(wp: dict, sicherheitsfaktor: float = 1.2) -> dict:
"""Erforderliche WT-Leistung auf Lade- und Entladeseite."""
return {
"ladeleistung_kW": wp["wp_thermisch_kW"] * sicherheitsfaktor,
"entladeleistung_kW": wp["wp_thermisch_kW"] * sicherheitsfaktor,
"sicherheitsfaktor": sicherheitsfaktor,
"_hinweis": "Entladeseite kann kleiner sein wenn Entladung ueber laengere Zeit erfolgt",
}
def haushalte_versorgung(jahres_waerme_kWh: float, cfg: dict) -> dict:
"""Wie viele Haushalte kann die Waerme versorgen?"""
h = cfg["haushalt"]
ww_tag = h["erwachsene"] * h["ww_erwachsener_kWh_tag"] + h["kinder"] * h["ww_kind_kWh_tag"]
winter_tag = ww_tag + h["heizbedarf_winter_kWh_tag"]
waerme_tag_schnitt = jahres_waerme_kWh / TAGE_PRO_JAHR
return {
"waerme_tag_schnitt_kWh": waerme_tag_schnitt,
"ww_pro_haushalt_kWh_tag": ww_tag,
"winter_pro_haushalt_kWh_tag": winter_tag,
"haushalte_warmwasser": waerme_tag_schnitt / ww_tag if ww_tag > 0 else 0.0,
"haushalte_winter": waerme_tag_schnitt / winter_tag if winter_tag > 0 else 0.0,
}
def wirtschaftlichkeit(kwp: float, jahres_pv_kWh: float,
jahres_waerme_kWh: float, cfg: dict) -> dict:
"""Erloes der Waerme im Vergleich zu fossilen Brennstoffen und Stromverkauf."""
vergleich = []
for b in cfg.get("brennstoffe", []):
nw_ct = b["preis_ct_kWh"] / b["wirkungsgrad"]
erloes_jahr = jahres_waerme_kWh * nw_ct / 100.0
vergleich.append({
"name": b["name"],
"nutzwaerme_ct_kWh": nw_ct,
"erloes_jahr_eur": erloes_jahr,
})
# PV-Gestehungskosten als Vergleich
pv_gestehung = cfg.get("anbieter", {}).get("pv_gestehung_ct_kWh", 9.0)
cop = cfg["waermepumpe"]["cop"]
waerme_gestehung_ct = pv_gestehung / cop
# Stromverkauf als Alternative (Einspeisung / Direktvermarktung)
pv_cfg = cfg.get("pv", {})
einspeisung_ct = pv_cfg.get("einspeiseverguetung_ct_kWh", 8.1)
direktvermarktung_ct = pv_cfg.get("direktvermarktung_ct_kWh", 7.0)
erloes_einspeisung = jahres_pv_kWh * einspeisung_ct / 100.0
erloes_direktvermarktung = jahres_pv_kWh * direktvermarktung_ct / 100.0
return {
"pv_gestehung_ct_kWh": pv_gestehung,
"waerme_gestehung_ct_kWh": waerme_gestehung_ct,
"vergleich": vergleich,
"stromverkauf": {
"jahres_pv_kWh": jahres_pv_kWh,
"einspeiseverguetung_ct_kWh": einspeisung_ct,
"direktvermarktung_ct_kWh": direktvermarktung_ct,
"erloes_einspeisung_eur": erloes_einspeisung,
"erloes_direktvermarktung_eur": erloes_direktvermarktung,
},
}
def rechne(kwp: float, cfg: dict, sonnenstunden: float = 5.0) -> dict:
mon = monatsberechnung(kwp, cfg)
wp = waermepumpe_auslegung(kwp, cfg, sonnenstunden)
wt = waermetauscher_auslegung(wp)
hh = haushalte_versorgung(mon["jahres_waerme_luft_kWh"], cfg)
wi = wirtschaftlichkeit(kwp, mon["jahres_pv_kWh"], mon["jahres_waerme_luft_kWh"], cfg)
return {
"eingang": {"kwp": kwp, "sonnenstunden": sonnenstunden},
"cop_luft": mon["cop_luft"],
"cop_wasser": mon["cop_wasser"],
"monatsberechnung": mon,
"waermepumpe": wp,
"waermetauscher": wt,
"haushalte": hh,
"wirtschaftlichkeit": wi,
}
def report(e: dict, cfg: dict) -> str:
wp = e["waermepumpe"]
wt = e["waermetauscher"]
mon = e["monatsberechnung"]
hh = e["haushalte"]
wi = e["wirtschaftlichkeit"]
kwp = e["eingang"]["kwp"]
cop_l = e["cop_luft"]
cop_w = e["cop_wasser"]
L = []
L.append(f"# PV-REVIVAL: {kwp:.0f} kWp Anlage -> Waermeproduktion")
L.append("")
L.append(f"Luft-WP COP {cop_l:.1f} / Wasser-WP COP {cop_w:.1f}, "
f"{wp['sonnenstunden']:.1f} h aequiv. Sonnenstunden/Tag")
L.append("")
L.append("## Jaehrliche Erzeugung")
L.append("")
L.append("| Kenngroesse | Wert | Einheit |")
L.append("|:---|---:|:---|")
L.append(f"| PV-Strom | {t(mon['jahres_pv_kWh'])} | kWh/a |")
L.append(f"| Waerme Luft-WP (COP {cop_l:.1f}) | {t(mon['jahres_waerme_luft_kWh'])} | kWh/a |")
L.append(f"| Waerme Wasser-WP (COP {cop_w:.1f}) | {t(mon['jahres_waerme_wasser_kWh'])} | kWh/a |")
L.append(f"| Waerme Tagesmittel (Luft) | {t(hh['waerme_tag_schnitt_kWh'], 1)} | kWh/Tag |")
L.append("")
L.append("## Erforderliche Waermepumpe")
L.append("")
L.append("| Kenngroesse | Wert | Einheit |")
L.append("|:---|---:|:---|")
L.append(f"| Spitzenmonat | {wp['spitzenmonat']} | |")
L.append(f"| PV-Erzeugung Spitzentag | {t(wp['pv_peak_tag_kWh'], 1)} | kWh/Tag |")
L.append(f"| Elektr. Leistung WP | {t(wp['wp_elektrisch_kW'], 1)} | kW |")
L.append(f"| Thermische Leistung WP | {t(wp['wp_thermisch_kW'], 1)} | kW |")
L.append(f"| Kaelteleistung (Verdampfer) | {t(wp['wp_kaelte_kW'], 1)} | kW |")
L.append("")
L.append(f"## Erforderlicher Waermetauscher (Sicherheitsfaktor {wt['sicherheitsfaktor']:.1f}x)")
L.append("")
L.append("| Kenngroesse | Wert | Einheit |")
L.append("|:---|---:|:---|")
L.append(f"| Ladeleistung | {t(wt['ladeleistung_kW'], 1)} | kW |")
L.append(f"| Entladeleistung | {t(wt['entladeleistung_kW'], 1)} | kW |")
L.append("")
L.append("## Monatsuebersicht")
L.append("")
L.append(f"| Monat | PV kWh | kWh/Tag | Luft kWh (COP {cop_l:.1f}) | Wasser kWh (COP {cop_w:.1f}) |")
L.append("|:---|---:|---:|---:|---:|")
for monat in MONATSTAGE:
m = mon["monate"][monat]
L.append(f"| {monat} | {t(m['pv_kWh'])} | {t(m['pv_kWh_tag'], 1)} |"
f" {t(m['waerme_luft_kWh'])} | {t(m['waerme_wasser_kWh'])} |")
L.append(f"| **Summe** | **{t(mon['jahres_pv_kWh'])}** | |"
f" **{t(mon['jahres_waerme_luft_kWh'])}** |"
f" **{t(mon['jahres_waerme_wasser_kWh'])}** |")
# Mermaid-Diagramm
L.append("")
L.append("## Diagramm: Strom- und Waermeproduktion pro Monat")
L.append("")
L.append("```mermaid")
L.append("%%{init: {'theme': 'base', 'themeVariables': {"
"'xyChart': {'plotColorPalette': '#f0c808, #5bc0eb, #1d3557'}"
"}}}%%")
L.append("xychart-beta")
L.append(f" title \"PV-Revival {kwp:.0f} kWp - Monatsproduktion (MWh)\"")
L.append(" x-axis [Jan, Feb, Mae, Apr, Mai, Jun, Jul, Aug, Sep, Okt, Nov, Dez]")
# Werte in MWh fuer bessere Lesbarkeit
pv_vals = [f"{mon['monate'][m]['pv_kWh']/1000:.1f}" for m in MONATSTAGE]
luft_vals = [f"{mon['monate'][m]['waerme_luft_kWh']/1000:.1f}" for m in MONATSTAGE]
wasser_vals = [f"{mon['monate'][m]['waerme_wasser_kWh']/1000:.1f}" for m in MONATSTAGE]
L.append(f" y-axis \"MWh\"")
L.append(f" bar [{', '.join(pv_vals)}]")
L.append(f" line [{', '.join(luft_vals)}]")
L.append(f" line [{', '.join(wasser_vals)}]")
L.append("```")
L.append("")
L.append(f"*Balken: PV-Strom | Linie 1: Waerme Luft-WP (COP {cop_l:.1f}) | "
f"Linie 2: Waerme Wasser-WP (COP {cop_w:.1f}) -- Werte in MWh*")
L.append("")
L.append(f"## Versorgbare Haushalte ({cfg['haushalt']['erwachsene']} Erw. + "
f"{cfg['haushalt']['kinder']} Kinder, Tagesmittel)")
L.append("")
L.append("| Kenngroesse | Wert |")
L.append("|:---|---:|")
L.append(f"| Warmwasser ganzjaehrig | {t(hh['haushalte_warmwasser'])} Haushalte |")
L.append(f"| Winter (Heizung + WW) | {t(hh['haushalte_winter'], 1)} Haushalte |")
if wi["vergleich"]:
L.append("")
L.append("## Wirtschaftlichkeit")
L.append("")
L.append("| Kenngroesse | Wert | Einheit |")
L.append("|:---|---:|:---|")
L.append(f"| PV-Stromgestehung | {t(wi['pv_gestehung_ct_kWh'], 1)} | ct/kWh |")
L.append(f"| Waermegestehung (/ COP) | {t(wi['waerme_gestehung_ct_kWh'], 1)} | ct/kWh |")
L.append("")
L.append("Zum Vergleich: Kosten der anderen Brennstoffe (Nutzwaerme-Preis):")
L.append("")
L.append("| Brennstoff | ct/kWh | Erloes EUR/a |")
L.append("|:---|---:|---:|")
for v in wi["vergleich"]:
L.append(f"| {v['name']} | {t(v['nutzwaerme_ct_kWh'], 1)} | {t(v['erloes_jahr_eur'])} |")
L.append("")
L.append("*Erloes = Waerme bewertet zu Ersatzbrennstoffkosten*")
sv = wi["stromverkauf"]
L.append("")
L.append("### Alternative: Strom direkt verkaufen")
L.append("")
L.append("| Variante | ct/kWh | Erloes EUR/a |")
L.append("|:---|---:|---:|")
L.append(f"| Einspeiseverguetung (EEG) | {t(sv['einspeiseverguetung_ct_kWh'], 1)} |"
f" {t(sv['erloes_einspeisung_eur'])} |")
L.append(f"| Direktvermarktung | {t(sv['direktvermarktung_ct_kWh'], 1)} |"
f" {t(sv['erloes_direktvermarktung_eur'])} |")
L.append("")
# Vergleich: bester Waerme-Erloes vs. Stromverkauf
best_waerme = max(wi["vergleich"], key=lambda v: v["erloes_jahr_eur"])
vorteil = best_waerme["erloes_jahr_eur"] - sv["erloes_einspeisung_eur"]
if vorteil > 0:
L.append(f"*Waermeverkauf (vs. {best_waerme['name']}) bringt "
f"{t(vorteil)} EUR/a mehr als Einspeisung.*")
else:
L.append(f"*Einspeisung bringt {t(-vorteil)} EUR/a mehr als "
f"Waermeverkauf (vs. {best_waerme['name']}).*")
return "\n".join(L)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("kwp", type=float, help="Groesse der PV-Anlage in kWp")
ap.add_argument("--config", type=Path,
default=PROJECT_ROOT / "cfg" / "config.json",
help="Konfiguration (Standard: cfg/config.json)")
ap.add_argument("--sonnenstunden", type=float, default=5.0,
help="Aequivalente Volllaststunden Sonne pro Tag im Spitzenmonat "
"(Standard: 5.0 h, bestimmt die noetige WP-Spitzenleistung)")
ap.add_argument("--out", type=Path, default=None,
help="Ausgabeverzeichnis (Standard: $MILCH_OUT oder out/)")
ap.add_argument("--json", type=Path, default=None,
help="Ergebnis zusaetzlich als JSON schreiben")
ap.add_argument("-v", "--verbose", action="store_true",
help="Ausgaben auf der Konsole")
args = ap.parse_args()
out_ordner = (
args.out
or Path(os.environ.get("MILCH_OUT", ""))
or PROJECT_ROOT / "out"
)
if not out_ordner or str(out_ordner) == ".":
out_ordner = PROJECT_ROOT / "out"
out_ordner = Path(out_ordner)
out_ordner.mkdir(parents=True, exist_ok=True)
cfg = lade_config(args.config)
ergebnis = rechne(args.kwp, cfg, sonnenstunden=args.sonnenstunden)
text = report(ergebnis, cfg)
if args.verbose:
print(text)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
md_name = f"revival_{args.kwp:.0f}kWp_{ts}.md"
md_path = out_ordner / md_name
md_path.write_text(text, encoding="utf-8")
if args.verbose:
print(f"\nReport geschrieben: {md_path}")
if args.json:
with args.json.open("w", encoding="utf-8") as f:
json.dump(ergebnis, f, ensure_ascii=False, indent=2)
if args.verbose:
print(f"JSON geschrieben: {args.json}")
if __name__ == "__main__":
main()