From 3f6129afc6d47a074161fd4473eacd30c0ae31e1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 21 Jul 2026 22:02:31 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Bill=20a=20sheltered=20wall=20to=20a=20stai?= =?UTF-8?q?rwell=20at=20Ru=202.1,=20not=20the=20corridor=200.5=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §5.9 "U-values of sheltered walls" (PDF p.43): a wall between a flat/maisonette and an unheated buffer takes SAP 10.2 §3.3's U = 1/(1/U0 + Ru), and Ru depends on the BUFFER TYPE — "Use Ru = 0.5 m²K/W for corridors and Ru = 2.1 m²K/W for stairwell." The buffer type is lodged on `SapFlatDetails.heat_loss_corridor` (0-based per item 1-11, PDF p.70: 0 none, 1 heated, 2 unheated corridor, 3 unheated stairwell). That field was read NOWHERE under domain/ — corridor presence was inferred only from a lodged sheltered alternative wall — so every sheltered wall took the corridor 0.5 and every stairwell flat over-billed its sheltered wall, under-rating the dwelling. Distinct from `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`, which is RdSAP 10 Table 4 (p.22) for a room-in-roof sheltered gable — same 0.5, different rule, deliberately untouched. Corpus gauge ratcheted UP: within-0.5 78.8% -> 79.8% (+10 certs), SAP MAE 0.626 -> 0.613, PE MAE 2.9 -> 2.8. Failure set and pyright count identical to main. Portfolio 824 is stairwell-heavy (60 properties at code 3); property 749898 over-bills 4.44 W/K on a 48 m2 flat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../worksheet/heat_transmission.py | 40 +++++++++++- .../worksheet/test_heat_transmission.py | 63 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 11 +++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 789483e6c..7ea286e01 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -207,6 +207,33 @@ _AREA_ROUND_DP: Final[int] = 2 # 1/(1/U_wall + 0.5). Back-solved from Elmhurst Default U-values: sim case # 21 (U_wall 1.10 → 0.71) and sim case 20 (U_wall 1.70 → 0.92). _SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W: Final[float] = 0.5 +# RdSAP 10 §5.9 "U-values of sheltered walls" (PDF p.43) — a wall between a +# flat/maisonette and an unheated buffer takes SAP 10.2 §3.3's +# U = 1/(1/U0 + Ru), and Ru depends on the BUFFER TYPE: "Use Ru = 0.5 m²K/W +# for corridors and Ru = 2.1 m²K/W for stairwell." A stairwell is the better +# buffer, so billing one at the corridor value over-states its heat loss. +# +# Distinct from `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W` above, which is +# RdSAP 10 Table 4 (p.22) for a room-in-roof sheltered gable — same number, +# different rule, unaffected by the buffer type. +_SHELTERED_CORRIDOR_RU_M2K_W: Final[float] = 0.5 +_SHELTERED_STAIRWELL_RU_M2K_W: Final[float] = 2.1 +# `SapFlatDetails.heat_loss_corridor`, 0-based per RdSAP 10 item 1-11 +# (PDF p.70): 0 no corridor/stairwell, 1 heated corridor/stairwell, +# 2 unheated CORRIDOR, 3 unheated STAIRWELL. Confirmed against the corpus and +# the DB: `unheated_corridor_length_m` is populated for exactly codes 2 and 3. +_HEAT_LOSS_CORRIDOR_UNHEATED_STAIRWELL: Final[int] = 3 + + +def _sheltered_added_resistance(heat_loss_corridor: Optional[int]) -> float: + """RdSAP 10 §5.9 (PDF p.43) Ru for a sheltered wall, by buffer type. + + Only an unheated STAIRWELL takes 2.1; an unheated corridor and any + unlodged/absent value keep the 0.5 corridor default, so a cert with no + flat-details block bills exactly as before.""" + if heat_loss_corridor == _HEAT_LOSS_CORRIDOR_UNHEATED_STAIRWELL: + return _SHELTERED_STAIRWELL_RU_M2K_W + return _SHELTERED_CORRIDOR_RU_M2K_W # RdSAP 10 §3.8 "Roof area" — pitched-sloping-ceiling roofs use the # inclined surface area (floor area divided by cos(30°)) rather than # the horizontal projection. @@ -883,6 +910,11 @@ def heat_transmission_from_cert( window_total_area_m2, _AREA_ROUND_DP, ) + sheltered_ru = _sheltered_added_resistance( + epc.sap_flat_details.heat_loss_corridor + if epc.sap_flat_details is not None + else None + ) for i, part in enumerate(parts): geom = _part_geometry(part) age_band = part.construction_age_band @@ -1268,6 +1300,11 @@ def heat_transmission_from_cert( age_band=age_band, wall_description=wall_description, opening_area_m2=alt_opening, + # RdSAP 10 §5.9 (PDF p.43) — Ru is 0.5 for a corridor but 2.1 + # for a stairwell. The buffer type is lodged on + # `SapFlatDetails.heat_loss_corridor`; before this it was read + # nowhere under domain/ and every sheltered wall took 0.5. + sheltered_added_resistance_m2k_w=sheltered_ru, ) # Main wall net adds back the alt-wall windows that were initially # deducted from the BP's total gross — those openings should have @@ -1595,6 +1632,7 @@ def _alt_wall_w_per_k( age_band: str, wall_description: Optional[str], opening_area_m2: float = 0.0, + sheltered_added_resistance_m2k_w: float = _SHELTERED_CORRIDOR_RU_M2K_W, ) -> float: """U × (gross − openings) for one alternative-wall sub-area. RdSAP §1.4.2: inherits the part's age band but carries its own construction @@ -1655,5 +1693,5 @@ def _alt_wall_w_per_k( # sheltered timber/cavity alt sub-area billed its full exposed U # (cert 0340-2976: a 42 m² sheltered timber-frame alt at U=2.5 # over-stated the wall channel by ~58 W/K → -5 SAP under-rate). - alt_u = 1.0 / (1.0 / alt_u + _SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W) + alt_u = 1.0 / (1.0 / alt_u + sheltered_added_resistance_m2k_w) return alt_u * net_alt_area diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 2df247a2d..ba970d8ea 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -37,6 +37,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( ) from domain.sap10_calculator.worksheet.heat_transmission import ( _alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage] + _sheltered_added_resistance, # pyright: ignore[reportPrivateUsage] _joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage] _main_roof_descriptions_by_kind, # pyright: ignore[reportPrivateUsage] _part_geometry, # pyright: ignore[reportPrivateUsage] @@ -2713,3 +2714,65 @@ def test_room_in_roof_detailed_gable_wall_excluded_from_line_31_external_area() assert result.total_external_element_area_m2 == pytest.approx( expected_line_31, abs=0.001 ) + + +def test_sheltered_wall_to_unheated_stairwell_uses_ru_2p1_not_0p5() -> None: + # Arrange — RdSAP 10 §5.9 "U-values of sheltered walls" (PDF p.43) is + # explicit that the buffer resistance differs by buffer TYPE: + # + # "For sheltered walls of flats and maisonettes (between the dwelling and + # an unheated corridor or stairwell), the U-value for the applicable wall + # area is adjusted as described in SAP10.2 specification Section 3.3: + # U = 1 / (1/U0 + Ru) ... Use Ru = 0.5 m²K/W for corridors and + # Ru = 2.1 m²K/W for stairwell." + # + # A stairwell is a far better buffer than a corridor, so billing a stairwell + # at Ru=0.5 OVER-states its heat loss and under-rates the dwelling. The + # buffer type is lodged on SapFlatDetails.heat_loss_corridor (0-based: + # 0 none, 1 heated, 2 unheated CORRIDOR, 3 unheated STAIRWELL — confirmed by + # `unheated_corridor_length_m` being populated for exactly codes 2 and 3). + # That field was read nowhere under domain/, so every sheltered wall got the + # corridor constant. + # + # NOTE the sibling 0.5 at `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W` is a + # DIFFERENT rule — RdSAP 10 Table 4 (p.22), room-in-roof sheltered gable — + # and is unaffected. + from dataclasses import replace + + from domain.sap10_ml.rdsap_uvalues import Country + + area = 42.0 + alt = SapAlternativeWall( + wall_area=area, wall_dry_lined="N", wall_construction=5, + wall_insulation_type=4, wall_thickness_measured="Y", + wall_insulation_thickness="NI", is_sheltered=True, + ) + + # Act — same wall, billed against a corridor and against a stairwell. + corridor_wpk = _alt_wall_w_per_k( + alt_wall=alt, country=Country.ENG, age_band="A", wall_description=None, + sheltered_added_resistance_m2k_w=_sheltered_added_resistance(2), + ) + stairwell_wpk = _alt_wall_w_per_k( + alt_wall=alt, country=Country.ENG, age_band="A", wall_description=None, + sheltered_added_resistance_m2k_w=_sheltered_added_resistance(3), + ) + + # Assert — the stairwell's Ru=2.1 yields a materially lower U than the + # corridor's Ru=0.5, per §5.9. + unsheltered_u = _alt_wall_w_per_k( + alt_wall=replace(alt, is_sheltered=False), + country=Country.ENG, age_band="A", wall_description=None, + ) / area + assert abs(corridor_wpk - (1.0 / (1.0 / unsheltered_u + 0.5)) * area) <= 1e-9 + assert abs(stairwell_wpk - (1.0 / (1.0 / unsheltered_u + 2.1)) * area) <= 1e-9 + assert stairwell_wpk < corridor_wpk + + +def test_sheltered_added_resistance_defaults_to_the_corridor_value() -> None: + # Arrange / Act / Assert — only code 3 (unheated stairwell) takes Ru=2.1; + # the unheated corridor (2) and any unlodged/absent value keep Ru=0.5, so a + # cert with no flat-details block is billed exactly as before. + assert _sheltered_added_resistance(3) == 2.1 + assert _sheltered_added_resistance(2) == 0.5 + assert _sheltered_added_resistance(None) == 0.5 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index a0a45a31e..56b826bbb 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -260,7 +260,14 @@ _CORPUS = Path( # the plant efficiency C4 reserves for CO2/PE is a known follow-up. Spec- # verified against the SAP 10.2 PDF, not the ticket prose; unit-pinned in # test_cert_to_inputs (950/951/952 must bill identical DHW fuel). -_MIN_WITHIN_HALF_SAP = 0.788 +# SHELTERED STAIRWELL SLICE (ratcheted 0.788 -> 0.798, MAE 0.626 -> 0.614): +# RdSAP 10 §5.9 (PDF p.43) sets the sheltered-wall buffer resistance by buffer +# TYPE — "Ru = 0.5 m²K/W for corridors and Ru = 2.1 m²K/W for stairwell". The +# buffer type is lodged on `SapFlatDetails.heat_loss_corridor` (0-based: 2 = +# unheated corridor, 3 = unheated stairwell), which was read NOWHERE under +# domain/, so every sheltered wall took the corridor 0.5 and every stairwell +# flat over-billed its sheltered wall. 23 corpus certs; within-0.5 +1.0pp. +_MIN_WITHIN_HALF_SAP = 0.798 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, @@ -386,7 +393,7 @@ _MIN_WITHIN_HALF_SAP = 0.788 # The -0.672 overshoot is expected to move again when the C4 plant- # efficiency and C3.2 pumping siblings land. Unit-pinned in # test_cert_to_inputs. -_MAX_SAP_MAE = 0.626 +_MAX_SAP_MAE = 0.614 _MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current From da993b53596a049670bcd9fb48fd106a265f0486 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 21 Jul 2026 22:23:52 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Treat=20a=20lodged=20party=20ceiling=20as?= =?UTF-8?q?=20U=3D0=20instead=20of=20a=20pitched=20roof=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 item 2-3(h) "another dwelling above" is U=0 (spec p.45: "There is no heat loss through the roof of a building part that has the same dwelling above or another dwelling above"). gov-API `roof_construction = 3` is that code, but the mapper labelled it "Pitched (slates/tiles), no access to loft" (which is code 5), so the per-part party override at heat_transmission.py:1172 never matched and the part fell through to the dwelling_type-label-only has_exposed_roof heuristic. 190 of the 200 corpus code-3 parts sit at index 0 and are already suppressed by `_cert_lodges_exposed_roof` off the roof_insulation_location == "ND" signal. The unmitigated set is the 10 parts at index > 0, where extensions default to exposed and were billed a phantom pitched roof. Also removes a latent PV defect: the bogus "Pitched" prefix satisfied `is_pitched` and divided PV array area by cos(35 deg), inflating kWp. Shipped with the stairwell fix so the corpus pin moves once: within-0.5 78.8% -> 79.6%, SAP MAE 0.626 -> 0.611, PE MAE 2.9 -> 2.7. The roof change alone trades 4 certs out of the +-0.5 band for 2 in (one moving 4.4 SAP closer). Those 4 were investigated and are NOT a masked bug: the corpus error distribution is symmetric (mean +0.076, median +0.03, sigma 1.606; 112 certs above +0.5 vs 102 below), ground-floor flats show 7 positive / 8 negative with no excess over the null, and closing them needs HLC changes of 2.3-5.6% — below the ~8.8% noise floor of the RHI anchor, calibrated on the 76 certs whose SAP is exact (space-heating ratio 1.062 +/- 0.088). Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 18 ++++- .../domain/test_mapper_roof_party_ceiling.py | 78 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 4 +- 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 62fe48069..046baa622 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4607,7 +4607,23 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { # here too rather than left half-fixed. _API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { 1: "Flat", - 3: "Pitched (slates/tiles), no access to loft", + # RdSAP 10 item 2-3(h) "another dwelling above" — U=0 (spec p.45: "There is + # no heat loss through the roof of a building part that has the same + # dwelling above or another dwelling above"). Previously mislabelled + # "Pitched (slates/tiles), no access to loft" (that is code 5), so the + # per-part party override at `heat_transmission.py:1172` never matched. + # 190 of the 200 corpus code-3 parts sit at index 0 and are already + # suppressed by `_cert_lodges_exposed_roof` off the `roof_insulation_location + # == "ND"` signal; the unmitigated set is the 10 parts at index > 0, where + # extensions default to exposed and were billed a phantom pitched roof. + # Also removes a latent PV defect: the bogus "Pitched" prefix satisfied + # `is_pitched` (cert_to_inputs.py:3559) and divided PV array area by + # cos(35°), inflating kWp. Corpus evidence (1000 certs, 200 code-3 parts): + # 200/200 lodge roof_insulation_location "ND", 186/200 lodge the roof + # description literally "(another dwelling above)", zero lodge a "no access + # to loft" description, and no top-floor dwelling ever lodges it. Codes 7 + # and 9 were corrected by an earlier slice; 3 was left behind. + 3: "(another dwelling above)", 4: "Pitched (slates/tiles), access to loft", 5: "Pitched (vaulted ceiling)", 6: None, diff --git a/tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py b/tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py new file mode 100644 index 000000000..ba3fe4f1c --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py @@ -0,0 +1,78 @@ +"""Regression: gov-API `roof_construction = 3` is "another dwelling above" +(RdSAP 10 item 2-3(h), U=0), NOT a pitched roof. + +RdSAP 10 item 2-3 "Above the building part" (spec PDF p.72, printed p.71) lists +the codes and their treatment; (g) "same dwelling above" and (h) "another +dwelling above" are both U=0, and p.45 states it plainly: "There is no heat loss +through the roof of a building part that has the same dwelling above or another +dwelling above." + +The mapper labelled code 3 "Pitched (slates/tiles), no access to loft", so the +per-part party override in `heat_transmission.py` — which matches on the literal +string "another dwelling above" in `roof_construction_type` — never fired. The +building part then fell through to the dwelling-level `has_exposed_roof` +heuristic, which is keyed only on the `dwelling_type` LABEL. Where that label +says top-floor (or the part is an extension, billed exposed unconditionally), we +invented an entire pitched roof over a lodged party ceiling. + +Evidence that code 3 is a party ceiling, from the committed RdSAP-21.0.1 corpus +(1000 certs, 200 code-3 building parts): + - 200/200 lodge `roof_insulation_location == "ND"` (not determined — there is + no insulation to determine on a party ceiling); + - 186/200 lodge the cert's roof description as literally + "(another dwelling above)"; + - dwelling types are exclusively non-top-floor (Mid-floor flat 104, + Ground-floor flat 86, Ground-floor maisonette 7, Mid-floor maisonette 1, + Mid-terrace house 2) — no top-floor dwelling ever lodges it. + +Codes 7 and 9 were corrected to the party string by an earlier slice; code 3 was +left behind. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +_FIXTURE = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json") + +_PARTY_CEILING = "another dwelling above" + + +def _load_with_roof_construction(code: int) -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + for part in data["sap_building_parts"]: + part["roof_construction"] = code + return data + + +def test_roof_construction_3_maps_to_a_party_ceiling() -> None: + # Arrange — the lodged part has another dwelling above it (API code 3). + data = _load_with_roof_construction(3) + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — carries the party-ceiling marker the per-part override matches on, + # so the roof is billed at U=0 rather than as an invented pitched roof. + assert epc is not None + assert epc.sap_building_parts + for part in epc.sap_building_parts: + assert _PARTY_CEILING in (part.roof_construction_type or "").lower() + + +def test_roof_construction_4_remains_a_real_pitched_roof() -> None: + # Arrange — code 4 is 2-3(a) "pitched roof, access to loft", a REAL roof. + data = _load_with_roof_construction(4) + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — must not be swept up by the code-3 correction. + assert epc is not None + assert epc.sap_building_parts + for part in epc.sap_building_parts: + assert _PARTY_CEILING not in (part.roof_construction_type or "").lower() diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 56b826bbb..2d9f27369 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -267,7 +267,7 @@ _CORPUS = Path( # unheated corridor, 3 = unheated stairwell), which was read NOWHERE under # domain/, so every sheltered wall took the corridor 0.5 and every stairwell # flat over-billed its sheltered wall. 23 corpus certs; within-0.5 +1.0pp. -_MIN_WITHIN_HALF_SAP = 0.798 +_MIN_WITHIN_HALF_SAP = 0.796 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, @@ -393,7 +393,7 @@ _MIN_WITHIN_HALF_SAP = 0.798 # The -0.672 overshoot is expected to move again when the C4 plant- # efficiency and C3.2 pumping siblings land. Unit-pinned in # test_cert_to_inputs. -_MAX_SAP_MAE = 0.614 +_MAX_SAP_MAE = 0.611 _MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current