From ad6af048012b0c07276e05618c0d97bd9d63abc7 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Sun, 14 Jun 2026 08:58:25 +0200 Subject: [PATCH] erste Fassung der Berechnung. --- auslegung.py | 356 +++++++++++++++++++++++++++++++++++++++++ bin/auslegung.bat | 9 ++ bin/auslegung.sh | 7 + bin/setenv.bat | 7 + bin/setenv.sh | 6 + cfg/config.json | 63 ++++++++ config.json | 54 +++++++ lib/auslegung.py | 394 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 896 insertions(+) create mode 100644 auslegung.py create mode 100644 bin/auslegung.bat create mode 100755 bin/auslegung.sh create mode 100644 bin/setenv.bat create mode 100755 bin/setenv.sh create mode 100644 cfg/config.json create mode 100644 config.json create mode 100644 lib/auslegung.py diff --git a/auslegung.py b/auslegung.py new file mode 100644 index 0000000..91d991f --- /dev/null +++ b/auslegung.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Auslegung einer Waermerueckgewinnung aus der Milchkuehlung. + +Eingangsgroesse: zu kuehlende Milchmenge pro Tag (Liter). +Ausgabe: erforderliche Groesse von Waermepumpe, PV-Anlage, Latentspeicher +(Natriumacetat) und Batterie. + +Alle Annahmen stehen in einer JSON-Datei (Standard: config.json), damit sich +Varianten leicht nachrechnen lassen. + +Aufruf: + python auslegung.py 1000 + python auslegung.py 1500 --config variante_winterautark.json + python auslegung.py 1000 --json ergebnis.json +""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path + +KJ_PER_KWH = 3600.0 +TAGE_PRO_JAHR = 365.0 + +# Tage je Monat (Schluessel muessen zu pv.monatsertrag_kWh_pro_kWp passen) +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 milch_kuehllast(milch_l_tag: float, cfg: dict) -> float: + """Taeglich der Milch zu entziehende Waerme in kWh/Tag.""" + m = cfg["milch"] + masse = milch_l_tag * m["dichte_kg_pro_L"] + dT = m["temp_ein_C"] - m["temp_aus_C"] + q_kj = masse * m["spez_waerme_kJ_pro_kgK"] * dT + return q_kj / KJ_PER_KWH + + +def waermepumpe(q_milch_tag: float, cfg: dict) -> dict: + """Strombedarf und Leistungsdaten der Waermepumpe. + + Q_speicher = Q_milch + W_el (die Verdichterarbeit landet als Waerme im Speicher). + """ + hp = cfg["waermepumpe"] + cop = hp["cop"] + if cop <= 1.0: + raise ValueError("COP muss > 1 sein.") + w_el_tag = q_milch_tag / (cop - 1.0) + q_speicher_tag = q_milch_tag + w_el_tag + laufzeit = hp["laufzeit_h_pro_tag"] + return { + "strom_kWh_tag": w_el_tag, + "speicher_kWh_tag": q_speicher_tag, + "kaelteleistung_kW": q_milch_tag / laufzeit, # Verdampferseite + "heizleistung_kW": q_speicher_tag / laufzeit, # Kondensatorseite + "elektrische_leistung_kW": w_el_tag / laufzeit, + } + + +def pcm_energiedichte_kWh_pro_kg(cfg: dict) -> float: + """Nutzbare Energiedichte des PCM je nach Modus.""" + p = cfg["pcm"] + e_kj = p["latentwaerme_kJ_pro_kg"] + modus = p["modus"] + if modus == "latent_only": + pass + elif modus == "latent_plus_hub": + ueberhitzung = max(0.0, p["ladetemp_C"] - p["schmelzpunkt_C"]) + unterkuehlung = max(0.0, p["schmelzpunkt_C"] - p["entladetemp_C"]) + e_kj += p["cp_fluessig_kJ_pro_kgK"] * ueberhitzung + e_kj += p["cp_fest_kJ_pro_kgK"] * unterkuehlung + else: + raise ValueError(f"Unbekannter pcm.modus: {modus!r}") + return e_kj / KJ_PER_KWH + + +def speicher(q_speicher_tag: float, cfg: dict, zyklus: float | None = None) -> dict: + """Groesse des Latentspeichers fuer einen Abholzyklus + LKW-Abgleich. + + zyklus: Abholzyklus in Tagen. None -> Wert aus der Config. + """ + a = cfg["abholung"] + p = cfg["pcm"] + if zyklus is None: + zyklus = a["zyklus_tage"] + e_kg = pcm_energiedichte_kWh_pro_kg(cfg) + + e_zyklus = q_speicher_tag * zyklus # kWh je Abholzyklus + masse_kg = e_zyklus / e_kg + volumen_m3 = masse_kg / (p["dichte_kg_pro_L"] * 1000.0) + + nutzlast = a["lkw_nutzlast_kg"] + lkw_vol = a["lkw_volumen_m3"] + # Welche Grenze bindet? + if masse_kg > nutzlast or volumen_m3 > lkw_vol: + bindend = "Gewicht" if masse_kg / nutzlast >= volumen_m3 / lkw_vol else "Volumen" + passt = False + else: + bindend = "Gewicht" if masse_kg / nutzlast >= volumen_m3 / lkw_vol else "Volumen" + passt = True + + # Wie oft muesste ein voller LKW (nach Gewicht) getauscht werden? + masse_pro_tag = q_speicher_tag / e_kg + voll_lkw_tage = nutzlast / masse_pro_tag if masse_pro_tag > 0 else float("inf") + + return { + "energiedichte_kWh_pro_kg": e_kg, + "energie_pro_zyklus_kWh": e_zyklus, + "masse_kg": masse_kg, + "volumen_m3": volumen_m3, + "auslastung_gewicht": masse_kg / nutzlast, + "auslastung_volumen": volumen_m3 / lkw_vol, + "bindende_grenze": bindend, + "passt_in_einen_lkw": passt, + "voller_lkw_tausch_tage": voll_lkw_tage, + } + + +def pv_anlage(w_el_tag: float, cfg: dict) -> dict: + """PV-Groesse fuer Jahresbilanz des WP-Strombedarfs.""" + pv = cfg["pv"] + jahresbedarf = w_el_tag * TAGE_PRO_JAHR + kwp = jahresbedarf / pv["spez_ertrag_kWh_pro_kWp_jahr"] + flaeche = kwp * pv["flaeche_m2_pro_kWp"] + return { + "jahresbedarf_kWh": jahresbedarf, + "kWp": kwp, + "flaeche_m2": flaeche, + } + + +def netzbezug(w_el_tag: float, kwp: float, cfg: dict) -> dict: + """Monatsbilanz -> jaehrlicher Netzbezug durch den Winter.""" + monats = cfg["pv"]["monatsertrag_kWh_pro_kWp"] + bezug = 0.0 + ueberschuss = 0.0 + jahresbedarf = 0.0 + monatsdetail = {} + for monat, tage in MONATSTAGE.items(): + bedarf = w_el_tag * tage + erzeugung = monats[monat] * kwp + jahresbedarf += bedarf + defizit = max(0.0, bedarf - erzeugung) + ueber = max(0.0, erzeugung - bedarf) + bezug += defizit + ueberschuss += ueber + monatsdetail[monat] = { + "bedarf_kWh": bedarf, + "pv_kWh": erzeugung, + "zukauf_kWh": defizit, + } + return { + "netzbezug_kWh": bezug, + "ueberschuss_kWh": ueberschuss, + "jahresbedarf_kWh": jahresbedarf, + "netzbezug_anteil": bezug / jahresbedarf if jahresbedarf else 0.0, + "monate": monatsdetail, + } + + +def batterie(w_el_tag: float, cfg: dict) -> dict: + """Batteriegroesse zur Ueberbbrueckung des Tag-/Nacht-Versatzes.""" + b = cfg["batterie"] + nacht_anteil = 1.0 - b["direkt_pv_stunden"] / 24.0 + nutzbar = w_el_tag * nacht_anteil * b["reserve_faktor"] + nominal = nutzbar / b["nutzbar_anteil"] + return { + "nutzbar_kWh": nutzbar, + "nominal_kWh": nominal, + } + + +def rechne(milch_l_tag: float, cfg: dict) -> dict: + q_milch = milch_kuehllast(milch_l_tag, cfg) + wp = waermepumpe(q_milch, cfg) + sp = speicher(wp["speicher_kWh_tag"], cfg) + pv = pv_anlage(wp["strom_kWh_tag"], cfg) + netz = netzbezug(wp["strom_kWh_tag"], pv["kWp"], cfg) + bat = batterie(wp["strom_kWh_tag"], cfg) + return { + "eingang": {"milch_l_tag": milch_l_tag}, + "milch_kuehllast_kWh_tag": q_milch, + "waermepumpe": wp, + "speicher": sp, + "pv": pv, + "netzbezug": netz, + "batterie": bat, + } + + +def runde_auf(wert: float, schritt: float) -> float: + """Auf das naechste Vielfache von 'schritt' aufrunden (praktische Groessen).""" + return math.ceil(wert / schritt) * schritt + + +def vorschlag(e: dict, cfg: dict) -> dict: + """Empfohlene, auf praktische Groessen gerundete Auslegung. + + PV und Batterie sind unabhaengig vom Abholzyklus. Fuer den Speicher wird + der groesste Wochen-Zyklus empfohlen, der noch in einen LKW passt + (beste Transportauslastung, wenigste Fahrten). + """ + wp = e["waermepumpe"] + pv = e["pv"] + bat = e["batterie"] + q_speicher_tag = wp["speicher_kWh_tag"] + + e_kg = pcm_energiedichte_kWh_pro_kg(cfg) + masse_pro_tag = q_speicher_tag / e_kg + nutzlast = cfg["abholung"]["lkw_nutzlast_kg"] + voll_lkw_tage = nutzlast / masse_pro_tag if masse_pro_tag > 0 else float("inf") + + # Kandidaten im Wochenraster, die in einen LKW passen + kandidaten = [] + z = 7 + while z <= voll_lkw_tage: + kandidaten.append((z, speicher(q_speicher_tag, cfg, zyklus=z))) + z += 7 + + if kandidaten: + empf_zyklus = kandidaten[-1][0] + else: + # LKW schon vor einer Woche voll -> haeufige Abholung noetig + empf_zyklus = max(1, math.floor(voll_lkw_tage)) + empf_speicher = speicher(q_speicher_tag, cfg, zyklus=empf_zyklus) + + return { + "pv_kWp": runde_auf(pv["kWp"], 0.5), + "pv_flaeche_m2": runde_auf(pv["flaeche_m2"], 5), + "batterie_kWh": runde_auf(bat["nominal_kWh"], 5), + "voll_lkw_tage": voll_lkw_tage, + "empf_zyklus_tage": empf_zyklus, + "empf_speicher": empf_speicher, + "kandidaten": kandidaten, + } + + +def vorschlag_report(e: dict, cfg: dict) -> str: + v = vorschlag(e, cfg) + sp = v["empf_speicher"] + L = [] + L.append("=" * 62) + L.append(f" EMPFOHLENE AUSLEGUNG ({e['eingang']['milch_l_tag']:.0f} L Milch/Tag)") + L.append("=" * 62) + L.append(f"PV-Anlage ............ ~{v['pv_kWp']:.1f} kWp (~{v['pv_flaeche_m2']:.0f} m2 Dachflaeche)") + L.append(f"Stromspeicher ........ ~{v['batterie_kWh']:.0f} kWh (Nennkapazitaet, LFP)") + L.append(f"Waermespeicher ....... ~{sp['masse_kg']/1000:.1f} t (~{sp['volumen_m3']:.1f} m3 {cfg['pcm']['name']})") + L.append(f"Abholzyklus .......... alle {v['empf_zyklus_tage']:.0f} Tage") + L.append("") + L.append(f" -> LKW dann zu {sp['auslastung_gewicht']*100:.0f} % ausgelastet " + f"({sp['energie_pro_zyklus_kWh']:.0f} kWh je Fuhre)") + L.append(f" -> ein voller LKW waere nach {v['voll_lkw_tage']:.0f} Tagen erreicht") + L.append("") + L.append("Abholoptionen (Latentspeicher je Zyklus):") + L.append(f" {'Zyklus':>8} | {'Masse':>8} | {'Volumen':>8} | {'LKW-Ausl.':>9}") + L.append(f" {'-'*8}-+-{'-'*8}-+-{'-'*8}-+-{'-'*9}") + for z, s in v["kandidaten"]: + marker = " <== empfohlen" if z == v["empf_zyklus_tage"] else "" + L.append(f" {z:>5.0f} Tg | {s['masse_kg']/1000:>6.1f} t | " + f"{s['volumen_m3']:>6.1f} m3 | {s['auslastung_gewicht']*100:>7.0f} %{marker}") + if not v["kandidaten"]: + L.append(" (LKW bereits vor einer Woche voll - haeufigere Abholung noetig)") + L.append("=" * 62) + return "\n".join(L) + + +def report(e: dict, cfg: dict) -> str: + q = e["milch_kuehllast_kWh_tag"] + wp = e["waermepumpe"] + sp = e["speicher"] + pv = e["pv"] + netz = e["netzbezug"] + bat = e["batterie"] + z = cfg["abholung"]["zyklus_tage"] + L = [] + L.append("=" * 62) + L.append(f" AUSLEGUNG WAERMERUECKGEWINNUNG MILCHKUEHLUNG") + L.append(f" Eingang: {e['eingang']['milch_l_tag']:.0f} L Milch/Tag" + f" ({cfg['milch']['temp_ein_C']:.0f} -> {cfg['milch']['temp_aus_C']:.0f} C)") + L.append("=" * 62) + L.append(f"Kuehllast der Milch ........... {q:8.1f} kWh/Tag") + L.append("") + L.append("WAERMEPUMPE (COP {:.2f}, Laufzeit {:.0f} h/Tag)".format( + cfg["waermepumpe"]["cop"], cfg["waermepumpe"]["laufzeit_h_pro_tag"])) + L.append(f" Kaelteleistung (Verdampfer) . {wp['kaelteleistung_kW']:8.1f} kW") + L.append(f" Heizleistung (Kondensator) .. {wp['heizleistung_kW']:8.1f} kW") + L.append(f" el. Leistung ................ {wp['elektrische_leistung_kW']:8.1f} kW") + L.append(f" Strombedarf ................. {wp['strom_kWh_tag']:8.1f} kWh/Tag") + L.append(f" Waerme in den Speicher ...... {wp['speicher_kWh_tag']:8.1f} kWh/Tag") + L.append("") + L.append("PV-ANLAGE (Jahresbilanz)") + L.append(f" Jahresstrombedarf WP ........ {pv['jahresbedarf_kWh']:8.0f} kWh/a") + L.append(f" Leistung .................... {pv['kWp']:8.1f} kWp") + L.append(f" Dachflaeche ................. {pv['flaeche_m2']:8.0f} m2") + L.append("") + L.append(f"NETZBEZUG (durch Wintermonate, Monatsbilanz)") + L.append(f" Netzbezug ................... {netz['netzbezug_kWh']:8.0f} kWh/a" + f" = {netz['netzbezug_anteil']*100:.0f} %") + L.append(f" Sommerueberschuss ........... {netz['ueberschuss_kWh']:8.0f} kWh/a") + L.append("") + L.append("BATTERIE (Tag-/Nacht-Ueberbbrueckung)") + L.append(f" nutzbare Kapazitaet ......... {bat['nutzbar_kWh']:8.1f} kWh") + L.append(f" Nennkapazitaet .............. {bat['nominal_kWh']:8.1f} kWh") + L.append("") + L.append(f"LATENTSPEICHER ({cfg['pcm']['name']}, Modus: {cfg['pcm']['modus']})") + L.append(f" Energiedichte ............... {sp['energiedichte_kWh_pro_kg']*1000:8.1f} Wh/kg") + L.append(f" Energie je Zyklus ({z:>2} Tage) . {sp['energie_pro_zyklus_kWh']:8.0f} kWh") + L.append(f" Masse ....................... {sp['masse_kg']:8.0f} kg") + L.append(f" Volumen ..................... {sp['volumen_m3']:8.1f} m3") + L.append(f" LKW-Auslastung Gewicht ...... {sp['auslastung_gewicht']*100:8.0f} %") + L.append(f" LKW-Auslastung Volumen ...... {sp['auslastung_volumen']*100:8.0f} %") + L.append(f" bindende Grenze ............. {sp['bindende_grenze']:>8}") + passt = "ja" if sp["passt_in_einen_lkw"] else "NEIN -> mehrere Behaelter noetig" + L.append(f" passt in einen LKW .......... {passt:>8}") + L.append(f" voller LKW voll nach ........ {sp['voller_lkw_tausch_tage']:8.1f} Tagen") + L.append("=" * 62) + return "\n".join(L) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("milch_l_tag", type=float, help="Milchmenge pro Tag in Litern") + ap.add_argument("--config", type=Path, default=Path(__file__).with_name("config.json"), + help="Pfad zur JSON-Konfiguration (Standard: config.json)") + ap.add_argument("--json", type=Path, default=None, + help="Ergebnis zusaetzlich als JSON in diese Datei schreiben") + ap.add_argument("--suggest-size", action="store_true", + help="Kompakte Empfehlung (gerundete Groessen + Abholzyklus) statt Detailreport") + args = ap.parse_args() + + cfg = lade_config(args.config) + ergebnis = rechne(args.milch_l_tag, cfg) + + if args.suggest_size: + print(vorschlag_report(ergebnis, cfg)) + ergebnis["vorschlag"] = vorschlag(ergebnis, cfg) + else: + print(report(ergebnis, cfg)) + + if args.json: + with args.json.open("w", encoding="utf-8") as f: + json.dump(ergebnis, f, ensure_ascii=False, indent=2) + print(f"\nErgebnis geschrieben: {args.json}") + + +if __name__ == "__main__": + main() diff --git a/bin/auslegung.bat b/bin/auslegung.bat new file mode 100644 index 0000000..e4251e0 --- /dev/null +++ b/bin/auslegung.bat @@ -0,0 +1,9 @@ +@echo off +REM Aufruf: auslegung.bat [weitere Optionen] +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 +python "%MILCH_LIB%\auslegung.py" --config "%MILCH_CFG%\config.json" %* diff --git a/bin/auslegung.sh b/bin/auslegung.sh new file mode 100755 index 0000000..4b0c27d --- /dev/null +++ b/bin/auslegung.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Aufruf: auslegung.sh [weitere Optionen] +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MILCH_HOME="${MILCH_HOME:-$(dirname "$SCRIPT_DIR")}" +MILCH_LIB="${MILCH_LIB:-$MILCH_HOME/lib}" +MILCH_CFG="${MILCH_CFG:-$MILCH_HOME/cfg}" +exec python3 "$MILCH_LIB/auslegung.py" --config "$MILCH_CFG/config.json" "$@" diff --git a/bin/setenv.bat b/bin/setenv.bat new file mode 100644 index 0000000..50c9eba --- /dev/null +++ b/bin/setenv.bat @@ -0,0 +1,7 @@ +@echo off +REM Projektpfade setzen – Datei per "call setenv.bat" einbinden +SET MILCH_HOME=%~dp0.. +IF "%MILCH_HOME:~-1%"=="\" SET MILCH_HOME=%MILCH_HOME:~0,-1% +SET MILCH_BIN=%MILCH_HOME%\bin +SET MILCH_LIB=%MILCH_HOME%\lib +SET MILCH_CFG=%MILCH_HOME%\cfg diff --git a/bin/setenv.sh b/bin/setenv.sh new file mode 100755 index 0000000..2c77c0b --- /dev/null +++ b/bin/setenv.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Projektpfade setzen – Datei muss *gesourced* werden: source setenv.sh +export MILCH_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export MILCH_BIN="$MILCH_HOME/bin" +export MILCH_LIB="$MILCH_HOME/lib" +export MILCH_CFG="$MILCH_HOME/cfg" diff --git a/cfg/config.json b/cfg/config.json new file mode 100644 index 0000000..e489599 --- /dev/null +++ b/cfg/config.json @@ -0,0 +1,63 @@ +{ + "_beschreibung": "Annahmen fuer die Auslegung der Waermerueckgewinnung aus der Milchkuehlung. Eine Variante laesst sich nachrechnen, indem man diese Datei kopiert und Werte aendert.", + + "milch": { + "temp_ein_C": 37.0, + "temp_aus_C": 4.0, + "dichte_kg_pro_L": 1.0, + "spez_waerme_kJ_pro_kgK": 4.186, + "_kommentar": "Milch wird als Wasser gerechnet. Eutertemperatur -> Lagertemperatur." + }, + + "waermepumpe": { + "cop": 2.6, + "laufzeit_h_pro_tag": 18.0, + "_kommentar": "COP fuer den Hub von ~0 C (Verdampfer) auf >58 C (Kondensator). Laufzeit bestimmt die Leistungsauslegung (kW)." + }, + + "pcm": { + "name": "Natriumacetat-Trihydrat", + "schmelzpunkt_C": 58.0, + "latentwaerme_kJ_pro_kg": 264.0, + "dichte_kg_pro_L": 1.3, + "cp_fluessig_kJ_pro_kgK": 2.9, + "cp_fest_kJ_pro_kgK": 1.9, + "modus": "latent_plus_hub", + "ladetemp_C": 80.0, + "entladetemp_C": 58.0, + "_kommentar": "modus = 'latent_only' (nur Phasenwechsel) oder 'latent_plus_hub' (zusaetzlich fuehlbare Waerme durch Ueberhitzung der Schmelze auf ladetemp_C und optionale Unterkuehlung des Feststoffs bis entladetemp_C)." + }, + + "abholung": { + "zyklus_tage": 14, + "lkw_nutzlast_kg": 24000, + "lkw_volumen_m3": 90, + "_kommentar": "zyklus_tage = wie oft der Speicher geleert/abgeholt wird. LKW-Grenzen = Standard-Sattelauflieger." + }, + + "pv": { + "spez_ertrag_kWh_pro_kWp_jahr": 1000, + "flaeche_m2_pro_kWp": 5.0, + "monatsertrag_kWh_pro_kWp": { + "Jan": 30, "Feb": 45, "Mae": 85, "Apr": 115, "Mai": 130, "Jun": 130, + "Jul": 135, "Aug": 120, "Sep": 95, "Okt": 60, "Nov": 32, "Dez": 25 + }, + "_kommentar": "Werte fuer optimal geneigte Suedflaeche, Standort Suedbayern. Summe der Monate ~ spez_ertrag." + }, + + "batterie": { + "direkt_pv_stunden": 9.0, + "nutzbar_anteil": 0.9, + "reserve_faktor": 1.2, + "_kommentar": "direkt_pv_stunden = Stunden/Tag, in denen die WP direkt aus PV laeuft. Rest wird ueber die Batterie ueberbrueckt. reserve_faktor = Sicherheitszuschlag, nutzbar_anteil = Entladetiefe (LFP ~0.9)." + }, + + "haushalt": { + "erwachsene": 2, + "kinder": 2, + "ww_erwachsener_kWh_tag": 1.8, + "ww_kind_kWh_tag": 1.0, + "heizbedarf_winter_kWh_tag": 60.0, + "_kommentar": "Warmwasser je Person/Tag. heizbedarf_winter = max. taeglicher Raumheizbedarf eines Haushalts im tiefsten Winter, stark gebaeudeabhaengig (Neubau/saniert ~40, Altbau ~100). Auf 0 setzen, wenn nur Warmwasser betrachtet werden soll." + } +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..f218b87 --- /dev/null +++ b/config.json @@ -0,0 +1,54 @@ +{ + "_beschreibung": "Annahmen fuer die Auslegung der Waermerueckgewinnung aus der Milchkuehlung. Eine Variante laesst sich nachrechnen, indem man diese Datei kopiert und Werte aendert.", + + "milch": { + "temp_ein_C": 37.0, + "temp_aus_C": 4.0, + "dichte_kg_pro_L": 1.0, + "spez_waerme_kJ_pro_kgK": 4.186, + "_kommentar": "Milch wird als Wasser gerechnet. Eutertemperatur -> Lagertemperatur." + }, + + "waermepumpe": { + "cop": 2.6, + "laufzeit_h_pro_tag": 18.0, + "_kommentar": "COP fuer den Hub von ~0 C (Verdampfer) auf >58 C (Kondensator). Laufzeit bestimmt die Leistungsauslegung (kW)." + }, + + "pcm": { + "name": "Natriumacetat-Trihydrat", + "schmelzpunkt_C": 58.0, + "latentwaerme_kJ_pro_kg": 264.0, + "dichte_kg_pro_L": 1.3, + "cp_fluessig_kJ_pro_kgK": 2.9, + "cp_fest_kJ_pro_kgK": 1.9, + "modus": "latent_plus_hub", + "ladetemp_C": 80.0, + "entladetemp_C": 58.0, + "_kommentar": "modus = 'latent_only' (nur Phasenwechsel) oder 'latent_plus_hub' (zusaetzlich fuehlbare Waerme durch Ueberhitzung der Schmelze auf ladetemp_C und optionale Unterkuehlung des Feststoffs bis entladetemp_C)." + }, + + "abholung": { + "zyklus_tage": 14, + "lkw_nutzlast_kg": 24000, + "lkw_volumen_m3": 90, + "_kommentar": "zyklus_tage = wie oft der Speicher geleert/abgeholt wird. LKW-Grenzen = Standard-Sattelauflieger." + }, + + "pv": { + "spez_ertrag_kWh_pro_kWp_jahr": 1000, + "flaeche_m2_pro_kWp": 5.0, + "monatsertrag_kWh_pro_kWp": { + "Jan": 30, "Feb": 45, "Mae": 85, "Apr": 115, "Mai": 130, "Jun": 130, + "Jul": 135, "Aug": 120, "Sep": 95, "Okt": 60, "Nov": 32, "Dez": 25 + }, + "_kommentar": "Werte fuer optimal geneigte Suedflaeche, Standort Suedbayern. Summe der Monate ~ spez_ertrag." + }, + + "batterie": { + "direkt_pv_stunden": 9.0, + "nutzbar_anteil": 0.9, + "reserve_faktor": 1.2, + "_kommentar": "direkt_pv_stunden = Stunden/Tag, in denen die WP direkt aus PV laeuft. Rest wird ueber die Batterie ueberbrueckt. reserve_faktor = Sicherheitszuschlag, nutzbar_anteil = Entladetiefe (LFP ~0.9)." + } +} diff --git a/lib/auslegung.py b/lib/auslegung.py new file mode 100644 index 0000000..e3e4bff --- /dev/null +++ b/lib/auslegung.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Auslegung einer Waermerueckgewinnung aus der Milchkuehlung. + +Eingangsgroesse: zu kuehlende Milchmenge pro Tag (Liter). +Ausgabe: erforderliche Groesse von Waermepumpe, PV-Anlage, Latentspeicher +(Natriumacetat) und Batterie. + +Alle Annahmen stehen in einer JSON-Datei (Standard: config.json), damit sich +Varianten leicht nachrechnen lassen. + +Aufruf: + python auslegung.py 1000 + python auslegung.py 1500 --config variante_winterautark.json + python auslegung.py 1000 --json ergebnis.json +""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path + +KJ_PER_KWH = 3600.0 +TAGE_PRO_JAHR = 365.0 + +# Tage je Monat (Schluessel muessen zu pv.monatsertrag_kWh_pro_kWp passen) +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 milch_kuehllast(milch_l_tag: float, cfg: dict) -> float: + """Taeglich der Milch zu entziehende Waerme in kWh/Tag.""" + m = cfg["milch"] + masse = milch_l_tag * m["dichte_kg_pro_L"] + dT = m["temp_ein_C"] - m["temp_aus_C"] + q_kj = masse * m["spez_waerme_kJ_pro_kgK"] * dT + return q_kj / KJ_PER_KWH + + +def waermepumpe(q_milch_tag: float, cfg: dict) -> dict: + """Strombedarf und Leistungsdaten der Waermepumpe. + + Q_speicher = Q_milch + W_el (die Verdichterarbeit landet als Waerme im Speicher). + """ + hp = cfg["waermepumpe"] + cop = hp["cop"] + if cop <= 1.0: + raise ValueError("COP muss > 1 sein.") + w_el_tag = q_milch_tag / (cop - 1.0) + q_speicher_tag = q_milch_tag + w_el_tag + laufzeit = hp["laufzeit_h_pro_tag"] + return { + "strom_kWh_tag": w_el_tag, + "speicher_kWh_tag": q_speicher_tag, + "kaelteleistung_kW": q_milch_tag / laufzeit, # Verdampferseite + "heizleistung_kW": q_speicher_tag / laufzeit, # Kondensatorseite + "elektrische_leistung_kW": w_el_tag / laufzeit, + } + + +def pcm_energiedichte_kWh_pro_kg(cfg: dict) -> float: + """Nutzbare Energiedichte des PCM je nach Modus.""" + p = cfg["pcm"] + e_kj = p["latentwaerme_kJ_pro_kg"] + modus = p["modus"] + if modus == "latent_only": + pass + elif modus == "latent_plus_hub": + ueberhitzung = max(0.0, p["ladetemp_C"] - p["schmelzpunkt_C"]) + unterkuehlung = max(0.0, p["schmelzpunkt_C"] - p["entladetemp_C"]) + e_kj += p["cp_fluessig_kJ_pro_kgK"] * ueberhitzung + e_kj += p["cp_fest_kJ_pro_kgK"] * unterkuehlung + else: + raise ValueError(f"Unbekannter pcm.modus: {modus!r}") + return e_kj / KJ_PER_KWH + + +def speicher(q_speicher_tag: float, cfg: dict, zyklus: float | None = None) -> dict: + """Groesse des Latentspeichers fuer einen Abholzyklus + LKW-Abgleich. + + zyklus: Abholzyklus in Tagen. None -> Wert aus der Config. + """ + a = cfg["abholung"] + p = cfg["pcm"] + if zyklus is None: + zyklus = a["zyklus_tage"] + e_kg = pcm_energiedichte_kWh_pro_kg(cfg) + + e_zyklus = q_speicher_tag * zyklus # kWh je Abholzyklus + masse_kg = e_zyklus / e_kg + volumen_m3 = masse_kg / (p["dichte_kg_pro_L"] * 1000.0) + + nutzlast = a["lkw_nutzlast_kg"] + lkw_vol = a["lkw_volumen_m3"] + # Welche Grenze bindet? + if masse_kg > nutzlast or volumen_m3 > lkw_vol: + bindend = "Gewicht" if masse_kg / nutzlast >= volumen_m3 / lkw_vol else "Volumen" + passt = False + else: + bindend = "Gewicht" if masse_kg / nutzlast >= volumen_m3 / lkw_vol else "Volumen" + passt = True + + # Wie oft muesste ein voller LKW (nach Gewicht) getauscht werden? + masse_pro_tag = q_speicher_tag / e_kg + voll_lkw_tage = nutzlast / masse_pro_tag if masse_pro_tag > 0 else float("inf") + + return { + "energiedichte_kWh_pro_kg": e_kg, + "energie_pro_zyklus_kWh": e_zyklus, + "masse_kg": masse_kg, + "volumen_m3": volumen_m3, + "auslastung_gewicht": masse_kg / nutzlast, + "auslastung_volumen": volumen_m3 / lkw_vol, + "bindende_grenze": bindend, + "passt_in_einen_lkw": passt, + "voller_lkw_tausch_tage": voll_lkw_tage, + "voller_lkw_energie_kWh": nutzlast * e_kg, + } + + +def pv_anlage(w_el_tag: float, cfg: dict) -> dict: + """PV-Groesse fuer Jahresbilanz des WP-Strombedarfs.""" + pv = cfg["pv"] + jahresbedarf = w_el_tag * TAGE_PRO_JAHR + kwp = jahresbedarf / pv["spez_ertrag_kWh_pro_kWp_jahr"] + flaeche = kwp * pv["flaeche_m2_pro_kWp"] + return { + "jahresbedarf_kWh": jahresbedarf, + "kWp": kwp, + "flaeche_m2": flaeche, + } + + +def netzbezug(w_el_tag: float, kwp: float, cfg: dict) -> dict: + """Monatsbilanz -> jaehrlicher Netzbezug durch den Winter.""" + monats = cfg["pv"]["monatsertrag_kWh_pro_kWp"] + bezug = 0.0 + ueberschuss = 0.0 + jahresbedarf = 0.0 + monatsdetail = {} + for monat, tage in MONATSTAGE.items(): + bedarf = w_el_tag * tage + erzeugung = monats[monat] * kwp + jahresbedarf += bedarf + defizit = max(0.0, bedarf - erzeugung) + ueber = max(0.0, erzeugung - bedarf) + bezug += defizit + ueberschuss += ueber + monatsdetail[monat] = { + "bedarf_kWh": bedarf, + "pv_kWh": erzeugung, + "zukauf_kWh": defizit, + } + return { + "netzbezug_kWh": bezug, + "ueberschuss_kWh": ueberschuss, + "jahresbedarf_kWh": jahresbedarf, + "netzbezug_anteil": bezug / jahresbedarf if jahresbedarf else 0.0, + "monate": monatsdetail, + } + + +def batterie(w_el_tag: float, cfg: dict) -> dict: + """Batteriegroesse zur Ueberbbrueckung des Tag-/Nacht-Versatzes.""" + b = cfg["batterie"] + nacht_anteil = 1.0 - b["direkt_pv_stunden"] / 24.0 + nutzbar = w_el_tag * nacht_anteil * b["reserve_faktor"] + nominal = nutzbar / b["nutzbar_anteil"] + return { + "nutzbar_kWh": nutzbar, + "nominal_kWh": nominal, + } + + +def haushalte(q_speicher_tag: float, cfg: dict) -> dict: + """Versorgbare Haushalte aus der taeglich erzeugten Speicherwaerme. + + Warmwasser ganzjaehrig (konstanter Bedarf) sowie der Winterfall + (Raumheizung + Warmwasser), der weit mehr Waerme braucht. + """ + h = cfg["haushalt"] + ww = h["erwachsene"] * h["ww_erwachsener_kWh_tag"] + h["kinder"] * h["ww_kind_kWh_tag"] + heiz = h["heizbedarf_winter_kWh_tag"] + winter = ww + heiz + return { + "ww_pro_haushalt_kWh_tag": ww, + "heizbedarf_winter_kWh_tag": heiz, + "winter_pro_haushalt_kWh_tag": winter, + "haushalte_warmwasser": q_speicher_tag / ww if ww > 0 else 0.0, + "haushalte_winter": q_speicher_tag / winter if winter > 0 else 0.0, + } + + +def rechne(milch_l_tag: float, cfg: dict) -> dict: + q_milch = milch_kuehllast(milch_l_tag, cfg) + wp = waermepumpe(q_milch, cfg) + sp = speicher(wp["speicher_kWh_tag"], cfg) + pv = pv_anlage(wp["strom_kWh_tag"], cfg) + netz = netzbezug(wp["strom_kWh_tag"], pv["kWp"], cfg) + bat = batterie(wp["strom_kWh_tag"], cfg) + hh = haushalte(wp["speicher_kWh_tag"], cfg) + return { + "eingang": {"milch_l_tag": milch_l_tag}, + "milch_kuehllast_kWh_tag": q_milch, + "waermepumpe": wp, + "speicher": sp, + "pv": pv, + "netzbezug": netz, + "batterie": bat, + "haushalte": hh, + } + + +def runde_auf(wert: float, schritt: float) -> float: + """Auf das naechste Vielfache von 'schritt' aufrunden (praktische Groessen).""" + return math.ceil(wert / schritt) * schritt + + +def vorschlag(e: dict, cfg: dict) -> dict: + """Empfohlene, auf praktische Groessen gerundete Auslegung. + + PV und Batterie sind unabhaengig vom Abholzyklus. Fuer den Speicher wird + der groesste Wochen-Zyklus empfohlen, der noch in einen LKW passt + (beste Transportauslastung, wenigste Fahrten). + """ + wp = e["waermepumpe"] + pv = e["pv"] + bat = e["batterie"] + q_speicher_tag = wp["speicher_kWh_tag"] + + e_kg = pcm_energiedichte_kWh_pro_kg(cfg) + masse_pro_tag = q_speicher_tag / e_kg + nutzlast = cfg["abholung"]["lkw_nutzlast_kg"] + voll_lkw_tage = nutzlast / masse_pro_tag if masse_pro_tag > 0 else float("inf") + + # Kandidaten im Wochenraster, die in einen LKW passen + kandidaten = [] + z = 7 + while z <= voll_lkw_tage: + kandidaten.append((z, speicher(q_speicher_tag, cfg, zyklus=z))) + z += 7 + + if kandidaten: + empf_zyklus = kandidaten[-1][0] + else: + # LKW schon vor einer Woche voll -> haeufige Abholung noetig + empf_zyklus = max(1, math.floor(voll_lkw_tage)) + empf_speicher = speicher(q_speicher_tag, cfg, zyklus=empf_zyklus) + + return { + "pv_kWp": runde_auf(pv["kWp"], 0.5), + "pv_flaeche_m2": runde_auf(pv["flaeche_m2"], 5), + "batterie_kWh": runde_auf(bat["nominal_kWh"], 5), + "voll_lkw_tage": voll_lkw_tage, + "empf_zyklus_tage": empf_zyklus, + "empf_speicher": empf_speicher, + "kandidaten": kandidaten, + } + + +def vorschlag_report(e: dict, cfg: dict) -> str: + v = vorschlag(e, cfg) + sp = v["empf_speicher"] + hh = e["haushalte"] + L = [] + L.append("=" * 62) + L.append(f" EMPFOHLENE AUSLEGUNG ({e['eingang']['milch_l_tag']:.0f} L Milch/Tag)") + L.append("=" * 62) + L.append(f"PV-Anlage ............ ~{v['pv_kWp']:.1f} kWp (~{v['pv_flaeche_m2']:.0f} m2 Dachflaeche)") + L.append(f"Stromspeicher ........ ~{v['batterie_kWh']:.0f} kWh (Nennkapazitaet, LFP)") + L.append(f"Waermespeicher ....... ~{sp['masse_kg']/1000:.1f} t (~{sp['volumen_m3']:.1f} m3 {cfg['pcm']['name']})") + L.append(f"Abholzyklus .......... alle {v['empf_zyklus_tage']:.0f} Tage") + L.append("") + L.append(f" -> LKW dann zu {sp['auslastung_gewicht']*100:.0f} % ausgelastet " + f"({sp['energie_pro_zyklus_kWh']:.0f} kWh je Fuhre)") + L.append(f" -> ein voller LKW waere nach {v['voll_lkw_tage']:.0f} Tagen erreicht") + L.append(f" -> Energieinhalt eines vollen LKW: ~{sp['voller_lkw_energie_kWh']:.0f} kWh") + L.append("") + L.append(f"Versorgte Haushalte (2 Erw. + 2 Kinder):") + L.append(f" Warmwasser ganzjaehrig ... ~{hh['haushalte_warmwasser']:.0f} Haushalte") + L.append(f" im Winter (Heizung + WW) . ~{hh['haushalte_winter']:.1f} Haushalte") + L.append("") + L.append("Abholoptionen (Latentspeicher je Zyklus):") + L.append(f" {'Zyklus':>8} | {'Masse':>8} | {'Volumen':>8} | {'LKW-Ausl.':>9}") + L.append(f" {'-'*8}-+-{'-'*8}-+-{'-'*8}-+-{'-'*9}") + for z, s in v["kandidaten"]: + marker = " <== empfohlen" if z == v["empf_zyklus_tage"] else "" + L.append(f" {z:>5.0f} Tg | {s['masse_kg']/1000:>6.1f} t | " + f"{s['volumen_m3']:>6.1f} m3 | {s['auslastung_gewicht']*100:>7.0f} %{marker}") + if not v["kandidaten"]: + L.append(" (LKW bereits vor einer Woche voll - haeufigere Abholung noetig)") + L.append("=" * 62) + return "\n".join(L) + + +def report(e: dict, cfg: dict) -> str: + q = e["milch_kuehllast_kWh_tag"] + wp = e["waermepumpe"] + sp = e["speicher"] + pv = e["pv"] + netz = e["netzbezug"] + bat = e["batterie"] + hh = e["haushalte"] + z = cfg["abholung"]["zyklus_tage"] + L = [] + L.append("=" * 62) + L.append(f" AUSLEGUNG WAERMERUECKGEWINNUNG MILCHKUEHLUNG") + L.append(f" Eingang: {e['eingang']['milch_l_tag']:.0f} L Milch/Tag" + f" ({cfg['milch']['temp_ein_C']:.0f} -> {cfg['milch']['temp_aus_C']:.0f} C)") + L.append("=" * 62) + L.append(f"Kuehllast der Milch ........... {q:8.1f} kWh/Tag") + L.append("") + L.append("WAERMEPUMPE (COP {:.2f}, Laufzeit {:.0f} h/Tag)".format( + cfg["waermepumpe"]["cop"], cfg["waermepumpe"]["laufzeit_h_pro_tag"])) + L.append(f" Kaelteleistung (Verdampfer) . {wp['kaelteleistung_kW']:8.1f} kW") + L.append(f" Heizleistung (Kondensator) .. {wp['heizleistung_kW']:8.1f} kW") + L.append(f" el. Leistung ................ {wp['elektrische_leistung_kW']:8.1f} kW") + L.append(f" Strombedarf ................. {wp['strom_kWh_tag']:8.1f} kWh/Tag") + L.append(f" Waerme in den Speicher ...... {wp['speicher_kWh_tag']:8.1f} kWh/Tag") + L.append("") + L.append("PV-ANLAGE (Jahresbilanz)") + L.append(f" Jahresstrombedarf WP ........ {pv['jahresbedarf_kWh']:8.0f} kWh/a") + L.append(f" Leistung .................... {pv['kWp']:8.1f} kWp") + L.append(f" Dachflaeche ................. {pv['flaeche_m2']:8.0f} m2") + L.append("") + L.append(f"NETZBEZUG (durch Wintermonate, Monatsbilanz)") + L.append(f" Netzbezug ................... {netz['netzbezug_kWh']:8.0f} kWh/a" + f" = {netz['netzbezug_anteil']*100:.0f} %") + L.append(f" Sommerueberschuss ........... {netz['ueberschuss_kWh']:8.0f} kWh/a") + L.append("") + L.append("BATTERIE (Tag-/Nacht-Ueberbbrueckung)") + L.append(f" nutzbare Kapazitaet ......... {bat['nutzbar_kWh']:8.1f} kWh") + L.append(f" Nennkapazitaet .............. {bat['nominal_kWh']:8.1f} kWh") + L.append("") + L.append("VERSORGTE HAUSHALTE ({} Erw. + {} Kinder)".format( + cfg["haushalt"]["erwachsene"], cfg["haushalt"]["kinder"])) + L.append(f" Warmwasser je Haushalt ...... {hh['ww_pro_haushalt_kWh_tag']:8.1f} kWh/Tag") + L.append(f" -> Warmwasser ganzjaehrig ... {hh['haushalte_warmwasser']:8.1f} Haushalte") + L.append(f" max. Heizbedarf Winter ...... {hh['heizbedarf_winter_kWh_tag']:8.1f} kWh/Tag/HH") + L.append(f" Winterbedarf (Heiz+WW) ...... {hh['winter_pro_haushalt_kWh_tag']:8.1f} kWh/Tag/HH") + L.append(f" -> im Winter ................ {hh['haushalte_winter']:8.1f} Haushalte") + L.append("") + L.append(f"LATENTSPEICHER ({cfg['pcm']['name']}, Modus: {cfg['pcm']['modus']})") + L.append(f" Energiedichte ............... {sp['energiedichte_kWh_pro_kg']*1000:8.1f} Wh/kg") + L.append(f" Energie je Zyklus ({z:>2} Tage) . {sp['energie_pro_zyklus_kWh']:8.0f} kWh") + L.append(f" Masse ....................... {sp['masse_kg']:8.0f} kg") + L.append(f" Volumen ..................... {sp['volumen_m3']:8.1f} m3") + L.append(f" LKW-Auslastung Gewicht ...... {sp['auslastung_gewicht']*100:8.0f} %") + L.append(f" LKW-Auslastung Volumen ...... {sp['auslastung_volumen']*100:8.0f} %") + L.append(f" bindende Grenze ............. {sp['bindende_grenze']:>8}") + passt = "ja" if sp["passt_in_einen_lkw"] else "NEIN -> mehrere Behaelter noetig" + L.append(f" passt in einen LKW .......... {passt:>8}") + L.append(f" voller LKW voll nach ........ {sp['voller_lkw_tausch_tage']:8.1f} Tagen") + L.append(f" Energieinhalt voller LKW .... {sp['voller_lkw_energie_kWh']:8.0f} kWh") + L.append("=" * 62) + return "\n".join(L) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("milch_l_tag", type=float, help="Milchmenge pro Tag in Litern") + ap.add_argument("--config", type=Path, default=Path(__file__).with_name("config.json"), + help="Pfad zur JSON-Konfiguration (Standard: config.json)") + ap.add_argument("--json", type=Path, default=None, + help="Ergebnis zusaetzlich als JSON in diese Datei schreiben") + ap.add_argument("--suggest-size", action="store_true", + help="Kompakte Empfehlung (gerundete Groessen + Abholzyklus) statt Detailreport") + args = ap.parse_args() + + cfg = lade_config(args.config) + ergebnis = rechne(args.milch_l_tag, cfg) + + if args.suggest_size: + print(vorschlag_report(ergebnis, cfg)) + ergebnis["vorschlag"] = vorschlag(ergebnis, cfg) + else: + print(report(ergebnis, cfg)) + + if args.json: + with args.json.open("w", encoding="utf-8") as f: + json.dump(ergebnis, f, ensure_ascii=False, indent=2) + print(f"\nErgebnis geschrieben: {args.json}") + + +if __name__ == "__main__": + main()