diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 36d0537a2..b506995c4 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4648,7 +4648,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/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 0bc92b9c4..25c5d5405 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 @@ -1298,6 +1330,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 @@ -1625,6 +1662,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 @@ -1685,5 +1723,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/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/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index cf6fa0bca..4c2db4c96 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] @@ -2715,6 +2716,68 @@ def test_room_in_roof_detailed_gable_wall_excluded_from_line_31_external_area() ) +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 + + def test_floor_to_unheated_space_on_flat_carries_semi_exposed_loss() -> None: # Arrange — a mid-/top-floor flat whose lowest floor is lodged "To unheated # space" (API floor_heat_loss=2, e.g. an enclosed garage or undercroft diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index f20ab52a3..5d74b143c 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -273,7 +273,14 @@ _CORPUS = Path( # 000480/000516 lodge Party gables). Cert 100040550095 -10.13 -> +0.37; the # 18-cert terraced-external-gable cohort mean -1.94 -> +0.11; end-terrace # untouched. Unit-pinned in test_mapper_rir_gable_terraced. -_MIN_WITHIN_HALF_SAP = 0.795 +# SHELTERED STAIRWELL + PARTY-CEILING ROOF-CODE-3 SLICE (stacks on the gable +# fix above): RdSAP 10 §5.9 (PDF p.43) bills a sheltered wall to an unheated +# STAIRWELL at Ru 2.1, not the corridor 0.5 (`SapFlatDetails.heat_loss_corridor` +# was read nowhere); and gov `roof_construction=3` is "another dwelling above" +# (U=0), previously mislabelled a pitched roof so the party override never fired. +# Combined with main's gable fix: within-0.5 79.5% -> 80.3%, MAE 0.599 -> 0.584, +# PE 2.71 -> 2.5. Ratcheted. +_MIN_WITHIN_HALF_SAP = 0.803 # 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, @@ -404,7 +411,11 @@ _MIN_WITHIN_HALF_SAP = 0.795 # efficiency / interlock -5pp — SAP Table 4a immersion = 100%): observed # within-0.5 78.9%, MAE 0.622. A handful of corpus certs with a gas-boiler # space main + separate electric immersion moved closer to accredited. -_MAX_SAP_MAE = 0.599 +# Ratcheted 0.585 -> 0.584 by the #1663 sheltered-stairwell Ru 2.1 + party- +# ceiling roof-code-3 slice stacking on main's #1662 floor-to-unheated-space +# fix: merged-corpus MAE measured 0.583761 (within-0.5 80.3%), rounded up to +# 3 d.p. so the `<=` assert holds. +_MAX_SAP_MAE = 0.584 _MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current