fix(water-heating): count electric showers in Noutlets for mixer demand (App J)

The mixer-shower hot-water demand (worksheet 42a) divided N_shower by the
count of MIXER outlets only. But SAP 10.2 Appendix J step 1a is explicit:
"Establish how many shower outlets are present in the dwelling, Noutlets
(including in the count any instantaneous electric showers)" — and the
electric-shower step (64a) uses that same Noutlets from step 1a. So a
dwelling with both a mixer and an electric shower assigned the FULL N_shower
to the mixer system AND billed the electric shower on top of it, double-
counting shower demand → over-counted main HW → under-rated the dwelling.

Fix: thread the electric-shower count into the mixer demand so the
denominator is the total outlet count (mixer + electric), iterating the
warm-water draw over the mixer outlets only (per step 1e).

shower_types=1,2 cohort: -0.37 median -> +0.28 (crossed zero); API gauge
68.4% -> 69.0% within-0.5. Golden cert 0300-2747 (1 mixer + 1 electric)
re-pinned: PE +0.93 -> -0.10, CO2 +0.25 -> +0.15 (both toward zero,
confirming the double-count). Worksheet harness 47/47, 0 divergers (the
Elmhurst fixtures have no electric showers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-13 23:31:02 +00:00
parent 4fb9b853dc
commit 5317175dd3
3 changed files with 52 additions and 8 deletions

View file

@ -132,6 +132,7 @@ def hot_water_mixer_showers_monthly_l_per_day(
has_bath: bool,
mixer_shower_flow_rates_l_per_min: tuple[float, ...],
cold_water_temps_c: tuple[float, ...],
n_electric_showers: int = 0,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (42a)m via Appendix J equations J1, J2, J3.
@ -145,14 +146,23 @@ def hot_water_mixer_showers_monthly_l_per_day(
Each outlet's warm-water draw for a month is the flow rate × 6 min ×
Table J5 fbeh. The hot fraction is (41 Tcold[m])/(52 Tcold[m]).
Per-outlet daily warm water is scaled by N_shower / N_outlets, then
summed across outlets to give (42a)m.
summed across the MIXER outlets to give (42a)m.
Instantaneous electric showers belong to worksheet (64a)m, not (42a)m
those should be excluded from `mixer_shower_flow_rates_l_per_min`.
N_outlets is the dwelling's TOTAL shower-outlet count: SAP 10.2
Appendix J step 1a is explicit that the count INCLUDES "any
instantaneous electric showers" (which then bill their own energy via
(64a)m). So `mixer_shower_flow_rates_l_per_min` lists only the mixer
outlets (iterated for the warm-water draw), but `n_electric_showers`
must be added to the denominator otherwise a dwelling with both a
mixer and an electric shower assigns the FULL N_shower to the mixer
system AND bills the electric shower on top, double-counting shower
demand (over-counts main HW under-rates the dwelling).
"""
n_outlets = len(mixer_shower_flow_rates_l_per_min)
if n_outlets == 0:
n_mixer_outlets = len(mixer_shower_flow_rates_l_per_min)
if n_mixer_outlets == 0:
return tuple(0.0 for _ in range(12))
# Appendix J step 1a: Noutlets includes instantaneous electric showers.
n_outlets = n_mixer_outlets + n_electric_showers
if has_bath:
n_shower = 0.45 * n_occupants + 0.65
else:
@ -894,6 +904,8 @@ def water_heating_from_cert(
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,
# SAP 10.2 Appendix J step 1a — Noutlets includes electric showers.
n_electric_showers=electric_shower_count if has_electric_shower else 0,
)
has_shower = (
len(mixer_shower_flow_rates_l_per_min) > 0

View file

@ -224,8 +224,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+0.9264,
expected_co2_resid_tonnes_per_yr=+0.2495,
expected_pe_resid_kwh_per_m2=-0.1033,
expected_co2_resid_tonnes_per_yr=+0.1488,
notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
"(no Table 4b code). Cert lodges open_flues_count=1 + "
@ -245,7 +245,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"CO2 / PE factors through the cert's mains-gas "
"`secondary_fuel_type` (mirroring the cost-side Slice 58 "
"fix), closing PE +8.28 → +0.93 and CO2 0.25 → +0.25 — "
"second-biggest cohort PE closure to date."
"second-biggest cohort PE closure to date. Slice S0380.xx "
"(SAP 10.2 Appendix J step 1a — Noutlets includes electric "
"showers) halved this cert's mixer-shower HW (1 mixer + 1 "
"electric → /2 not /1), removing the double-counted shower "
"demand: PE +0.93 → -0.10, CO2 +0.25 → +0.15 (both toward 0)."
),
),
_GoldenExpectation(

View file

@ -344,6 +344,34 @@ def test_hot_water_mixer_showers_two_outlets_distributes_shower_count_evenly() -
assert t == pytest.approx(s, abs=1e-9), f"month {m+1}"
def test_hot_water_mixer_showers_counts_electric_showers_in_noutlets() -> None:
# Arrange — SAP 10.2 Appendix J step 1a (PDF p.~): "Establish how many
# shower outlets are present in the dwelling, Noutlets (INCLUDING in the
# count any instantaneous electric showers)". The mixer (42a) demand
# divides N_shower by this TOTAL outlet count, so a coexisting electric
# shower reduces each mixer outlet's share. Adding one electric shower to
# a single-mixer dwelling makes Noutlets=2 → the mixer demand halves
# (vs. counting only the mixer outlet, which over-counts main HW and
# under-rates the dwelling).
mixer_only = hot_water_mixer_showers_monthly_l_per_day(
n_occupants=2.0, has_bath=True,
mixer_shower_flow_rates_l_per_min=(8.0,),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
)
# Act — same single mixer outlet, but one electric shower also present.
with_electric = hot_water_mixer_showers_monthly_l_per_day(
n_occupants=2.0, has_bath=True,
mixer_shower_flow_rates_l_per_min=(8.0,),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
n_electric_showers=1,
)
# Assert — N_shower / 2 instead of / 1 → exactly half the mixer demand.
for m, (only, both) in enumerate(zip(mixer_only, with_electric)):
assert abs(both - only / 2.0) <= 1e-9, f"month {m+1}"
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture: ModuleType) -> None:
"""SAP10.2 §4 line (44)m via Appendix J equation J13: