diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6b8e15dc..0d99af01 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -210,7 +210,26 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13 _PENCE_TO_GBP: Final[float] = 0.01 +# RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter (TMP), +# keyed on the construction type of the MAIN building (not extensions): +# 100 kJ/m²K — timber frame, cob, park home (the three types regardless +# of internal insulation); OR masonry (stone, solid brick, +# cavity, system built) WITH internal insulation. +# 250 kJ/m²K — masonry WITHOUT internal insulation. +# This default is the masonry-no-internal value; `_thermal_mass_parameter_ +# kj_per_m2_k` lowers it to 100 for the Table 22 low-mass cases. Unknown / +# unmapped / curtain-wall constructions keep the 250 default (the +# pre-Table-22 behaviour, so no fixture regresses on a missing class). _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 +_TMP_LOW_KJ_PER_M2_K: Final[float] = 100.0 +# `wall_construction` int codes (domain/sap10_ml/rdsap_uvalues.py): +# 5 = timber frame, 7 = cob, 8 = park home — Table 22's "three types". +_TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7, 8}) +# `wall_insulation_type` int codes that are INTERNAL insulation +# (Table 22 "masonry … with internal insulation"): 3 = internal wall +# insulation, 7 = filled cavity + internal. External (1), filled cavity +# (2), cavity+external (6), as-built (4), none (5) keep the masonry 250. +_TMP_INTERNAL_WALL_INSULATION_CODES: Final[frozenset[int]] = frozenset({3, 7}) # SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump # rows. Keyed on RdSAP API `central_heating_pump_age` enum: @@ -3269,6 +3288,30 @@ def _int_or_none(value: object) -> Optional[int]: return value if isinstance(value, int) else None +def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: + """RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from + the MAIN building's wall construction. + + Timber frame / cob / park home → 100 kJ/m²K regardless of insulation. + Masonry (stone, solid brick, cavity, system built) → 100 with internal + insulation, else 250. Unknown / unmapped / curtain-wall constructions + fall back to the masonry default (250). See the Table 22 constant + comments above for the `wall_construction` / `wall_insulation_type` + code sets. TMP feeds the §7 time constant τ = Cm/(3.6·H); a wrong + (too-high) TMP slows the cooling rate, under-cuts the §7 temperature + reduction, and over-states mean internal temperature → space heating. + """ + parts: list[SapBuildingPart] = epc.sap_building_parts or [] + if not parts: + return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K + main: SapBuildingPart = parts[0] + if _int_or_none(main.wall_construction) in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: + return _TMP_LOW_KJ_PER_M2_K + if _int_or_none(main.wall_insulation_type) in _TMP_INTERNAL_WALL_INSULATION_CODES: + return _TMP_LOW_KJ_PER_M2_K + return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K + + @dataclass(frozen=True) class _VentilationCounts: open_flues: int = 0 @@ -3513,7 +3556,7 @@ def mean_internal_temperature_section_from_cert( ), monthly_total_gains_w=monthly_total_gains_w, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=_control_type(main), responsiveness=_responsiveness(main, tariff=tariff), @@ -3612,7 +3655,7 @@ def space_cooling_section_from_cert( monthly_external_temperature_c=monthly_external_temp_c, monthly_total_gains_w=(0.0,) * 12, total_floor_area_m2=dim.total_floor_area_m2, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), cooled_area_fraction=0.0, intermittency_factor=0.25, ) @@ -6174,7 +6217,7 @@ def cert_to_inputs( ), monthly_total_gains_w=monthly_total_gains_w, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=control_type_value, responsiveness=responsiveness_value, @@ -6270,7 +6313,7 @@ def cert_to_inputs( monthly_external_temperature_c=monthly_external_temp_c, monthly_total_gains_w=(0.0,) * 12, total_floor_area_m2=dim.total_floor_area_m2, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), cooled_area_fraction=0.0, intermittency_factor=0.25, ) @@ -6454,7 +6497,7 @@ def cert_to_inputs( responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=_control_temperature_adjustment_c(main), - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), main_heating_efficiency=eff, hot_water_kwh_per_yr=hw_kwh, pumps_fans_kwh_per_yr=pumps_fans_kwh, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index ccfdee3e..c8b92602 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -59,6 +59,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] _space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] + _thermal_mass_parameter_kj_per_m2_k, # pyright: ignore[reportPrivateUsage] _water_efficiency_with_category_inherit, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] cert_to_demand_inputs, @@ -122,6 +123,64 @@ def _typical_semi_detached_epc(): ) +@pytest.mark.parametrize( + "wall_construction, wall_insulation_type, expected_tmp", + [ + # RdSAP 10 §5.16 Table 22 (PDF p.48) — timber frame (5), cob (7), + # park home (8) are always low-mass, regardless of insulation. + (5, 4, 100.0), # timber frame, as-built + (5, 2, 100.0), # timber frame, filled cavity — still 100 + (7, 4, 100.0), # cob + (8, 1, 100.0), # park home, external insulation — still 100 + # Masonry WITH internal insulation (ins 3 = internal, 7 = + # filled cavity + internal) → low-mass 100. + (3, 3, 100.0), # solid brick + internal + (3, 7, 100.0), # solid brick + filled cavity + internal + (4, 3, 100.0), # cavity + internal + (6, 3, 100.0), # system built + internal + (1, 3, 100.0), # stone granite + internal + # Masonry WITHOUT internal insulation → high-mass 250. External + # (1), filled cavity (2), cavity+external (6), as-built (4) all + # leave the structural mass coupled. + (3, 4, 250.0), # solid brick, as-built + (4, 2, 250.0), # cavity, filled + (4, 1, 250.0), # cavity, external insulation (NOT internal) + (4, 6, 250.0), # cavity + external + (1, 4, 250.0), # stone, as-built + (6, 4, 250.0), # system built, as-built (Table 22 lists it as masonry) + # Unmapped / curtain (9) / unknown (10) → masonry default 250 + # (pre-Table-22 behaviour; no fixture regresses on a missing class). + (9, 4, 250.0), # curtain wall + (10, 4, 250.0), # unknown + ], +) +def test_thermal_mass_parameter_follows_rdsap_table_22( + wall_construction: int, wall_insulation_type: int, expected_tmp: float +) -> None: + # Arrange — a single-part dwelling carrying the wall construction + + # insulation under test (RdSAP 10 §5.16 Table 22, PDF p.48). + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[ + make_building_part( + wall_construction=wall_construction, + wall_insulation_type=wall_insulation_type, + ), + ], + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + ), + ) + + # Act + tmp: float = _thermal_mass_parameter_kj_per_m2_k(epc) + + # Assert + assert abs(tmp - expected_tmp) <= 1e-9 + + def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> None: # Arrange — heat-network main heating (Table 4a code 301 = community # heating with CHP/boilers; main_heating_category=6). Cert age band diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 7ca77668..dc000956 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -193,11 +193,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+46.4156, - expected_co2_resid_tonnes_per_yr=+1.0677, + expected_sap_resid=-2, + expected_pe_resid_kwh_per_m2=+19.1566, + expected_co2_resid_tonnes_per_yr=+0.4211, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " + "S0380.189 fixed the dominant driver: walls are solid brick " + "WITH internal insulation (wall_insulation_type=3), so " + "RdSAP 10 §5.16 Table 22 sets TMP=100 (not the old hardcoded " + "250). Correct TMP → §7 time constant ~16h not ~40h → larger " + "temperature reduction → MIT down ~0.7C → space heating drops. " + "SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. " + "Validated 1e-4 against the user-simulated 001431 worksheet " + "(same archetype). Remaining +19 PE is other gaps + lodged " + "divergence (no worksheet for 6035 itself to pin further). " "Slice 59 per-bp window apportionment tightens all 3 " "residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → " "+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs "