#!/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()