diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 0c8ebd5c..56eb0cd9 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 63e3b83d..a01b25e8 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -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( diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index e8e85b1e..81086c46 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -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: