"""SAP 10.2 Table 9c step 10 — monthly space-heating requirement. Final step of the heating worksheet: the heat the heating system has to deliver to maintain the mean internal temperature, given the loss rate to the outside and the gains the dwelling has already accumulated. L_m = H × (T_i,m − T_e,m) (W) Q_heat,m = 0.024 × (L_m − η_m × G_m) × n_m (kWh) If Q_heat would be negative or below 1 kWh in any month, set it to 0 per the Table 9c clamp. Reference: SAP 10.2 specification (14-03-2025) Table 9c (page 185). """ from __future__ import annotations from dataclasses import dataclass from typing import Final from domain.sap10_calculator.worksheet.heat_transmission import _round_half_up _MIN_KWH_PER_MONTH: Final[float] = 1.0 _WH_TO_KWH_PER_DAY: Final[float] = 0.024 # 24 h / 1000 _DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) _WORKSHEET_DISPLAY_DP: Final[int] = 4 # SAP10.2 Table 9c step 10: "Include the heating requirement for each month # from October to May (disregarding June to September)." Set Q_heat to zero # in Jun..Sep regardless of computed value. Indices 5..8 inclusive (zero-based). _SUMMER_MONTH_INDICES: Final[frozenset[int]] = frozenset({5, 6, 7, 8}) def monthly_heat_requirement_kwh( *, heat_transfer_coefficient_w_per_k: float, internal_temperature_c: float, external_temperature_c: float, utilisation_factor: float, total_gains_w: float, days_in_month: int, ) -> float: """SAP 10.2 Table 9c step 10. Returns delivered kWh required for the month; clamps to 0 when below 1 kWh or negative.""" loss_rate_w = heat_transfer_coefficient_w_per_k * ( internal_temperature_c - external_temperature_c ) useful_loss_w = loss_rate_w - utilisation_factor * total_gains_w if useful_loss_w <= 0: return 0.0 q_heat = _WH_TO_KWH_PER_DAY * useful_loss_w * days_in_month if q_heat < _MIN_KWH_PER_MONTH: return 0.0 return q_heat @dataclass(frozen=True) class SpaceHeatingResult: """SAP 10.2 §8 worksheet line refs (95)..(99). Returned by `space_heating_monthly_kwh`. Downstream calculator consumes `total_space_heating_monthly_kwh` (98c) directly to drive fuel-cost + rating chains; per-line tuples are exposed for worksheet conformance + audit. Field names mirror the SAP 10.2 line refs. """ useful_gains_monthly_w: tuple[float, ...] # (95) heat_loss_rate_monthly_w: tuple[float, ...] # (97) space_heating_requirement_monthly_kwh: tuple[float, ...] # (98a) solar_space_heating_monthly_kwh: tuple[float, ...] # (98b) total_space_heating_monthly_kwh: tuple[float, ...] # (98c) space_heating_requirement_kwh_per_yr: float # Σ(98a) — FEE input total_space_heating_kwh_per_yr: float # Σ(98c) space_heating_per_m2_kwh: float # (99) def space_heating_monthly_kwh( *, monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...], monthly_internal_temperature_c: tuple[float, ...], monthly_external_temperature_c: tuple[float, ...], monthly_utilisation_factor: tuple[float, ...], monthly_total_gains_w: tuple[float, ...], total_floor_area_m2: float, ) -> SpaceHeatingResult: """SAP 10.2 §8 orchestrator — produce (95)..(99) line refs for all months. Composes the existing single-month leaf with the spec inclusion rule: Jun..Sep are zeroed (Table 9c step 10) regardless of computed value, on top of the per-month value clamp (< 1 kWh or negative). Solar space heating (98b) — Appendix H — is always 0 in this slice; no Elmhurst fixture lodges a solar space heating system. (98c) = (98a) for the current corpus. Inputs are length-12 Jan..Dec tuples. `total_floor_area_m2` only drives the (99) per-m² aggregate; everything else is per-month physics. """ useful_gains: list[float] = [] heat_loss_rate: list[float] = [] q_heat_98a: list[float] = [] q_solar_98b: list[float] = [] q_total_98c: list[float] = [] for m in range(12): h = monthly_heat_transfer_coefficient_w_per_k[m] t_i = monthly_internal_temperature_c[m] t_e = monthly_external_temperature_c[m] eta = monthly_utilisation_factor[m] gains = monthly_total_gains_w[m] useful_gains.append(eta * gains) heat_loss_rate.append(h * (t_i - t_e)) q98a = monthly_heat_requirement_kwh( heat_transfer_coefficient_w_per_k=h, internal_temperature_c=t_i, external_temperature_c=t_e, utilisation_factor=eta, total_gains_w=gains, days_in_month=_DAYS_IN_MONTH[m], ) # Spec inclusion rule: Jun..Sep do not contribute regardless of value. if m in _SUMMER_MONTH_INDICES: q98a = 0.0 q_heat_98a.append(q98a) q_solar_98b.append(0.0) q_total_98c.append(q98a) # U985 worksheet lodges (98a)_m / (98c)_m at 4 d.p. half-up and reports # the annual as the Σ of those displayed monthlies. The full-precision Σ # diverges from the lodged annual by up to ~1.4e-4 (accumulated 4-d.p. # rounding over 8 heating months) — e.g. 000490 = 0.000132. Rounding # each monthly to 4 d.p. before summing reproduces the lodged annual # exactly for all 6 fixtures. SAP10.2 Table 9c step 10 (p.184) defines # (98a)_m without an explicit annual rounding rule; this matches the # worksheet display convention. annual_98a = sum(_round_half_up(q, _WORKSHEET_DISPLAY_DP) for q in q_heat_98a) annual_98c = sum(_round_half_up(q, _WORKSHEET_DISPLAY_DP) for q in q_total_98c) per_m2_99 = annual_98c / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0 return SpaceHeatingResult( useful_gains_monthly_w=tuple(useful_gains), heat_loss_rate_monthly_w=tuple(heat_loss_rate), space_heating_requirement_monthly_kwh=tuple(q_heat_98a), solar_space_heating_monthly_kwh=tuple(q_solar_98b), total_space_heating_monthly_kwh=tuple(q_total_98c), space_heating_requirement_kwh_per_yr=annual_98a, total_space_heating_kwh_per_yr=annual_98c, space_heating_per_m2_kwh=per_m2_99, )