diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 80a650515..5a04e5165 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4423,28 +4423,47 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { # enum (1=Flat, 3=Pitched no-access, 5=Vaulted, etc.) is mapped as # best-effort against SAP10 nomenclature. # -# Codes 6 and 7 → None. This field is read ONLY for the sloping-ceiling -# inclined factor; the base roof U-value comes from the global -# roofs[].description, so a non-sloping code carries no information the -# cascade consumes here, and None correctly avoids the cos(30°) false- -# trigger: +# Code 6 → None. This field is read ONLY for the sloping-ceiling inclined +# factor; the base roof U-value comes from the global roofs[].description, +# so a non-sloping code carries no information the cascade consumes here: # 6 = "Thatched, with additional insulation" — its U is set by the -# global description; not a sloping ceiling. -# 7 = "(same dwelling above)" / "(another dwelling above)" — an -# internal ceiling with no roof heat loss (the roof-side analogue -# of floor_construction code 0). Heat loss is governed by the -# roof_heat_loss / description path, not this field. -# Empirically inert: roof W/K is identical whether 6/7 map to None or to -# an explicit pitched string across all code-6/7 certs in the 2026 -# sample (were raising UnmappedApiCode, blocking the cert). +# global description; not a sloping ceiling. None correctly avoids +# the cos(30°) false-trigger. +# +# Codes 7 and 9 → "(another dwelling above)", NOT None. RdSAP 10 +# Specification (10-06-2025) §5.2.5 (p.31): "If a dwelling or part of a +# dwelling has commercial premises above record as another dwelling +# above" — i.e. premises-above (code 9) gets the SAME treatment as +# dwelling-above (code 7), not the semi-exposed "partially heated space" +# treatment used for premises *below* (see `_API_FLOOR_HEAT_LOSS_ABOVE_ +# PARTIALLY_HEATED`) — the spec's above/below split is asymmetric. Per +# spec p.~55: "There is no heat loss through the roof of a building part +# that has the same dwelling above or another dwelling above" — zero +# heat loss, mirrored by the landlord-override system's independent +# `roof_party_ceiling_guard` (`domain/epc/property_overrides/`), which +# already resolves this exact "(another premises above)" marker to a +# ~0-heat-loss RoofType citing the same spec line. +# +# `heat_transmission.py`'s per-part suppression (`part_roof_is_party = +# "another dwelling above" in roof_type`) only fires when this field +# CONTAINS that literal substring — it existed already for the Elmhurst +# path (which sets it via `_strip_code(roof.roof_type)`) but the API path +# previously mapped 7 (and, before this fix, 9) to None, so the +# suppression never fired for ANY API-derived cert: `has_exposed_roof` +# (the dwelling_type-label-only fallback) was the sole gate, and it has +# no way to know about a per-part party-ceiling lodgement. Surfaced by +# live cert uprn 10013320122 ("44 Regent House", code 9); code 7 has the +# same latent gap (11 building parts in the RdSAP-21.0.1 corpus), fixed +# 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", 4: "Pitched (slates/tiles), access to loft", 5: "Pitched (vaulted ceiling)", 6: None, - 7: None, + 7: "(another dwelling above)", 8: "Pitched, sloping ceiling", + 9: "(another dwelling above)", } diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 7d68b4a1f..f6ce46bfb 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -1339,18 +1339,18 @@ class TestDefaultMissingPostTown: class TestApiRoofConstructionCode: """`_api_roof_construction_str` maps the GOV.UK API integer - roof_construction code to the string the cascade reads ONLY for the - "sloping ceiling" cos(30°) inclined-surface factor (Slice 89). Codes - 6 and 7 are neither sloping ceilings nor base-U drivers (the roof - U-value comes from the global roofs[].description), so both map to - None: code 6 = "Thatched" (its U is set by the description, not this - field) and code 7 = "(same/another dwelling above)" — an internal - ceiling with no roof heat loss, the roof-side analogue of - floor_construction code 0. Empirically inert: roof W/K is identical - whether 6/7 map to None or to an explicit pitched string across all - code-6/7 certs in the 2026 sample.""" + roof_construction code to the string `heat_transmission.py` reads for + two purposes: the "sloping ceiling" cos(30°) inclined-surface factor + (Slice 89), and — for codes 7/9 — the "another dwelling above" + substring that suppresses that building part's roof entirely (zero + heat loss). Code 6 = "Thatched" is neither: its U-value comes from + the global roofs[].description, not this field, so it maps to None + (avoids the cos(30°) false-trigger). Codes 7 and 9 both map to + "(another dwelling above)" per RdSAP 10 Specification (10-06-2025) + §5.2.5: commercial premises above (code 9) is recorded identically to + another dwelling above (code 7) — both zero heat loss.""" - def test_code_7_same_dwelling_above_maps_to_none(self) -> None: + def test_code_7_same_dwelling_above_suppresses_roof_heat_loss(self) -> None: # Arrange from datatypes.epc.domain.mapper import ( _api_roof_construction_str, @@ -1359,9 +1359,22 @@ class TestApiRoofConstructionCode: # Act result = _api_roof_construction_str(7) - # Assert — None: no sloping-ceiling signal (avoids the cos(30°) - # false-trigger); the internal ceiling has no roof heat loss. - assert result is None + # Assert — the party-ceiling marker, so heat_transmission.py's + # per-part "another dwelling above" suppression fires. + assert result == "(another dwelling above)" + + def test_code_9_another_premises_above_suppresses_roof_heat_loss(self) -> None: + # Arrange — RdSAP 10 Spec §5.2.5: commercial premises above is + # recorded as "another dwelling above", same as code 7. + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(9) + + # Assert + assert result == "(another dwelling above)" def test_code_6_thatched_maps_to_none(self) -> None: # Arrange