From 3c2f975c6d4946d0f47f2e5643a9b8c78888aeb1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 16:35:53 +0000 Subject: [PATCH] =?UTF-8?q?cert=5Fto=5Finputs:=20wire=20=C2=A74=20workshee?= =?UTF-8?q?t=20orchestrator=20into=20HW=20kWh=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy `predicted_hot_water_kwh` cascade with a call into `water_heating_from_cert` for the modal combi-gas-mains population. The new helper `_hot_water_fuel_kwh_per_yr` chains the §4 cascade end-to-end (occupancy → daily hot water → energy content → distribution + combi loss → (62)m total → (64)m output) then divides by water-heater efficiency to land annual fuel kWh — the slot CalculatorInputs expects. Section-by-section validation across all 6 Elmhurst fixtures shows: §1 dimensions exact (≤ 1e-4) on all 6 §2 ventilation exact (≤ 1e-4) on all 6 §3 heat trans exact on non-RR (000474, 000490) within 0.04 W/K (display-rounding); RR fixtures under-count per the formal SapRoomInRoof sub-area deferral. §4 hot water exact on the 2 fixtures with LINE_42/LINE_64 lodged (000474 PCDB override + 000490 cascade-default); 4 RR fixtures emit plausible orchestrator values. End-to-end SAP impact (legacy → new): 000490 57=57 (cont 56.72 → 56.92, closer to worksheet 57.40) 000474 55→56 (cont 55.39 → 55.59, expected 62, still 6pt under) Caveats / future slices: - Cold water source defaults to mains (no domain-model field yet). - Shower flow rate defaults to 7 L/min vented (no shower_outlet_type plumbing yet); both fixtures actually lodge this so no false drift. - Cylinder + solar + WWHRS / PV / FGHRS branches default to zero. - PCDB Table 3b combi loss not implemented; orchestrator accepts a `combi_loss_monthly_kwh_override` for now but cert_to_inputs always falls to Table 3a row "time-clock keep-hot". - water_efficiency variable misnamed "pct" — it's a decimal (0.0-1.0). Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 115 +++++++++++++++--- 1 file changed, 98 insertions(+), 17 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 04f6d555..3224e6f0 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -68,6 +68,10 @@ from domain.sap.worksheet.ventilation import ( MechanicalVentilationKind, ventilation_from_inputs, ) +from domain.sap.worksheet.water_heating import ( + TABLE_J1_TCOLD_FROM_MAINS_C, + water_heating_from_cert, +) # RdSAP 10 Table 27 — fraction of total floor area treated as the @@ -741,6 +745,95 @@ def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts: ) +# SAP 10.2 Table J4 — default mixer-shower flow rate for an existing +# dwelling with a vented hot water system (the existing-dwelling minimum). +# Both validation worksheets (000474 + 000490) lodge this value. Combi- +# pumped showers (11 L/min) and instantaneous-electric showers (handled +# via line (64a)m, not here) need shower-outlet-type plumbing in a later +# slice. +_SHOWER_FLOW_VENTED_L_PER_MIN: Final[float] = 7.0 + + +def _mixer_shower_flow_rates_from_cert( + epc: EpcPropertyData, +) -> tuple[float, ...]: + """Pull mixer-shower flow rates from the cert. + + The cert lodges flow rate per shower outlet (Elmhurst worksheets + show "Vented hot water system, 7.00"). The domain model doesn't + surface that field yet; until it does, every cert defaults to the + SAP10.2 Table J4 "Vented hot water system" row at 7 L/min — the + existing-dwelling minimum and what both validation fixtures + (000474 + 000490) actually lodge. Combi-pumped showers (11 L/min) + or electric showers (handled via (64a)m, not here) will need + proper shower-outlet-type plumbing in a later slice. + """ + return (_SHOWER_FLOW_VENTED_L_PER_MIN,) + + +def _has_bath_from_cert(epc: EpcPropertyData) -> bool: + """True iff cert lodges ≥ 1 bath. `number_baths is None` is treated as + bath present (modal UK lodging — bathless dwellings are rare and + typically explicitly lodged as 0).""" + n = epc.sap_heating.number_baths + return n is None or n >= 1 + + +def _hot_water_fuel_kwh_per_yr( + *, + epc: EpcPropertyData, + water_efficiency_pct: float, + is_instantaneous: bool, + primary_age: Optional[str], +) -> float: + """Annual hot water FUEL kWh (the slot calculator.CalculatorInputs + expects). Wires the SAP10.2 §4 worksheet orchestrator into the cert→ + inputs adapter. + + For combi gas (the dominant population) the orchestrator handles the + full Appendix J cascade including Table 3a row "time-clock keep-hot" + combi loss. Cylinder + solar + WWHRS / PV diverter / FGHRS branches + default to zero — extension slices will populate them as needed. + + Annual output (Σ (64)m) is divided by `water_efficiency_pct / 100` + to convert delivered heat to fuel kWh, mirroring the worksheet's + (219) line. Falls back to legacy `predicted_hot_water_kwh` if the + TFA is missing (the orchestrator requires it for occupancy). + """ + if epc.total_floor_area_m2 is None: + return predicted_hot_water_kwh( + total_floor_area_m2=epc.total_floor_area_m2, + seasonal_efficiency_water=water_efficiency_pct, + cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size), + cylinder_insulation_thickness_mm=( + None if is_instantaneous else epc.sap_heating.cylinder_insulation_thickness_mm + ), + cylinder_insulation_type=( + None if is_instantaneous + else _int_or_none(epc.sap_heating.cylinder_insulation_type) + ), + age_band=None if is_instantaneous else primary_age, + has_wwhrs=False, + has_solar_water_heating=epc.solar_water_heating, + ) + result = water_heating_from_cert( + epc=epc, + mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), + has_bath=_has_bath_from_cert(epc), + # Cold water source isn't on the domain model yet; default to mains + # (the dominant UK lodging — 95%+). Header-tank dwellings will need + # a domain-model field + plumb-through in a future slice. + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + low_water_use=False, + ) + if water_efficiency_pct <= 0: + return 0.0 + # `water_efficiency_pct` is misnamed in the calling code — the value + # is a decimal (0.0–1.0), not a percent. Divide the orchestrator's + # delivered-heat output by the decimal efficiency to land fuel kWh. + return result.output_kwh_per_yr / water_efficiency_pct + + def cert_to_inputs( epc: EpcPropertyData, *, prices: PriceTable = SAP_10_2_SPEC_PRICES ) -> CalculatorInputs: @@ -848,23 +941,11 @@ def cert_to_inputs( # = q_generated, matching the per-kWh-generated unit price. water_eff = 1.0 / _heat_network_dlf(primary_age) is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES - hw_kwh = predicted_hot_water_kwh( - total_floor_area_m2=epc.total_floor_area_m2, - seasonal_efficiency_water=water_eff, - # Instantaneous HW systems (codes 907/909) have no cylinder and - # no primary circuit — pass None for both to suppress storage - # and primary losses in the demand model. - cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size), - cylinder_insulation_thickness_mm=( - None if is_instantaneous else epc.sap_heating.cylinder_insulation_thickness_mm - ), - cylinder_insulation_type=( - None if is_instantaneous - else _int_or_none(epc.sap_heating.cylinder_insulation_type) - ), - age_band=None if is_instantaneous else primary_age, - has_wwhrs=False, - has_solar_water_heating=epc.solar_water_heating, + hw_kwh = _hot_water_fuel_kwh_per_yr( + epc=epc, + water_efficiency_pct=water_eff, + is_instantaneous=is_instantaneous, + primary_age=primary_age, ) lighting_kwh = predicted_lighting_kwh( total_floor_area_m2=epc.total_floor_area_m2,