§4 orchestrator: water_heating_from_cert + WaterHeatingResult

Chains every leaf function landed in slices 1-9 into a single call that
takes an EpcPropertyData + the few site-notes inputs that aren't on the
domain object yet (shower flow rates, has_bath, cold-water source, low-
water-use flag). Mirrors heat_transmission_from_cert's shape from §3.

WaterHeatingResult exposes the line refs (42), (43), (44)m, (45)m, (46)m,
(61)m, (62)m, (64)m, (65)m plus the annual sum of (64)m as
`output_kwh_per_yr` — that's the slot calculator.py's CalculatorInputs
expects for `hot_water_kwh_per_yr` (modulo division by water heater
efficiency, handled by the caller).

`combi_loss_monthly_kwh_override` accepts a (61)m array for PCDB-tested
boilers (Table 3b/3c) since those need r1+F1 parameters we haven't
implemented. Defaulting to Table 3a row "time-clock keep-hot" suits the
modal non-PCDB combi lodging.

Validated end-to-end against both Elmhurst non-RR fixtures:
  - 000490: cascade-default combi loss, output matches annual to 0.01 kWh
  - 000474: PCDB-derived (61)m injected, output matches to 0.01 kWh

Cylinder + solar + WWHRS/PV/FGHRS + electric-shower branches default to
zero — extension slices land them when needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 16:23:56 +00:00
parent 6a3552a50d
commit d6e2c99f5b
2 changed files with 211 additions and 1 deletions

View file

@ -30,6 +30,7 @@ from domain.sap.worksheet.water_heating import (
output_from_water_heater_monthly_kwh,
total_hot_water_monthly_l_per_day,
total_water_heating_demand_monthly_kwh,
water_heating_from_cert,
)
@ -584,6 +585,70 @@ def test_heat_gains_from_water_heating_matches_elmhurst_line_65(fixture) -> None
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
def test_water_heating_from_cert_matches_elmhurst_worksheet_000490() -> None:
"""End-to-end §4 orchestrator for the combi-time-clock-keep-hot path.
For 000490: 1 vented mixer shower at 7 L/min, bath present, mains
cold water, combi gas with time-clock keep-hot. The orchestrator
chains every line ref from (42) through (65) using only the cert
inputs + the SAP defaults from Appendix J / Table 3a / Table J1.
Asserts the worksheet's annual output (Σ (64)m) matches and the per-
line-ref intermediate dict pins the key monthly arrays.
"""
# Arrange
epc = _w000490.build_epc()
# Act
result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=(7.0,),
has_bath=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
)
# Assert — annual output equals the days-month-weighted sum of (64)m
expected_annual = sum(_w000490.LINE_64_M_OUTPUT_FROM_WH_KWH)
assert result.output_kwh_per_yr == pytest.approx(expected_annual, abs=0.01)
# Per-line-ref values for audit
for m, exp in enumerate(_w000490.LINE_44_M_DAILY_HW_USAGE_L):
assert result.daily_hot_water_l_per_day_monthly[m] == pytest.approx(exp, abs=1e-3)
for m, exp in enumerate(_w000490.LINE_62_M_TOTAL_WH_KWH):
assert result.total_demand_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
for m, exp in enumerate(_w000490.LINE_65_M_HEAT_GAINS_FROM_WH_KWH):
assert result.heat_gains_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
def test_water_heating_from_cert_accepts_combi_loss_override_for_pcdb_boilers() -> None:
"""000474's Vaillant ecoTEC pro is PCDB-tested → Table 3b combi loss
with PCDB-backed r1 + F1 params we haven't implemented yet. The
orchestrator accepts a `combi_loss_monthly_kwh` override so callers
that have the value (from PCDB or a worksheet) can inject it. With
the LINE_61_M values from the 000474 worksheet, the orchestrator
reproduces the rest of §4 end-to-end."""
# Arrange
epc = _w000474.build_epc()
# Act
result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=(7.0,),
has_bath=True,
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=_w000474.LINE_61_M_COMBI_LOSS_KWH,
)
# Assert
expected_annual = sum(_w000474.LINE_64_M_OUTPUT_FROM_WH_KWH)
assert result.output_kwh_per_yr == pytest.approx(expected_annual, abs=0.01)
for m, exp in enumerate(_w000474.LINE_62_M_TOTAL_WH_KWH):
assert result.total_demand_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
for m, exp in enumerate(_w000474.LINE_65_M_HEAT_GAINS_FROM_WH_KWH):
assert result.heat_gains_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
def test_assumed_occupancy_floor_at_n_eq_1_for_small_dwellings() -> None:
"""Appendix J piecewise definition: TFA ≤ 13.9 m² → N=1 exactly. A
tiny studio flat at the boundary is the most common trigger."""

View file

@ -23,12 +23,47 @@ Reference: SAP 10.2 specification §4 (pages 22-31) + Appendix J (pages
from __future__ import annotations
from dataclasses import dataclass
from math import exp
from typing import Final
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
_OCCUPANCY_TFA_FLOOR_M2: Final[float] = 13.9
@dataclass(frozen=True)
class WaterHeatingResult:
"""SAP 10.2 §4 worksheet outputs broken down per line ref so callers
can audit the cascade against the canonical xlsx or an Elmhurst
worksheet. Annual totals are days-weighted where appropriate.
Field-to-line-ref mapping:
(42) occupancy
(43) annual_avg_hot_water_l_per_day
(44)m daily_hot_water_l_per_day_monthly
(45)m energy_content_monthly_kwh
(46)m distribution_loss_monthly_kwh
(61)m combi_loss_monthly_kwh
(62)m total_demand_monthly_kwh
(64)m output_monthly_kwh
(65)m heat_gains_monthly_kwh
Annual sum of (64)m is exposed as `output_kwh_per_yr` for the
calculator's `hot_water_kwh_per_yr` slot.
"""
occupancy: float
annual_avg_hot_water_l_per_day: float
daily_hot_water_l_per_day_monthly: tuple[float, ...]
energy_content_monthly_kwh: tuple[float, ...]
distribution_loss_monthly_kwh: tuple[float, ...]
combi_loss_monthly_kwh: tuple[float, ...]
total_demand_monthly_kwh: tuple[float, ...]
output_monthly_kwh: tuple[float, ...]
heat_gains_monthly_kwh: tuple[float, ...]
output_kwh_per_yr: float
# Table J2 — monthly factors for hot water use (also used by Appendix J
# equation J11 for "other uses"). Symmetric about the year midpoint.
_TABLE_J2_MONTHLY_FACTORS: Final[tuple[float, ...]] = (
@ -399,3 +434,113 @@ def hot_water_other_uses_monthly_l_per_day(
if low_water_use:
annual_average *= 1.0 - _LOW_WATER_USE_REDUCTION
return tuple(annual_average * f for f in _TABLE_J2_MONTHLY_FACTORS)
def water_heating_from_cert(
*,
epc: EpcPropertyData,
mixer_shower_flow_rates_l_per_min: tuple[float, ...],
has_bath: bool,
cold_water_temps_c: tuple[float, ...],
low_water_use: bool,
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
) -> WaterHeatingResult:
"""SAP 10.2 §4 orchestrator — chain every line ref from (42) through
(65) for a combi-gas dwelling with optional PCDB-backed combi loss.
Inputs the cert / site notes contribute:
- TFA occupancy (line 42)
- bath presence bath formula branch (J6)
- shower flow rates per mixer outlet (42a)m
- cold water source (mains / header tank) Tcold table
- low-water-use target flag J7/J11 -5% reduction
`combi_loss_monthly_kwh_override` lets callers inject a (61)m array
derived from PCDB Table 3b/3c (tested boilers). When omitted the
cascade defaults to Table 3a row "Instantaneous, with keep-hot
facility controlled by time clock" — the modal lodging for non-PCDB
combis.
All remaining (47)(60), (63a-d), (64a)m branches default to zero
suits the combi-no-storage-no-solar-no-renewables population. Cylinder
+ solar + WWHRS / PV-diverter / FGHRS + electric-shower paths land
in future slices.
"""
if epc.total_floor_area_m2 is None:
raise ValueError("EpcPropertyData.total_floor_area_m2 is required for §4")
n = assumed_occupancy(epc.total_floor_area_m2)
showers = hot_water_mixer_showers_monthly_l_per_day(
n_occupants=n,
has_bath=has_bath,
mixer_shower_flow_rates_l_per_min=mixer_shower_flow_rates_l_per_min,
cold_water_temps_c=cold_water_temps_c,
)
baths = hot_water_baths_monthly_l_per_day(
n_occupants=n,
has_bath=has_bath,
has_shower=len(mixer_shower_flow_rates_l_per_min) > 0,
cold_water_temps_c=cold_water_temps_c,
low_water_use=low_water_use,
)
other = hot_water_other_uses_monthly_l_per_day(
n_occupants=n, low_water_use=low_water_use,
)
daily_total = total_hot_water_monthly_l_per_day(
showers=showers, baths=baths, other_uses=other,
)
other_avg = annual_average_hot_water_other_uses_l_per_day(
n_occupants=n, low_water_use=low_water_use,
)
annual_avg = annual_average_hot_water_l_per_day(
showers_monthly=showers,
baths_monthly=baths,
other_uses_annual_avg=other_avg,
)
energy_content = energy_content_of_hot_water_monthly_kwh(
monthly_hot_water_l_per_day=daily_total,
cold_water_temps_c=cold_water_temps_c,
)
distribution = distribution_loss_monthly_kwh(
monthly_energy_content_kwh=energy_content,
is_instantaneous_at_point_of_use=False,
)
combi = (
combi_loss_monthly_kwh_override
if combi_loss_monthly_kwh_override is not None
else combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
)
zero12 = (0.0,) * 12
total_demand = total_water_heating_demand_monthly_kwh(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=zero12,
primary_loss_monthly_kwh=zero12,
combi_loss_monthly_kwh=combi,
)
output = output_from_water_heater_monthly_kwh(
total_demand_monthly_kwh=total_demand,
wwhrs_monthly_kwh=zero12,
pv_diverter_monthly_kwh=zero12,
solar_monthly_kwh=zero12,
fghrs_monthly_kwh=zero12,
)
gains = heat_gains_from_water_heating_monthly_kwh(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=zero12,
primary_loss_monthly_kwh=zero12,
combi_loss_monthly_kwh=combi,
electric_shower_monthly_kwh=zero12,
)
return WaterHeatingResult(
occupancy=n,
annual_avg_hot_water_l_per_day=annual_avg,
daily_hot_water_l_per_day_monthly=daily_total,
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
combi_loss_monthly_kwh=combi,
total_demand_monthly_kwh=total_demand,
output_monthly_kwh=output,
heat_gains_monthly_kwh=gains,
output_kwh_per_yr=sum(output),
)