From e440e2df2eee21bcf933621f3c46fe2d309844b7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:02:56 +0000 Subject: [PATCH] S0380.205: SAP 10.2 p.186 two-systems-different-parts MIT (weighted R + elsewhere blend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two main heating systems heat different parts of a dwelling, SAP 10.2 §7 (PDF p.186) adapts the mean-internal-temperature calculation: - Table 9b weighted responsiveness: R = (1−(203))·R_sys1 + (203)·R_sys2. - Rest-of-dwelling temperature (90)m = weighted average of T2 computed under EACH system's control schedule, weights (203)/[1−(91)] for sys2 and [1−(203)−(91)]/[1−(91)] for sys1 (or sys2's control alone when (203) ≥ 1−(91)). The cascade used Main 1's control + R=1.0 for the whole dwelling, over-stating MIT by +0.037 °C on simulated case 6 (Main 1 radiators/2106 type 2 living + Main 2 underfloor/2110 type 3 elsewhere, R 1.0/0.75). That inflated (97) heat loss by ~11 W → demand +61 kWh/yr. `mean_internal_temperature_monthly` gains `main_2_control_type`, `main_2_fraction`, `main_2_responsiveness`; cert_to_inputs derives them from the second main detail (gated on main_heating_fraction > 0, so single-main / DHW-only second mains pass the defaults → unchanged). Case 6: (87) living, (90) elsewhere, (98c) demand 11991.96 and per-system fuel (211)=7741.6458 / (213)=6995.3106 all match the worksheet to 1e-4. Re-pin: golden 0240 (same 2106/2110 archetype, API-only) — PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both closer to zero; SAP 72 unchanged). Single-main certs unchanged (2360 pass + 0 fail). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 30 +++++++++- .../worksheet/mean_internal_temperature.py | 56 ++++++++++++++++++- .../rdsap/test_golden_fixtures.py | 13 ++++- .../_elmhurst_worksheet_001431_case6.py | 8 +++ .../worksheet/test_section_cascade_pins.py | 27 +++++++++ 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 68c91620..6cec1313 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6250,12 +6250,33 @@ def cert_to_inputs( # = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0 # for the Elmhurst corpus (cert-side mapping is a future slice). control_type_value = _control_type(main) - responsiveness_value = _responsiveness( - main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type), - ) + _mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) + responsiveness_value = _responsiveness(main, tariff=_mit_tariff) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) + # SAP 10.2 Table 9b weighted R + p.186 two-systems-different-parts MIT. + # A genuine second main (main_heating_fraction > 0 = (203)) contributes + # its own responsiveness (Table 9b weighted average) and, when it + # carries a different control type, its own rest-of-dwelling control + # schedule. `_first_main_heating` is system 1 (living area); the second + # detail is system 2. Single-main / DHW-only second mains (frac 0) pass + # the None/0 defaults → unchanged single-system MIT. + _mit_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + _mit_main_2 = _mit_details[1] if len(_mit_details) >= 2 else None + main_2_control_type_value: Optional[int] = None + main_2_fraction_value = 0.0 + main_2_responsiveness_value = 1.0 + if ( + _mit_main_2 is not None + and _mit_main_2.main_heating_fraction is not None + and _mit_main_2.main_heating_fraction > 0 + ): + main_2_control_type_value = _control_type(_mit_main_2) + main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0 + main_2_responsiveness_value = _responsiveness( + _mit_main_2, tariff=_mit_tariff + ) monthly_total_gains_w = tuple( internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) ) @@ -6281,6 +6302,9 @@ def cert_to_inputs( responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=_control_temperature_adjustment_c(main), + main_2_control_type=main_2_control_type_value, + main_2_fraction=main_2_fraction_value, + main_2_responsiveness=main_2_responsiveness_value, extended_heating_days_per_month=extended_heating_days, ) diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index 8e562ed4..7cfa637e 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -321,6 +321,9 @@ def mean_internal_temperature_monthly( control_temperature_adjustment_c: float = 0.0, secondary_fraction: float = 0.0, secondary_responsiveness: float = 1.0, + main_2_control_type: Optional[int] = None, + main_2_fraction: float = 0.0, + main_2_responsiveness: float = 1.0, extended_heating_days_per_month: Optional[tuple[tuple[int, int], ...]] = None, ) -> MeanInternalTemperatureResult: """SAP 10.2 §7 orchestrator — chain Table 9c steps 1–9 for all 12 months. @@ -354,13 +357,36 @@ def mean_internal_temperature_monthly( standard SAP heating schedule applies: T_zone = T_bimodal directly. """ + # SAP 10.2 Table 9b (PDF p.183) — "where there are two main systems R + # is a weighted average ... R = (203)·R_system2 + [1 − (203)]·R_system1". + # (203) = `main_2_fraction`. Applied before the secondary-heating blend. + main_responsiveness = responsiveness + if main_2_control_type is not None and main_2_fraction > 0.0: + main_responsiveness = ( + (1.0 - main_2_fraction) * responsiveness + + main_2_fraction * main_2_responsiveness + ) effective_responsiveness = ( - (1.0 - secondary_fraction) * responsiveness + (1.0 - secondary_fraction) * main_responsiveness + secondary_fraction * secondary_responsiveness ) elsewhere_off_hours = ( _ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12 ) + # SAP 10.2 p.186 "two systems heat different parts of the house": when + # the two mains carry different controls, the rest-of-dwelling (90)m is + # the weighted average of T2 computed under EACH system's control. The + # elsewhere off-hours for main system 2's control: + two_main_different_parts = ( + main_2_control_type is not None + and main_2_fraction > 0.0 + and main_2_control_type != control_type + ) + elsewhere_off_hours_main_2 = ( + _ELSEWHERE_OFF_HOURS_TYPE_3 + if main_2_control_type == 3 + else _ELSEWHERE_OFF_HOURS_TYPE_12 + ) eta_living: list[float] = [] t_1: list[float] = [] @@ -408,6 +434,34 @@ def mean_internal_temperature_monthly( ) eta_elsewhere.append(eta_e) + # SAP 10.2 p.186 part 2 — two systems heat different parts: blend + # the rest-of-dwelling temperature computed under each system's + # control. Th2 + η are identical for control types 2/3 (Table 9 + # uses the same Th2 formula); only the off-hours differ, so the + # second computation reuses t_h2_m and shares η. Weights: + # sys2 control: (203) / [1 − (91)] + # sys1 control: [1 − (203) − (91)] / [1 − (91)] + # If (203) ≥ rest-of-house area [1 − (91)], use sys2's control + # alone for elsewhere (per the spec's threshold clause). + if two_main_different_parts: + rest_of_house = 1.0 - living_area_fraction + _, t_e_main_2 = _zone_mean_temp_with_per_zone_eta( + heating_temperature_c=t_h2_m, + off_hours_first=elsewhere_off_hours_main_2[0], + off_hours_second=elsewhere_off_hours_main_2[1], + external_temp_c=ext, responsiveness=effective_responsiveness, + total_gains_w=gains, heat_transfer_coefficient_w_per_k=h, + time_constant_h=tau, + ) + if rest_of_house <= 0.0 or main_2_fraction >= rest_of_house: + t_e_bimodal = t_e_main_2 + else: + w_main_2 = main_2_fraction / rest_of_house + w_main_1 = ( + rest_of_house - main_2_fraction + ) / rest_of_house + t_e_bimodal = w_main_1 * t_e_bimodal + w_main_2 * t_e_main_2 + # SAP 10.2 Appendix N3.5 Equation N5 — when the caller provides # per-month (N24,9, N16,9) day allocations, blend Th / T_unimodal # / T_bimodal for each zone. T_unimodal applies one 8-hour off diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index c1507af0..381895b5 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+2.1519, - expected_co2_resid_tonnes_per_yr=+0.1051, + expected_pe_resid_kwh_per_m2=+1.6893, + expected_co2_resid_tonnes_per_yr=+0.0815, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -169,7 +169,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "instead of the regular roof (the case-6 worksheet rule). 0240 " "is detailed-RR gables-only like case 6 → roof drops → space-" "heating demand falls → PE +2.5812 → +2.1519, CO2 +0.1269 → " - "+0.1051 (both closer to zero; SAP integer 72 unchanged)." + "+0.1051 (both closer to zero; SAP integer 72 unchanged). " + "Slice S0380.205 applied the SAP 10.2 p.186 two-systems-" + "different-parts MIT (Main 1 2106 type 2 / Main 2 2110 type 3, " + "emitter 2 R=0.75): weighted responsiveness 0.8775 + elsewhere " + "two-control blend. Lowers MIT ~0.037 °C → space-heating demand " + "falls → PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both " + "closer to zero; SAP integer 72 unchanged). Verified 1e-4 " + "against the case-6 worksheet (87)/(90)/(98c)." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index c9f1d820..95e4824f 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -84,6 +84,14 @@ LINE_30_ROOF_W_PER_K: Final[float] = 19.0523 # wired in `_table_4f_additive_components`). LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0 +# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). The dual +# oil boiler heats different parts (Main 1 radiators/2106 living 51%, Main +# 2 underfloor/2110 elsewhere 49%) — the SAP 10.2 p.186 two-systems- +# different-parts MIT (weighted R 0.8775 + elsewhere two-control blend) +# lands (98c) demand 11991.96 exact, so the per-system fuels pin. +LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7741.6458 +LINE_213_MAIN_2_FUEL_KWH: Final[float] = 6995.3106 + # Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only # (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair # per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2 diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 57937a58..9ec6975f 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -301,6 +301,33 @@ def test_case6_main_2_emitter_and_control_extracted() -> None: assert main_2.main_heating_control == 2110 +def test_section_9a_per_system_fuel_case6_match_pdf() -> None: + """(211)/(213) per-system space-heating fuel for simulated case 6. The + dual oil boiler heats different parts (Main 1 radiators/2106 living, + Main 2 underfloor/2110 elsewhere), so SAP 10.2 p.186 applies the + two-systems-different-parts MIT: weighted responsiveness R = 0.51·1.0 + + 0.49·0.75 = 0.8775 (Table 9b) and a rest-of-dwelling temperature + blended from each system's control schedule. That lands (98c) demand + 11991.96 exact, so the per-system fuels pin. Pre-S0380.205 the cascade + used Main 1's control + R=1.0 for the whole dwelling → MIT +0.037 °C → + demand +61 kWh → both legs ~+1.3 % high.""" + # Arrange / Act — pin the REAL cascade (the §2.4 section helper skips + # the interlock penalty + two-system MIT params, so use cert_to_inputs). + er = cert_to_inputs(_w001431_case6.build_epc()).energy_requirements + + # Assert + _pin( + er.main_1_fuel_kwh_per_yr, + _w001431_case6.LINE_211_MAIN_1_FUEL_KWH, + "§9a (211) case6", + ) + _pin( + er.main_2_fuel_kwh_per_yr, + _w001431_case6.LINE_213_MAIN_2_FUEL_KWH, + "§9a (213) case6", + ) + + def test_section_4f_pumps_fans_case6_match_pdf() -> None: """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler detached dwelling. Worksheet (231) = 356 = (230c) central heating