erste Fassung der Berechnung.

This commit is contained in:
Michael Stangl
2026-06-14 08:58:25 +02:00
commit ad6af04801
8 changed files with 896 additions and 0 deletions
+394
View File
@@ -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()