682 lines
28 KiB
Python
682 lines
28 KiB
Python
#!/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: cfg/config.json), damit sich
|
|
Varianten leicht nachrechnen lassen. Eine Datei im in/-Ordner (oder --file) kann
|
|
einzelne Werte gezielt ueberschreiben.
|
|
|
|
Aufruf:
|
|
python auslegung.py 1000
|
|
python auslegung.py 1500 --file in/variante_winter.json
|
|
python auslegung.py 1000 -v
|
|
python auslegung.py 1000 --json ergebnis.json -v
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from report_md import report_md
|
|
|
|
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
|
|
|
|
# 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 deep_merge(base: dict, override: dict) -> dict:
|
|
"""Rekursives Zusammenfuehren zweier Dicts; override gewinnt bei Konflikten."""
|
|
result = base.copy()
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = deep_merge(result[key], value)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
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
|
|
|
|
# Tage bis LKW voll (je Grenze, bindende = kleinerer Wert)
|
|
masse_pro_tag = q_speicher_tag / e_kg
|
|
voll_lkw_tage = nutzlast / masse_pro_tag if masse_pro_tag > 0 else float("inf")
|
|
volumen_pro_tag = masse_pro_tag / (p["dichte_kg_pro_L"] * 1000.0)
|
|
voll_vol_tage = lkw_vol / volumen_pro_tag if volumen_pro_tag > 0 else float("inf")
|
|
voll_bind_tage = min(voll_lkw_tage, voll_vol_tage)
|
|
|
|
# Empfehlung wenn bindende Auslastung < 90 %
|
|
auslastung_bind = max(masse_kg / nutzlast, volumen_m3 / lkw_vol)
|
|
empf_zyklus = math.ceil(0.90 * voll_bind_tage) if auslastung_bind < 0.90 else None
|
|
|
|
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,
|
|
"empf_zyklus_tage": empf_zyklus,
|
|
}
|
|
|
|
|
|
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 brennstoffkosten(e: dict, cfg: dict) -> dict:
|
|
"""Vergleichswert der Waerme gegenueber fossilen Brennstoffen / Pellets.
|
|
|
|
Nutzwaermekosten = Brennstoffpreis / Kesselwirkungsgrad.
|
|
Bewertet werden der Energieinhalt eines vollen LKW und die Jahresmenge.
|
|
"""
|
|
energie_lkw = e["speicher"]["voller_lkw_energie_kWh"]
|
|
energie_jahr = e["waermepumpe"]["speicher_kWh_tag"] * TAGE_PRO_JAHR
|
|
vergleich = []
|
|
for b in cfg.get("brennstoffe", []):
|
|
nw_ct = b["preis_ct_kWh"] / b["wirkungsgrad"]
|
|
vergleich.append({
|
|
"name": b["name"],
|
|
"preis_ct_kWh": b["preis_ct_kWh"],
|
|
"wirkungsgrad": b["wirkungsgrad"],
|
|
"nutzwaerme_ct_kWh": nw_ct,
|
|
"eur_pro_lkw": energie_lkw * nw_ct / 100.0,
|
|
"eur_pro_jahr": energie_jahr * nw_ct / 100.0,
|
|
})
|
|
return {
|
|
"energie_lkw_kWh": energie_lkw,
|
|
"energie_jahr_kWh": energie_jahr,
|
|
"vergleich": vergleich,
|
|
}
|
|
|
|
|
|
def container_invest_total(sp: dict, cfg: dict) -> dict:
|
|
"""Investitionssumme aller Container-Module aus der container_invest-Config."""
|
|
ci = cfg["container_invest"]
|
|
masse = sp["masse_kg"]
|
|
energie = sp["energie_pro_zyklus_kWh"]
|
|
anzahl = ci["anzahl_module_dauerbetrieb"]
|
|
|
|
pcm_eur = masse * ci["pcm_natriumacetat"]["preis_eur_pro_kg"] * ci["pcm_natriumacetat"]["reserve_faktor"]
|
|
behaelter_eur = max(ci["behaelter"]["mindestpreis_eur"],
|
|
masse * ci["behaelter"]["preis_eur_pro_kg_pcm"])
|
|
wt_eur = (ci["waermetauscher_ladung"]["preis_eur"]
|
|
+ ci["waermetauscher_entladung"]["preis_eur"])
|
|
hydraulik_eur = ci["hydraulik"]["preis_eur"]
|
|
el = ci["elektronik"]
|
|
elektronik_eur = (el["steuerung_eur"]
|
|
+ el["sensoren_pt100_anzahl"] * el["sensor_preis_eur"]
|
|
+ el["ventile_schaltschrank_eur"]
|
|
+ el["fernueberwachung_4g_eur"]
|
|
+ el["montage_verdrahtung_eur"])
|
|
montage_eur = ci["montage_inbetriebnahme_eur"]
|
|
|
|
pro_modul = pcm_eur + behaelter_eur + wt_eur + hydraulik_eur + elektronik_eur + montage_eur
|
|
gesamt = pro_modul * anzahl
|
|
eur_pro_kwh = gesamt / (energie * anzahl) if energie > 0 else 0.0
|
|
|
|
return {
|
|
"anzahl_module": anzahl,
|
|
"pcm_eur": pcm_eur * anzahl,
|
|
"behaelter_eur": behaelter_eur * anzahl,
|
|
"wt_hydraulik_eur": (wt_eur + hydraulik_eur) * anzahl,
|
|
"elektronik_eur": elektronik_eur * anzahl,
|
|
"montage_eur": montage_eur * anzahl,
|
|
"gesamt_eur": gesamt,
|
|
"eur_pro_kwh_kapazitaet": eur_pro_kwh,
|
|
}
|
|
|
|
|
|
def anbieterkosten(e: dict, cfg: dict, *, eh_da: bool = False, driver: bool = False) -> dict:
|
|
"""Jaehrliche Betriebskosten des Anbieters -> Gestehungskosten und Marge.
|
|
|
|
eh_da=True : PV und Kuehlung bereits vorhanden -> Kapitalkosten dafuer entfallen.
|
|
driver=True : Fahrer stellt der Landwirt selbst -> Fahreranteil wird abgezogen.
|
|
Abschreibung: linear ohne Kapitalzins (konservativ).
|
|
"""
|
|
a = cfg["anbieter"]
|
|
inv = a["invest"]
|
|
wp = e["waermepumpe"]
|
|
sp = e["speicher"]
|
|
pv_data = e["pv"]
|
|
netz = e["netzbezug"]
|
|
bat = e["batterie"]
|
|
|
|
energie_jahr = wp["speicher_kWh_tag"] * TAGE_PRO_JAHR
|
|
fuhren_jahr = TAGE_PRO_JAHR / cfg["abholung"]["zyklus_tage"]
|
|
|
|
fahrer_abzug = a.get("fahrer_eur_pro_fuhre", 0.0) if driver else 0.0
|
|
kosten_transport = fuhren_jahr * (a["transport_eur_pro_fuhre"] - fahrer_abzug)
|
|
|
|
netz_eur = netz["netzbezug_kWh"] * a["netzstrompreis_ct_kWh"] / 100.0
|
|
pv_kwh = netz["jahresbedarf_kWh"] - netz["netzbezug_kWh"]
|
|
pv_eur = pv_kwh * a["pv_gestehung_ct_kWh"] / 100.0
|
|
kosten_strom = netz_eur + pv_eur
|
|
|
|
kosten_wartung = a["wartung_eur_pro_jahr"]
|
|
|
|
kapital_pv = 0.0 if eh_da else pv_data["kWp"] * inv["pv_eur_pro_kWp"] / inv["pv_nutzungsdauer_jahre"]
|
|
kapital_bat = bat["nominal_kWh"] * inv["batterie_eur_pro_kWh"] / inv["batterie_nutzungsdauer_jahre"]
|
|
kapital_wp = 0.0 if eh_da else inv["waermepumpe_eur"] / inv["waermepumpe_nutzungsdauer_jahre"]
|
|
ci = container_invest_total(sp, cfg)
|
|
kapital_speicher = ci["gesamt_eur"] / inv["speicher_nutzungsdauer_jahre"]
|
|
kosten_kapital = kapital_pv + kapital_bat + kapital_wp + kapital_speicher
|
|
|
|
jahreskosten = kosten_transport + kosten_strom + kosten_wartung + kosten_kapital
|
|
gestehung_ct = jahreskosten / energie_jahr * 100.0 if energie_jahr > 0 else 0.0
|
|
|
|
marge = []
|
|
for v in e.get("brennstoffkosten", {}).get("vergleich", []):
|
|
m_ct = v["nutzwaerme_ct_kWh"] - gestehung_ct
|
|
marge.append({
|
|
"name": v["name"],
|
|
"nutzwaerme_ct_kWh": v["nutzwaerme_ct_kWh"],
|
|
"marge_ct_kWh": m_ct,
|
|
"marge_eur_jahr": m_ct / 100.0 * energie_jahr,
|
|
})
|
|
|
|
return {
|
|
"fuhren_jahr": fuhren_jahr,
|
|
"energie_jahr_kWh": energie_jahr,
|
|
"kosten_transport_eur": kosten_transport,
|
|
"kosten_strom_eur": kosten_strom,
|
|
"kosten_strom_detail": {
|
|
"netz_kWh": netz["netzbezug_kWh"],
|
|
"netz_ct_kWh": a["netzstrompreis_ct_kWh"],
|
|
"netz_eur": netz_eur,
|
|
"pv_kWh": pv_kwh,
|
|
"pv_ct_kWh": a["pv_gestehung_ct_kWh"],
|
|
"pv_eur": pv_eur,
|
|
},
|
|
"kosten_wartung_eur": kosten_wartung,
|
|
"kapital_pv_eur": kapital_pv,
|
|
"kapital_batterie_eur": kapital_bat,
|
|
"kapital_waermepumpe_eur": kapital_wp,
|
|
"kapital_speicher_eur": kapital_speicher,
|
|
"kosten_kapital_eur": kosten_kapital,
|
|
"jahreskosten_eur": jahreskosten,
|
|
"gestehungskosten_ct_kWh": gestehung_ct,
|
|
"container_invest": ci,
|
|
"marge": marge,
|
|
"eh_da": eh_da,
|
|
"driver": driver,
|
|
"fahrer_abzug_eur_pro_fuhre": fahrer_abzug,
|
|
}
|
|
|
|
|
|
def rechne(milch_l_tag: float, cfg: dict, *, eh_da: bool = False, driver: bool = False) -> 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)
|
|
e = {
|
|
"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,
|
|
}
|
|
if "brennstoffe" in cfg:
|
|
e["brennstoffkosten"] = brennstoffkosten(e, cfg)
|
|
if "anbieter" in cfg and "container_invest" in cfg:
|
|
e["anbieter"] = anbieterkosten(e, cfg, eh_da=eh_da, driver=driver)
|
|
return e
|
|
|
|
|
|
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"({t(sp['energie_pro_zyklus_kWh'])} 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: ~{t(sp['voller_lkw_energie_kWh'])} 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 ........... {t(q, 1):>8} 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) . {t(wp['kaelteleistung_kW'], 1):>8} kW")
|
|
L.append(f" Heizleistung (Kondensator) .. {t(wp['heizleistung_kW'], 1):>8} kW")
|
|
L.append(f" el. Leistung ................ {t(wp['elektrische_leistung_kW'], 1):>8} kW")
|
|
L.append(f" Strombedarf ................. {t(wp['strom_kWh_tag'], 1):>8} kWh/Tag")
|
|
L.append(f" Waerme in den Speicher ...... {t(wp['speicher_kWh_tag'], 1):>8} kWh/Tag")
|
|
L.append("")
|
|
L.append("PV-ANLAGE (Jahresbilanz)")
|
|
L.append(f" Jahresstrombedarf WP ........ {t(pv['jahresbedarf_kWh']):>8} kWh/a")
|
|
L.append(f" Leistung .................... {t(pv['kWp'], 1):>8} kWp")
|
|
L.append(f" Dachflaeche ................. {t(pv['flaeche_m2']):>8} m2")
|
|
L.append("")
|
|
L.append(f"NETZBEZUG (durch Wintermonate, Monatsbilanz)")
|
|
L.append(f" Netzbezug ................... {t(netz['netzbezug_kWh']):>8} kWh/a"
|
|
f" = {netz['netzbezug_anteil']*100:.0f} %")
|
|
L.append(f" Sommerueberschuss ........... {t(netz['ueberschuss_kWh']):>8} kWh/a")
|
|
L.append("")
|
|
L.append("BATTERIE (Tag-/Nacht-Ueberbbrueckung)")
|
|
L.append(f" nutzbare Kapazitaet ......... {t(bat['nutzbar_kWh'], 1):>8} kWh")
|
|
L.append(f" Nennkapazitaet .............. {t(bat['nominal_kWh'], 1):>8} kWh")
|
|
L.append("")
|
|
L.append("VERSORGTE HAUSHALTE ({} Erw. + {} Kinder)".format(
|
|
cfg["haushalt"]["erwachsene"], cfg["haushalt"]["kinder"]))
|
|
L.append(f" Warmwasser je Haushalt ...... {t(hh['ww_pro_haushalt_kWh_tag'], 1):>8} kWh/Tag")
|
|
L.append(f" -> Warmwasser ganzjaehrig ... {t(hh['haushalte_warmwasser'], 1):>8} Haushalte")
|
|
L.append(f" max. Heizbedarf Winter ...... {t(hh['heizbedarf_winter_kWh_tag'], 1):>8} kWh/Tag/HH")
|
|
L.append(f" Winterbedarf (Heiz+WW) ...... {t(hh['winter_pro_haushalt_kWh_tag'], 1):>8} kWh/Tag/HH")
|
|
L.append(f" -> im Winter ................ {t(hh['haushalte_winter'], 1):>8} Haushalte")
|
|
L.append("")
|
|
L.append(f"LATENTSPEICHER ({cfg['pcm']['name']}, Modus: {cfg['pcm']['modus']})")
|
|
L.append(f" Energiedichte ............... {t(sp['energiedichte_kWh_pro_kg']*1000, 1):>8} Wh/kg")
|
|
L.append(f" Energie je Zyklus ({z:>2} Tage) . {t(sp['energie_pro_zyklus_kWh']):>8} kWh")
|
|
L.append(f" Masse ....................... {t(sp['masse_kg']):>8} kg")
|
|
L.append(f" Volumen ..................... {t(sp['volumen_m3'], 1):>8} m3")
|
|
L.append(f" LKW-Auslastung Gewicht ...... {t(sp['auslastung_gewicht']*100):>8} %")
|
|
L.append(f" LKW-Auslastung Volumen ...... {t(sp['auslastung_volumen']*100):>8} %")
|
|
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 ........ {t(sp['voller_lkw_tausch_tage'], 1):>8} Tagen")
|
|
L.append(f" Energieinhalt voller LKW .... {t(sp['voller_lkw_energie_kWh']):>8} kWh")
|
|
bk = e.get("brennstoffkosten")
|
|
if bk and bk["vergleich"]:
|
|
L.append("")
|
|
L.append("WERT DER WAERME BEIM KAEUFER (Ersatzkosten fossil/Holz)")
|
|
L.append(f" {'Brennstoff':<10} | {'ct/kWh':>7} | {'EUR/LKW':>9} | {'EUR/Jahr':>9}")
|
|
L.append(f" {'-'*10}-+-{'-'*7}-+-{'-'*9}-+-{'-'*9}")
|
|
for v in bk["vergleich"]:
|
|
L.append(f" {v['name']:<10} | {t(v['nutzwaerme_ct_kWh'], 1):>7} | "
|
|
f"{t(v['eur_pro_lkw']):>9} | {t(v['eur_pro_jahr']):>9}")
|
|
L.append(f" Bewertet: voller LKW ~{t(bk['energie_lkw_kWh'])} kWh, "
|
|
f"Jahreserzeugung ~{t(bk['energie_jahr_kWh'])} kWh")
|
|
ak = e.get("anbieter")
|
|
if ak:
|
|
inv = cfg["anbieter"]["invest"]
|
|
ci = ak["container_invest"]
|
|
sd = ak["kosten_strom_detail"]
|
|
L.append("")
|
|
L.append("WAERME-GESTEHUNGSKOSTEN ANBIETER (linear, kein Kapitalzins)")
|
|
L.append(f" Transport ({ak['fuhren_jahr']:.0f} Fuhren/a) ........"
|
|
f" {t(ak['kosten_transport_eur']):>8} EUR/a")
|
|
L.append(f" WP-Strom ({t(sd['netz_kWh'])} kWh Netz"
|
|
f" @ {sd['netz_ct_kWh']:.0f} ct + {t(sd['pv_kWh'])} kWh PV"
|
|
f" @ {sd['pv_ct_kWh']:.0f} ct) .. {t(ak['kosten_strom_eur']):>6} EUR/a")
|
|
L.append(f" Wartung .......................... {t(ak['kosten_wartung_eur']):>8} EUR/a")
|
|
L.append(f" Kapital PV ({pv['kWp']:.1f} kWp, {inv['pv_nutzungsdauer_jahre']} J) ......"
|
|
f" {t(ak['kapital_pv_eur']):>8} EUR/a")
|
|
L.append(f" Kapital Batterie ({bat['nominal_kWh']:.0f} kWh, {inv['batterie_nutzungsdauer_jahre']} J) ."
|
|
f" {t(ak['kapital_batterie_eur']):>8} EUR/a")
|
|
L.append(f" Kapital WP ({inv['waermepumpe_nutzungsdauer_jahre']} J) ............."
|
|
f" {t(ak['kapital_waermepumpe_eur']):>8} EUR/a")
|
|
L.append(f" Kapital Speicher ({ci['anzahl_module']} Module, {inv['speicher_nutzungsdauer_jahre']} J)"
|
|
f" {t(ak['kapital_speicher_eur']):>8} EUR/a")
|
|
L.append(f" {'-'*46}")
|
|
L.append(f" Jahreskosten gesamt .............. {t(ak['jahreskosten_eur']):>8} EUR/a")
|
|
L.append(f" Jahreserzeugung .................. {t(ak['energie_jahr_kWh']):>8} kWh/a")
|
|
L.append(f" -> Mindestverkaufspreis .......... {t(ak['gestehungskosten_ct_kWh'], 1):>8} ct/kWh")
|
|
L.append(f" Speicher-Invest ({ci['anzahl_module']} Module): {t(ci['gesamt_eur'])} EUR"
|
|
f" = {t(ci['eur_pro_kwh_kapazitaet'])} EUR/kWh Kapazitaet")
|
|
L.append(f" PCM {t(ci['pcm_eur'])} | Behaelter {t(ci['behaelter_eur'])}"
|
|
f" | WT+Hydr. {t(ci['wt_hydraulik_eur'])}"
|
|
f" | Elektr. {t(ci['elektronik_eur'])} | Montage {t(ci['montage_eur'])}")
|
|
if ak["marge"]:
|
|
L.append("")
|
|
L.append("MARGE GEGEN ERSATZBRENNSTOFF")
|
|
L.append(f" {'Brennstoff':<10} | {'ct/kWh':>7} | {'Marge ct/kWh':>12} | {'Marge EUR/Jahr':>14}")
|
|
L.append(f" {'-'*10}-+-{'-'*7}-+-{'-'*12}-+-{'-'*14}")
|
|
for m in ak["marge"]:
|
|
haertester = " <== haertester Massstab" if m == ak["marge"][-1] else ""
|
|
vorzeichen_ct = "+" if m['marge_ct_kWh'] >= 0 else ""
|
|
vorzeichen_eur = "+" if m['marge_eur_jahr'] >= 0 else ""
|
|
L.append(f" {m['name']:<10} | {t(m['nutzwaerme_ct_kWh'], 1):>7} |"
|
|
f" {vorzeichen_ct}{t(m['marge_ct_kWh'], 1):>11} |"
|
|
f" {vorzeichen_eur}{t(m['marge_eur_jahr']):>13}{haertester}")
|
|
L.append("=" * 62)
|
|
return "\n".join(L)
|
|
|
|
|
|
# report_md wurde nach lib/report_md.py ausgelagert und oben importiert.
|
|
|
|
|
|
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=PROJECT_ROOT / "cfg" / "config.json",
|
|
help="Basis-Konfiguration (Standard: cfg/config.json)")
|
|
ap.add_argument("--file", type=Path, default=None,
|
|
help="Override-Datei, die cfg/config.json selektiv ueberschreibt "
|
|
"(Standard: alle *.json im in/-Ordner)")
|
|
ap.add_argument("--out", type=Path, default=None,
|
|
help="Ausgabeverzeichnis fuer die .md-Datei "
|
|
"(Standard: $MILCH_OUT oder out/)")
|
|
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")
|
|
ap.add_argument("--eh-da", action="store_true",
|
|
help="PV und Kuehlung bereits vorhanden: Kapitalkosten dafuer entfallen")
|
|
ap.add_argument("--driver", action="store_true",
|
|
help="Fahrer stellt der Landwirt selbst: Fahreranteil aus Transportkosten abziehen")
|
|
ap.add_argument("-v", "--verbose", action="store_true",
|
|
help="Ausgaben an der Konsole anzeigen")
|
|
args = ap.parse_args()
|
|
|
|
# Ausgabeverzeichnis bestimmen
|
|
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)
|
|
|
|
# In-Ordner bestimmen
|
|
in_ordner = Path(os.environ.get("MILCH_IN", PROJECT_ROOT / "in"))
|
|
|
|
# Konfiguration laden: Basis + Override
|
|
cfg = lade_config(args.config)
|
|
quelldateien = [args.config.name]
|
|
|
|
if args.file:
|
|
cfg = deep_merge(cfg, lade_config(args.file))
|
|
quelldateien.append(args.file.name)
|
|
elif in_ordner.is_dir():
|
|
for f in sorted(in_ordner.glob("*.json")):
|
|
cfg = deep_merge(cfg, lade_config(f))
|
|
quelldateien.append(f.name)
|
|
|
|
ergebnis = rechne(args.milch_l_tag, cfg, eh_da=args.eh_da, driver=args.driver)
|
|
|
|
if args.suggest_size:
|
|
text = vorschlag_report(ergebnis, cfg)
|
|
ergebnis["vorschlag"] = vorschlag(ergebnis, cfg)
|
|
else:
|
|
text = report(ergebnis, cfg)
|
|
|
|
if args.verbose:
|
|
print(text)
|
|
|
|
# Markdown-Ergebnis in out/ schreiben
|
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
md_name = f"auslegung_{args.milch_l_tag:.0f}L_{ts}.md"
|
|
md_path = out_ordner / md_name
|
|
md_path.write_text(report_md(ergebnis, cfg, quelldateien), encoding="utf-8")
|
|
if args.verbose:
|
|
print(f"\nMarkdown 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()
|