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,