From 42e0bb31223b22e8cf8272bc194b18144d2e286d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 12:12:25 +0000 Subject: [PATCH] fix(thermal-mass): gov-API system built (wall code 8) is masonry, not park home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5.16 Table 22 thermal-mass-parameter (TMP) "always low-mass" set was {timber 5, cob 7, park home 8}. But wall_construction code 8 is OVERLOADED by the same gov-API/calc code-space divergence as the wall-U fix: the Summary path's "PH" mapping uses 8 for park home, while the gov-EPC API enum uses 8 for SYSTEM BUILD (Summary system build = code 6). So every API system-built cert was mis-rated as low-mass 100 kJ/m²K instead of masonry 250 (Table 22 lists system build as masonry — PDF p.48, line "System build 250..."). A too-low TMP shortens the §7 time constant tau = Cm/(3.6·H), over-cutting the temperature reduction so mean internal temperature is UNDER-stated → space-heating demand under-stated → SAP over-rated. This was the cause of the uninsulated system-built over-rate cluster (n=9 gas-boiler certs at signed +2.39 vs cavity +0.43 / solid-brick +0.08 at the same bands — a system-built- specific anomaly with a spec-correct wall U). Fix: drop 8 from the always-low set and gate it on `property_type` — code 8 is the low-mass park-home value only when the dwelling really is a park home, otherwise it is gov-API system build and keeps masonry 250. Disambiguated by the same `property_type == "park home"` signal used elsewhere in the cascade. Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path uses code 6 for system build and code 8 only for genuine park homes (which stay low-mass via the property_type gate). API gauge 65.3% -> 67.1% within-0.5 (mean|err| 1.059 -> 1.024, signed +0.050 -> -0.002). The uninsulated system-built cluster collapses +2.82 -> +0.28 signed (0/11 -> 7/11 within 0.5). 2 AAA tests (parametrised code-8 system-built -> 250; park-home property -> 100). pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 20 +++++++-- .../rdsap/test_cert_to_inputs.py | 41 +++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9a90c0fe..fad11fae 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -234,8 +234,15 @@ _PENCE_TO_GBP: Final[float] = 0.01 _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}) +# 5 = timber frame, 7 = cob — always Table 22 low-mass. Park home is the +# third "always low-mass" type, but its wall code (8) is OVERLOADED: the +# Summary path's "PH" mapping uses 8 for park home, whereas the gov-API +# enum uses 8 for SYSTEM BUILT (a masonry type, Summary system build = code +# 6). Code 8 therefore takes the low-mass value only when the dwelling is +# actually a park home (`property_type`); otherwise it is system built and +# keeps the masonry 250 — see `_thermal_mass_parameter_kj_per_m2_k`. +_TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7}) +_TMP_PARK_HOME_OR_SYSTEM_BUILT_WALL_CODE: Final[int] = 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 @@ -3871,7 +3878,14 @@ def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: 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: + wall_code: Optional[int] = _int_or_none(main.wall_construction) + if wall_code in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: + return _TMP_LOW_KJ_PER_M2_K + # Wall code 8 is a park home (low-mass) ONLY when the dwelling really is + # one; on the gov-API path code 8 is system built (masonry). Disambiguate + # by property_type so an API system build is not mis-rated as low-mass. + is_park_home: bool = (epc.property_type or "").strip().lower() == "park home" + if wall_code == _TMP_PARK_HOME_OR_SYSTEM_BUILT_WALL_CODE and is_park_home: 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 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 19288401..c088628d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -148,12 +148,17 @@ 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. + # RdSAP 10 §5.16 Table 22 (PDF p.48) — timber frame (5), cob (7) + # 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 + # Wall code 8 with no park-home property is gov-API SYSTEM BUILT + # (calc 8 = park home only on the Summary path) → masonry. Table 22 + # lists system build as masonry (250 as-built); the park-home + # low-mass case is covered separately below (needs property_type). + (8, 1, 250.0), # system built (gov-API code 8), external insulation + (8, 4, 250.0), # system built (gov-API code 8), as-built # Masonry WITH internal insulation (ins 3 = internal, 7 = # filled cavity + internal) → low-mass 100. (3, 3, 100.0), # solid brick + internal @@ -203,6 +208,36 @@ def test_thermal_mass_parameter_follows_rdsap_table_22( assert abs(tmp - expected_tmp) <= 1e-9 +def test_thermal_mass_wall_code_8_is_park_home_only_when_property_type_says_so() -> None: + # Arrange — RdSAP 10 §5.16 Table 22 (PDF p.48): timber frame / cob / + # PARK HOME are low-mass (100); system build is masonry (250 as-built). + # Wall_construction code 8 is overloaded: the Summary path's "PH" + # mapping uses 8 for park home, but the gov-API enum uses 8 for SYSTEM + # BUILT (Summary system build is code 6). So code 8 may only take the + # park-home low-mass value when the dwelling really is a park home — + # otherwise it is gov-API system built (masonry). Same construction + # code, opposite thermal mass, disambiguated by `property_type`. + def _epc(property_type: Optional[str]) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, + country_code="ENG", property_type=property_type, + sap_building_parts=[ + make_building_part(wall_construction=8, wall_insulation_type=4), + ], + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + ), + ) + + # Act + tmp_park_home: float = _thermal_mass_parameter_kj_per_m2_k(_epc("Park home")) + tmp_system_built: float = _thermal_mass_parameter_kj_per_m2_k(_epc("House")) + + # Assert — park home → low-mass 100; system built (code 8) → masonry 250. + assert abs(tmp_park_home - 100.0) <= 1e-9 + assert abs(tmp_system_built - 250.0) <= 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