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