diff --git a/bin/revival.bat b/bin/revival.bat new file mode 100644 index 0000000..572cbe4 --- /dev/null +++ b/bin/revival.bat @@ -0,0 +1,12 @@ +@echo off +REM Aufruf: revival.bat [weitere Optionen] +REM Beispiel: revival.bat 100 +REM revival.bat 250 --sonnenstunden 5.5 -v +IF NOT DEFINED MILCH_HOME ( + SET MILCH_HOME=%~dp0.. + IF "%MILCH_HOME:~-1%"=="\" SET MILCH_HOME=%MILCH_HOME:~0,-1% +) +IF NOT DEFINED MILCH_LIB SET MILCH_LIB=%MILCH_HOME%\lib +IF NOT DEFINED MILCH_CFG SET MILCH_CFG=%MILCH_HOME%\cfg +IF NOT DEFINED MILCH_OUT SET MILCH_OUT=%MILCH_HOME%\out +python "%MILCH_LIB%\revival.py" --config "%MILCH_CFG%\config.json" --out "%MILCH_OUT%" %* diff --git a/lib/revival.py b/lib/revival.py new file mode 100644 index 0000000..a0e0a5f --- /dev/null +++ b/lib/revival.py @@ -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()