cert_to_inputs: wire §4 worksheet orchestrator into HW kWh derivation

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 16:35:53 +00:00
parent 171cb97c6e
commit 3c2f975c6d

View file

@ -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.01.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,