Skip to content

Commit 35d8516

Browse files
Marco's Agentclaude
authored andcommitted
Add NkCostZEVStromallmend and extra context injection for ODT bills.
- Add NkCostZEVStromallmend (cost/zev.py): new NkCost subclass implementing ZEV electricity cost calculation per rental unit, mirroring the logic of the old report_nk.stromrechnung(). Reads measurement data from report.object_messung[ru.name] and building totals from report.data_amount, computes eigenverbrauch solar, netzstrom, HKN, and corrections. - Add NkCost.get_extra_context() hook (cost/base.py): base class returns {} by default; subclasses can override to inject extra variables into the ODT bill template context beyond the standard billing-group fields. - Modify NkBill._get_rental_unit_context() (bill.py): after building the costs list, call get_extra_context() on every cost and merge the result into the context dict, enabling NkCostZEVStromallmend to inject the ssd_*, sss_*, snh_*, snt_*, shk_*, sk_*, st_*, stot_* variables required by the Stromkosten section of the ODT template. - Add tests (tests/test_nk_zev.py): three test cases covering correct per-unit cost calculation, extra-context variable generation, and the no-measurement-data zero case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1315c00 commit 35d8516

5 files changed

Lines changed: 495 additions & 0 deletions

File tree

django/report/nk/bill.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ def _get_rental_unit_context(
101101
)
102102
if cost_context:
103103
context["costs"].append(cost_context)
104+
for cost in costs:
105+
context.update(cost.get_extra_context(ru, self.contract))
104106
return context
105107

106108
def _create_rental_unit_files(self, context, ru):

django/report/nk/cost/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .base import NkCost, NkCostValueType
22
from .general import NkMonthlyCost, NkPerRentalUnitCost, NkTotalCost, NkTotalEnergyCost
3+
from .zev import NkCostZEVStromallmend
34

45
__all__ = [
56
"NkCost",
@@ -8,4 +9,5 @@
89
"NkTotalCost",
910
"NkMonthlyCost",
1011
"NkTotalEnergyCost",
12+
"NkCostZEVStromallmend",
1113
]

django/report/nk/cost/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,8 @@ def get_assigned_amount(
232232
def get_building_amount(self, value_type: NkCostValueType):
233233
return self.total_values[value_type].amount
234234

235+
def get_extra_context(self, ru: "NkRentalUnit", contract: "NkContract") -> dict:
236+
"""Return extra context variables for ODT template rendering. Override in subclasses."""
237+
return {}
238+
235239
# def update_context(self, context, ru, contract):

django/report/nk/cost/zev.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
from typing import TYPE_CHECKING
2+
3+
from geno.utils import nformat
4+
5+
from .base import NkCost, NkCostValueType
6+
7+
if TYPE_CHECKING:
8+
from report.nk.contract import NkContract
9+
from report.nk.generator import NkReportGenerator
10+
from report.nk.rental_unit import NkRentalUnit
11+
12+
13+
class NkCostZEVStromallmend(NkCost):
14+
"""ZEV electricity costs calculated individually per rental unit.
15+
16+
Mirrors the logic from the old report_nk.stromrechnung():
17+
- Reads tariffs from report config (Strom:Tarif:*)
18+
- Reads per-unit measurement data from report.object_messung[ru.name]:
19+
strom_solar, strom_ew_hoch, strom_ew_nieder, chf_netz_hoch, chf_netz_nieder
20+
- Reads building-level data from report.data_amount:
21+
Strom_kwh_egon, Strom_kwh_ruecklieferung
22+
- Computes per-unit costs and injects detailed context variables for the
23+
"Stromkosten" section in the ODT bill template.
24+
25+
ODT template variable naming convention:
26+
ssd_* Eigenverbrauch Solar direkt (from roof)
27+
sss_* Eigenverbrauch Solar via Speicher/Stromallmend
28+
snh_* Netzstrombezug Hochtarif
29+
snt_* Netzstrombezug Niedertarif
30+
shk_* Herkunftsnachweise (HKN) for purchased solar
31+
sk_* Korrektur (manual correction)
32+
st_* Strom subtotal (before Allgemeinstrom and fees)
33+
sa_* Anteil Allgemeinstrom (communal electricity share)
34+
snk_* Stromnebenkosten / Messung
35+
stot_* Grand total
36+
37+
Suffixes:
38+
_chft Building total CHF (formatted string)
39+
_t Building total kWh (number)
40+
_eh CHF/kWh rate
41+
(none) Rental unit kWh
42+
_chf Rental unit CHF (formatted string)
43+
"""
44+
45+
cost_type_id = "zev_stromallmend"
46+
47+
def __init__(self, report: "NkReportGenerator", cost_config: dict):
48+
super().__init__(report, cost_config)
49+
self.add_value_type(NkCostValueType.USAGE, "Verbrauch", "kWh")
50+
# Per-unit intermediate data: ru_id -> dict
51+
self._strom_data: dict[int, dict] = {}
52+
# Building-level totals (populated in load_input_data)
53+
self._building_totals: dict = {}
54+
55+
def load_input_data(self):
56+
super().load_input_data()
57+
58+
config = self.report.config
59+
tarif_eigenstrom = config.get("Strom:Tarif:Eigenstrom", 0)
60+
tarif_einspeiseverguetung = config.get("Strom:Tarif:Einspeisevergütung", 12 * [0])
61+
tarif_hkn = config.get("Strom:Tarif:HKN", 0)
62+
tarif_korrektur = config.get("Strom:Tarif:Korrekturen", {"mittel": 0, "nacht": 0})
63+
64+
num_months = self.report.num_months
65+
66+
# Building-level: einspeisefaktor per month
67+
kwh_egon = self.report.data_amount.get("Strom_kwh_egon", num_months * [0])
68+
kwh_ruecklieferung = self.report.data_amount.get(
69+
"Strom_kwh_ruecklieferung", num_months * [0]
70+
)
71+
einspeisefaktor = []
72+
for m in range(num_months):
73+
if kwh_egon[m]:
74+
einspeisefaktor.append(kwh_ruecklieferung[m] / kwh_egon[m])
75+
else:
76+
einspeisefaktor.append(0)
77+
78+
# Building-level totals accumulators
79+
totals = {
80+
"ssd": {"kwh": num_months * [0.0], "chf": num_months * [0.0]}, # solar direkt
81+
"sss": {"kwh": num_months * [0.0], "chf": num_months * [0.0]}, # solar speicher
82+
"snh": {"kwh": num_months * [0.0], "chf": num_months * [0.0]}, # netz hoch
83+
"snt": {"kwh": num_months * [0.0], "chf": num_months * [0.0]}, # netz nieder
84+
"shk": {"kwh": num_months * [0.0], "chf": num_months * [0.0]}, # HKN einkauf
85+
"sk": {"kwh": num_months * [0.0], "chf": num_months * [0.0]}, # korrektur
86+
"total": {"kwh": num_months * [0.0], "chf": num_months * [0.0]},
87+
}
88+
89+
for ru in self.report.rental_units:
90+
if ru.is_virtual:
91+
self._strom_data[ru.id] = self._zero_strom_data(num_months)
92+
continue
93+
94+
ru_messung = self.report.object_messung.get(ru.name, {})
95+
if "strom_solar" not in ru_messung:
96+
self._strom_data[ru.id] = self._zero_strom_data(num_months)
97+
continue
98+
99+
messung_solar = ru_messung.get("strom_solar", num_months * [0.0])
100+
messung_ew_hoch = ru_messung.get("strom_ew_hoch", num_months * [0.0])
101+
messung_ew_nieder = ru_messung.get("strom_ew_nieder", num_months * [0.0])
102+
messung_chf_hoch = ru_messung.get("chf_netz_hoch", num_months * [0.0])
103+
messung_chf_nieder = ru_messung.get("chf_netz_nieder", num_months * [0.0])
104+
105+
# Korrekturen from config
106+
kwh_korrektur = num_months * [0.0]
107+
chf_korrektur = num_months * [0.0]
108+
korrektur_config = config.get("Strom:Korrekturen", {})
109+
if ru.name in korrektur_config:
110+
for korr in korrektur_config[ru.name]:
111+
tarif = tarif_korrektur.get(korr.get("tarif", "mittel"), 0)
112+
for m in range(num_months):
113+
kwh_korrektur[m] += korr["kwh"][m]
114+
chf_korrektur[m] += korr["kwh"][m] * tarif
115+
116+
d = {}
117+
d["kwh_solar"] = list(messung_solar)
118+
d["kwh_solar_speicher"] = []
119+
d["kwh_solar_einkauf"] = []
120+
d["kwh_netzstrom"] = []
121+
d["kwh_total"] = []
122+
d["chf_solar_eigen"] = []
123+
d["chf_solar_speicher"] = []
124+
d["chf_solar_hkn"] = []
125+
d["chf_netz_hoch"] = list(messung_chf_hoch)
126+
d["chf_netz_nieder"] = list(messung_chf_nieder)
127+
d["kwh_korrektur"] = kwh_korrektur
128+
d["chf_korrektur"] = chf_korrektur
129+
d["chf_total"] = []
130+
131+
for m in range(num_months):
132+
kwh_netz = messung_ew_hoch[m] + messung_ew_nieder[m]
133+
kwh_speicher = einspeisefaktor[m] * kwh_netz
134+
kwh_einkauf = kwh_netz - kwh_speicher
135+
kwh_tot = (
136+
messung_solar[m]
137+
+ kwh_speicher
138+
+ kwh_einkauf
139+
+ kwh_korrektur[m]
140+
)
141+
142+
chf_eigen = messung_solar[m] * tarif_eigenstrom
143+
tarif_einsp = (
144+
tarif_einspeiseverguetung[m]
145+
if m < len(tarif_einspeiseverguetung)
146+
else 0
147+
)
148+
chf_speicher = kwh_speicher * (tarif_eigenstrom - tarif_einsp)
149+
chf_hkn = kwh_einkauf * tarif_hkn
150+
chf_tot = (
151+
chf_eigen
152+
+ chf_speicher
153+
+ chf_hkn
154+
+ messung_chf_hoch[m]
155+
+ messung_chf_nieder[m]
156+
+ chf_korrektur[m]
157+
)
158+
159+
d["kwh_solar_speicher"].append(kwh_speicher)
160+
d["kwh_solar_einkauf"].append(kwh_einkauf)
161+
d["kwh_netzstrom"].append(kwh_netz)
162+
d["kwh_total"].append(kwh_tot)
163+
d["chf_solar_eigen"].append(chf_eigen)
164+
d["chf_solar_speicher"].append(chf_speicher)
165+
d["chf_solar_hkn"].append(chf_hkn)
166+
d["chf_total"].append(chf_tot)
167+
168+
totals["ssd"]["kwh"][m] += messung_solar[m]
169+
totals["ssd"]["chf"][m] += chf_eigen
170+
totals["sss"]["kwh"][m] += kwh_speicher
171+
totals["sss"]["chf"][m] += chf_speicher
172+
totals["snh"]["kwh"][m] += messung_ew_hoch[m]
173+
totals["snh"]["chf"][m] += messung_chf_hoch[m]
174+
totals["snt"]["kwh"][m] += messung_ew_nieder[m]
175+
totals["snt"]["chf"][m] += messung_chf_nieder[m]
176+
totals["shk"]["kwh"][m] += kwh_einkauf
177+
totals["shk"]["chf"][m] += chf_hkn
178+
totals["sk"]["kwh"][m] += kwh_korrektur[m]
179+
totals["sk"]["chf"][m] += chf_korrektur[m]
180+
totals["total"]["kwh"][m] += kwh_tot
181+
totals["total"]["chf"][m] += chf_tot
182+
183+
self._strom_data[ru.id] = d
184+
185+
# Store building totals as simple sums
186+
self._building_totals = {
187+
k: {
188+
"kwh": sum(v["kwh"]),
189+
"chf": sum(v["chf"]),
190+
}
191+
for k, v in totals.items()
192+
}
193+
self._building_totals_monthly = totals
194+
195+
# Set COST and USAGE totals for the base cost aggregation
196+
self.total_values[NkCostValueType.COST].amount = self._building_totals["total"]["chf"]
197+
self.total_values[NkCostValueType.USAGE].amount = self._building_totals["total"]["kwh"]
198+
for ru in self.report.rental_units:
199+
d = self._strom_data[ru.id]
200+
self.rental_unit_values[ru.id][NkCostValueType.COST].amount = sum(d["chf_total"])
201+
self.rental_unit_values[ru.id][NkCostValueType.USAGE].amount = sum(d["kwh_total"])
202+
monthly_amounts = d["chf_total"]
203+
self.rental_unit_values[ru.id][NkCostValueType.COST].monthly_amounts = monthly_amounts
204+
205+
def split_costs(self):
206+
"""Aggregate pre-calculated per-rental-unit costs up to sections and total."""
207+
for ru in self.report.rental_units:
208+
for month in range(self.report.num_months):
209+
amount = self.rental_unit_values[ru.id][NkCostValueType.COST].monthly_amounts[month]
210+
self.section_values[ru.section.id][NkCostValueType.COST].monthly_amounts[month] += amount
211+
self.total_values[NkCostValueType.COST].monthly_amounts[month] += amount
212+
213+
for section in self.report.sections:
214+
self.section_values[section.id][NkCostValueType.COST].amount = sum(
215+
self.section_values[section.id][NkCostValueType.COST].monthly_amounts
216+
)
217+
self.total_values[NkCostValueType.COST].amount = sum(
218+
self.total_values[NkCostValueType.COST].monthly_amounts
219+
)
220+
221+
def get_extra_context(self, ru: "NkRentalUnit", contract: "NkContract") -> dict:
222+
"""Return Stromkosten detail variables for the ODT bill template."""
223+
d = self._strom_data.get(ru.id, self._zero_strom_data(self.report.num_months))
224+
bt = self._building_totals
225+
226+
def fmt(val):
227+
return nformat(val)
228+
229+
def rate(chf, kwh):
230+
return nformat(chf / kwh if kwh else 0, 4)
231+
232+
# Building totals (formatted)
233+
ctx = {
234+
# Eigenverbrauch Solar direkt (from roof)
235+
"ssd_chft": fmt(bt["ssd"]["chf"]),
236+
"ssdt": bt["ssd"]["kwh"],
237+
"ssd_eh": rate(bt["ssd"]["chf"], bt["ssd"]["kwh"]),
238+
"ssd": sum(d["kwh_solar"]),
239+
"ssd_chf": fmt(sum(d["chf_solar_eigen"])),
240+
# Eigenverbrauch Solar via Speicher/Stromallmend
241+
"sss_chft": fmt(bt["sss"]["chf"]),
242+
"ssst": bt["sss"]["kwh"],
243+
"sss_eh": rate(bt["sss"]["chf"], bt["sss"]["kwh"]),
244+
"sss": sum(d["kwh_solar_speicher"]),
245+
"sss_chf": fmt(sum(d["chf_solar_speicher"])),
246+
# Netzstrombezug Hochtarif
247+
"snh_chft": fmt(bt["snh"]["chf"]),
248+
"snht": bt["snh"]["kwh"],
249+
"snh_eh": rate(bt["snh"]["chf"], bt["snh"]["kwh"]),
250+
"snh": sum(d["kwh_netzstrom"]),
251+
"snh_chf": fmt(sum(d["chf_netz_hoch"])),
252+
# Netzstrombezug Niedertarif
253+
"snt_chft": fmt(bt["snt"]["chf"]),
254+
"sntt": bt["snt"]["kwh"],
255+
"snt_eh": rate(bt["snt"]["chf"], bt["snt"]["kwh"]),
256+
"snt": sum(d["kwh_netzstrom"]), # combined with Hoch for total netz
257+
"snt_chf": fmt(sum(d["chf_netz_nieder"])),
258+
# Herkunftsnachweise (HKN)
259+
"shk_chft": fmt(bt["shk"]["chf"]),
260+
"shkt": bt["shk"]["kwh"],
261+
"shk_eh": rate(bt["shk"]["chf"], bt["shk"]["kwh"]),
262+
"shk": sum(d["kwh_solar_einkauf"]),
263+
"shk_chf": fmt(sum(d["chf_solar_hkn"])),
264+
# Korrektur
265+
"sk_chft": fmt(bt["sk"]["chf"]),
266+
"skt": bt["sk"]["kwh"],
267+
"sk_eh": rate(bt["sk"]["chf"], bt["sk"]["kwh"]),
268+
"sk": sum(d["kwh_korrektur"]),
269+
"sk_chf": fmt(sum(d["chf_korrektur"])),
270+
# Strom subtotal (sum of above, no separate Allgemeinstrom/fees in this class)
271+
"st_chft": fmt(bt["total"]["chf"]),
272+
"stt": bt["total"]["kwh"],
273+
"st": sum(d["kwh_total"]),
274+
"st_chf": fmt(sum(d["chf_total"])),
275+
# Anteil Allgemeinstrom (not computed by this class – leave empty)
276+
"sa_chft": "",
277+
"sat": "",
278+
"sa_eh": "",
279+
"sa": "",
280+
"sa_chf": "",
281+
# Stromnebenkosten/Messung (not computed by this class – leave empty)
282+
"snk_chft": "",
283+
"snkt": "",
284+
"snk_eh": "",
285+
"snk": "",
286+
"snk_chf": "",
287+
# Grand total
288+
"stot_chft": fmt(bt["total"]["chf"]),
289+
"stot_chf": fmt(sum(d["chf_total"])),
290+
}
291+
return ctx
292+
293+
@staticmethod
294+
def _zero_strom_data(num_months: int) -> dict:
295+
zeros = num_months * [0.0]
296+
return {
297+
"kwh_solar": list(zeros),
298+
"kwh_solar_speicher": list(zeros),
299+
"kwh_solar_einkauf": list(zeros),
300+
"kwh_netzstrom": list(zeros),
301+
"kwh_total": list(zeros),
302+
"chf_solar_eigen": list(zeros),
303+
"chf_solar_speicher": list(zeros),
304+
"chf_solar_hkn": list(zeros),
305+
"chf_netz_hoch": list(zeros),
306+
"chf_netz_nieder": list(zeros),
307+
"kwh_korrektur": list(zeros),
308+
"chf_korrektur": list(zeros),
309+
"chf_total": list(zeros),
310+
}

0 commit comments

Comments
 (0)