|
| 1 | +from collections.abc import Callable |
| 2 | +from enum import Enum |
| 3 | +from operator import add |
| 4 | +from typing import TYPE_CHECKING |
| 5 | + |
| 6 | +from django.utils.translation import gettext_lazy as _ |
| 7 | + |
| 8 | +from geno.utils import nformat |
| 9 | + |
| 10 | +from . import NkTotalCost |
| 11 | +from .base import NkCommonCostMixin, NkCostValueType, NkMeasurementDataMixin |
| 12 | + |
| 13 | +if TYPE_CHECKING: |
| 14 | + from report.nk.contract import NkContract |
| 15 | + from report.nk.generator import NkReportGenerator |
| 16 | + from report.nk.rental_unit import NkRentalUnit |
| 17 | + |
| 18 | + |
| 19 | +class NkCostVEWACategories(Enum): |
| 20 | + HEAT_WATER = 1 # Warmwasseraufbereitung |
| 21 | + HEAT_HEATING = 2 # Heizung |
| 22 | + WATER_GENERAL = 3 # Wasser- & Abwasserkosten |
| 23 | + |
| 24 | + |
| 25 | +class NkCostVEWA(NkCommonCostMixin, NkMeasurementDataMixin, NkTotalCost): |
| 26 | + """VEWA (Verbrauchsabhängige Energie- und Wasserkostenabrechnung, vewa.ch) |
| 27 | +
|
| 28 | + Splits between base costs and usage costs (typically 30/70%). |
| 29 | +
|
| 30 | + NkCostValueTypes: |
| 31 | + - COST, USAGE, WEIGHT: Base costs (handled in NkTotalCost) |
| 32 | + - USAGE_COST, USAGE_USAGE, USAGE_WEIGHT: Usage costs |
| 33 | + """ |
| 34 | + |
| 35 | + cost_type_id = "vewa" |
| 36 | + |
| 37 | + def __init__(self, report_generator: "NkReportGenerator", cost_config: dict): |
| 38 | + super().__init__(report_generator, cost_config) |
| 39 | + self.add_value_type(NkCostValueType.USAGE_COST, "Verbrauchsabhängige Kosten", "CHF") |
| 40 | + self.add_value_type(NkCostValueType.USAGE_USAGE, "Verbrauch", "kWh") |
| 41 | + self.add_value_type(NkCostValueType.COMMON_USAGE, "Allgemeinverbrauch", "kWh") |
| 42 | + |
| 43 | + config = self.generator.config |
| 44 | + self.base_cost_factor = float(config.get(cost_config.get("base_cost_factor_key"), 0.3)) |
| 45 | + self.vewa_category = cost_config.get("vewa_category") |
| 46 | + self._validate_config() |
| 47 | + |
| 48 | + def load_building_totals(self): |
| 49 | + total_costs = self.get_total_costs() |
| 50 | + ## Split base costs and usage costs |
| 51 | + if isinstance(total_costs, list): |
| 52 | + self.total_values[NkCostValueType.COST].monthly_amounts = [ |
| 53 | + x * self.base_cost_factor for x in total_costs |
| 54 | + ] |
| 55 | + self.total_values[NkCostValueType.USAGE_COST].monthly_amounts = [ |
| 56 | + x * (1 - self.base_cost_factor) for x in total_costs |
| 57 | + ] |
| 58 | + else: |
| 59 | + self.total_values[NkCostValueType.COST].amount = total_costs * self.base_cost_factor |
| 60 | + self.total_values[ |
| 61 | + NkCostValueType.USAGE_COST |
| 62 | + ].monthly_amounts = self.get_monthly_values_by_building_usage( |
| 63 | + total_costs * (1.0 - self.base_cost_factor) |
| 64 | + ) |
| 65 | + |
| 66 | + # Building-level usage |
| 67 | + building_total = self.measurements["building"].get("verbrauch") |
| 68 | + if not building_total: |
| 69 | + building_total = self.generator.num_months * [0] |
| 70 | + elif not isinstance(building_total, list): |
| 71 | + # Distribute usage with monthly usage weights |
| 72 | + building_total = self.get_monthly_values_by_building_usage(building_total) |
| 73 | + self.total_values[NkCostValueType.USAGE_USAGE].monthly_amounts = building_total |
| 74 | + |
| 75 | + # Set common costs from virtual rental unit "allg" (Allgemeinstrom) |
| 76 | + # self.set_common_costs( |
| 77 | + # self._strom_data[NkVirtualRentalUnitId.COMMON]["chf_total"], |
| 78 | + # self._strom_data[NkVirtualRentalUnitId.COMMON]["kwh_total"], |
| 79 | + # ) |
| 80 | + |
| 81 | + def get_monthly_values_by_building_usage(self, annual_value): |
| 82 | + """Split annual value into monthly values based on monthly usage at the building level.""" |
| 83 | + monthly_weights = [0] * self.generator.num_months |
| 84 | + for ru in self.generator.rental_units: |
| 85 | + monthly_weights = list( |
| 86 | + map(add, monthly_weights, self.get_rental_unit_usage_weights(ru.id)) |
| 87 | + ) |
| 88 | + total_weight = sum(monthly_weights) |
| 89 | + monthly_values = [] |
| 90 | + for i in range(self.generator.num_months): |
| 91 | + monthly_values.append(annual_value * monthly_weights[i] / total_weight) |
| 92 | + return monthly_values |
| 93 | + |
| 94 | + def get_rental_unit_usage_weights(self, ru_id): |
| 95 | + """Use rental unit measurements as weights to disribute the building totals.""" |
| 96 | + ru = self.generator.get_rental_unit_by_id(ru_id) |
| 97 | + ru_messung = self.measurements["rental_units"].get(ru.name, {}) |
| 98 | + return ru_messung.get("verbrauch", self.generator.num_months * [0.0]) |
| 99 | + |
| 100 | + def split_costs(self): |
| 101 | + # Base costs are handled by the super class |
| 102 | + super().split_costs() |
| 103 | + # Split usage costs |
| 104 | + self._calculate_usage_weights() |
| 105 | + for kind in (NkCostValueType.USAGE_COST, NkCostValueType.USAGE_USAGE): |
| 106 | + self._split_cost(kind, NkCostValueType.USAGE_WEIGHT) |
| 107 | + # self._calculate_weights() |
| 108 | + # self._split_common_costs() |
| 109 | + # self._aggregate_monthly_amounts() |
| 110 | + |
| 111 | + def _calculate_usage_weights(self): |
| 112 | + self._calculate_weights_for_type( |
| 113 | + NkCostValueType.USAGE_WEIGHT, "get_rental_unit_usage_weights" |
| 114 | + ) |
| 115 | + |
| 116 | + def get_extra_context(self, ru: "NkRentalUnit", contract: "NkContract") -> dict: |
| 117 | + """Return Stromkosten detail variables for the ODT bill template.""" |
| 118 | + |
| 119 | + ru_data = self._strom_data.get(ru.id, self._zero_strom_data(self.generator.num_months)) |
| 120 | + d = self.get_assigned_amounts(ru_data, contract, ru) |
| 121 | + bt = self._building_totals |
| 122 | + |
| 123 | + # Common costs (Allgemeinstrom) |
| 124 | + common_cost = self._get_assigned_amount(NkCostValueType.COMMON_COST, contract, ru) |
| 125 | + common_weight = self._get_assigned_amount(NkCostValueType.COMMON_WEIGHT, contract, ru) |
| 126 | + common_total_cost = self.total_values[NkCostValueType.COMMON_COST].amount |
| 127 | + common_total_weight = self.total_values[NkCostValueType.COMMON_WEIGHT].amount |
| 128 | + |
| 129 | + def fmt(val): |
| 130 | + return nformat(val) |
| 131 | + |
| 132 | + def fmt_kwh(val): |
| 133 | + return nformat(val, 0) |
| 134 | + |
| 135 | + def rate(chf, kwh): |
| 136 | + return nformat(chf / kwh if kwh else 0, 4) |
| 137 | + |
| 138 | + # Building totals (formatted) |
| 139 | + ctx = { |
| 140 | + # Eigenverbrauch Solar direkt (from roof) |
| 141 | + "ssd_chft": fmt(bt["ssd"]["chf"]), |
| 142 | + "ssdt": fmt_kwh(bt["ssd"]["kwh"]), |
| 143 | + "ssd_eh": rate(bt["ssd"]["chf"], bt["ssd"]["kwh"]), |
| 144 | + "ssd": fmt_kwh(d["kwh_solar"]), |
| 145 | + "ssd_chf": fmt(d["chf_solar_eigen"]), |
| 146 | + # Eigenverbrauch Solar via Speicher/Stromallmend |
| 147 | + "sss_chft": fmt(bt["sss"]["chf"]), |
| 148 | + "ssst": fmt_kwh(bt["sss"]["kwh"]), |
| 149 | + "sss_eh": rate(bt["sss"]["chf"], bt["sss"]["kwh"]), |
| 150 | + "sss": fmt_kwh(d["kwh_solar_speicher"]), |
| 151 | + "sss_chf": fmt(d["chf_solar_speicher"]), |
| 152 | + # Netzstrombezug Hochtarif |
| 153 | + "snh_chft": fmt(bt["snh"]["chf"]), |
| 154 | + "snht": fmt_kwh(bt["snh"]["kwh"]), |
| 155 | + "snh_eh": rate(bt["snh"]["chf"], bt["snh"]["kwh"]), |
| 156 | + "snh": fmt_kwh(d["kwh_netz_hoch"]), |
| 157 | + "snh_chf": fmt(d["chf_netz_hoch"]), |
| 158 | + # Netzstrombezug Niedertarif |
| 159 | + "snt_chft": fmt(bt["snt"]["chf"]), |
| 160 | + "sntt": fmt_kwh(bt["snt"]["kwh"]), |
| 161 | + "snt_eh": rate(bt["snt"]["chf"], bt["snt"]["kwh"]), |
| 162 | + "snt": fmt_kwh(d["kwh_netz_nieder"]), |
| 163 | + "snt_chf": fmt(d["chf_netz_nieder"]), |
| 164 | + # Herkunftsnachweise (HKN) |
| 165 | + "shk_chft": fmt(bt["shk"]["chf"]), |
| 166 | + "shkt": fmt_kwh(bt["shk"]["kwh"]), |
| 167 | + "shk_eh": rate(bt["shk"]["chf"], bt["shk"]["kwh"]), |
| 168 | + "shk": fmt_kwh(d["kwh_solar_einkauf"]), |
| 169 | + "shk_chf": fmt(d["chf_solar_hkn"]), |
| 170 | + # Korrektur |
| 171 | + "sk_chft": fmt(bt["sk"]["chf"]), |
| 172 | + "skt": fmt_kwh(bt["sk"]["kwh"]), |
| 173 | + "sk_eh": rate(bt["sk"]["chf"], bt["sk"]["kwh"]), |
| 174 | + "sk": fmt_kwh(d["kwh_korrektur"]), |
| 175 | + "sk_chf": fmt(d["chf_korrektur"]), |
| 176 | + # Strom subtotal ( of above, no separate Allgemeinstrom/fees in this class) |
| 177 | + "st_chft": fmt(bt["total"]["chf"]), |
| 178 | + "stt": fmt_kwh(bt["total"]["kwh"]), |
| 179 | + "st": fmt_kwh(d["kwh_total"]), |
| 180 | + "st_chf": fmt(d["chf_total"]), |
| 181 | + # Anteil Allgemeinstrom (not computed by this class – leave empty) |
| 182 | + "sa_chft": fmt(common_total_cost), |
| 183 | + "sat": nformat(common_total_weight, 0), |
| 184 | + "sa_eh": nformat( |
| 185 | + common_total_cost / common_total_weight if common_total_weight else 0, 2 |
| 186 | + ), |
| 187 | + "sa": nformat(common_weight, 1), |
| 188 | + "sa_chf": fmt(common_cost), |
| 189 | + # Stromnebenkosten/Messung (not computed by this class – leave empty) |
| 190 | + "snk_chft": "", |
| 191 | + "snkt": "", |
| 192 | + "snk_eh": "", |
| 193 | + "snk": "", |
| 194 | + "snk_chf": "", |
| 195 | + # Grand total, building totals already include common costs |
| 196 | + "stot_chft": fmt(bt["total"]["chf"]), |
| 197 | + "stot_chf": fmt(d["chf_total"] + common_cost), |
| 198 | + } |
| 199 | + return ctx |
| 200 | + |
| 201 | + def get_export_extra_info( |
| 202 | + self, include_percent: bool = False, formatter: Callable = lambda x: x |
| 203 | + ) -> list: |
| 204 | + lines = [] |
| 205 | + for key in ( |
| 206 | + "kwh_solar", # "messung_strom_solar", |
| 207 | + "kwh_solar_speicher", |
| 208 | + "kwh_solar_einkauf", |
| 209 | + "kwh_netzstrom", |
| 210 | + "kwh_netz_hoch", |
| 211 | + "kwh_netz_nieder", |
| 212 | + "kwh_korrektur", |
| 213 | + "kwh_total", |
| 214 | + "chf_solar_eigen", |
| 215 | + "chf_solar_speicher", # "chf_solar_einspeise", |
| 216 | + "chf_solar_hkn", |
| 217 | + "chf_netz_hoch", # "messung_chf_netz_hoch", |
| 218 | + "chf_netz_nieder", # "messung_chf_netz_nieder", |
| 219 | + "chf_korrektur", |
| 220 | + "chf_total", |
| 221 | + ): |
| 222 | + obj_data = [] |
| 223 | + total = 0 |
| 224 | + for ru in self.generator.rental_units: |
| 225 | + d = self._strom_data.get(ru.id, self._zero_strom_data(self.generator.num_months)) |
| 226 | + value = d.get(key) |
| 227 | + if value: |
| 228 | + annual_value = sum(value) |
| 229 | + total += annual_value |
| 230 | + obj_data.append(formatter(annual_value)) |
| 231 | + else: |
| 232 | + obj_data.append("") |
| 233 | + if include_percent: |
| 234 | + obj_data.append("") |
| 235 | + |
| 236 | + row = [key, formatter(total)] |
| 237 | + for _s in self.generator.sections: |
| 238 | + row.append("") |
| 239 | + if include_percent: |
| 240 | + row.append("") |
| 241 | + row += obj_data |
| 242 | + lines.append(row) |
| 243 | + return lines |
| 244 | + |
| 245 | + @staticmethod |
| 246 | + def _zero_data(num_months: int) -> dict: |
| 247 | + zeros = num_months * [0.0] |
| 248 | + return { |
| 249 | + "verbrauch": list(zeros), |
| 250 | + } |
| 251 | + |
| 252 | + def _validate_config(self): |
| 253 | + if self.base_cost_factor < 0.0 or self.base_cost_factor > 1.0: |
| 254 | + raise ValueError( |
| 255 | + _("The base cost factor is not in the range 0.0 - 1.0: {factor}").format( |
| 256 | + tarif=self.base_cost_factor |
| 257 | + ) |
| 258 | + ) |
| 259 | + if not isinstance(self.vewa_category, NkCostVEWACategories): |
| 260 | + raise ValueError( |
| 261 | + _("Invalid VEWA category: {category}").format(category=self.vewa_category) |
| 262 | + ) |
0 commit comments