S0380.189: thermal mass parameter per RdSAP 10 §5.16 Table 22, not hardcoded 250

The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:

  100 kJ/m²K — timber frame, cob, park home (regardless of internal
               insulation); OR masonry (stone/solid brick/cavity/system
               built) WITH internal insulation.
  250 kJ/m²K — masonry WITHOUT internal insulation.

A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.

`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.

Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.

Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 22:01:35 +00:00
parent 1382c8c886
commit e03f08cdc8
3 changed files with 119 additions and 8 deletions

View file

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

View file

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

View file

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