S0380.205: SAP 10.2 p.186 two-systems-different-parts MIT (weighted R + elsewhere blend)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 16:02:56 +00:00
parent 2b1afa7339
commit e440e2df2e
5 changed files with 127 additions and 7 deletions

View file

@ -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,
)

View file

@ -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 19 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

View file

@ -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(

View file

@ -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

View file

@ -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