fix(cascade): suppress floor heat loss for "another dwelling below" (code 6)

A floor lodged API floor_heat_loss=6 ("another dwelling below") sits over
another heated dwelling, so it is a party floor with no heat loss (RdSAP
10 §3). The mapper mapped code 6 → None and the heat-transmission step
drove floor exposure solely from the dwelling-level `has_exposed_floor`
flag — which is keyed only on the dwelling_type label and defaults a
"Ground-floor flat" to an exposed floor. So a ground-floor flat above a
basement dwelling kept its full ground-floor heat-loss area.

Map code 6 → "(another dwelling below)" (still != "Ground floor", so the
§5 (12) suspended-timber rule stays inert) and have the cascade suppress
that BP's floor when its floor_type carries the signal, mirroring the
roof's existing "another dwelling above" per-BP party override.

Cert 2115-4121-4711-9361-3686 (ground-floor flat, floor_heat_loss=6):
floor_w_per_k 47.85 → 0; SAP -23.44 → -4.41. Cert 0350-…-6435 -12.38 →
-0.55; 0926-…-9024 -2.35 → -0.82. Eval mean |err| 1.982 → 1.944.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 18:05:33 +00:00
parent 6b04514645
commit 3aed8f858a
5 changed files with 93 additions and 12 deletions

View file

@ -2631,16 +2631,21 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
# the same top-level floors[] description
# as code 2; route to the same cascade
# signal until a fixture forces them apart
# 6 = "(another dwelling below)" — top-floor flat over a party floor;
# cert 9501 lodges this. The cascade's
# floor-as-party-floor dispatch already
# handles this via `property_type=Flat` +
# cert.floors[].description, so the
# floor_type string from this helper is
# not consumed for the (12) spec rule
# in that path — explicit None preserves
# the cert 9501 cascade match without
# silently letting unknown codes through.
# 6 = "(another dwelling below)" — the floor sits over another heated
# dwelling (e.g. an upper-floor flat, or a
# ground-floor flat above a basement flat),
# so it is a party floor with no heat loss
# (RdSAP 10 §3). The heat-transmission step
# reads this string to suppress the BP's
# floor area, mirroring the roof's "another
# dwelling above" party override — the
# dwelling-level exposure heuristic (keyed
# only on the dwelling_type label) defaults
# has_exposed_floor=True for a ground-floor
# flat, so the per-BP lodgement is needed to
# override it. It is != "Ground floor", so
# the §5 (12) suspended-timber rule stays
# inert (short-circuits exactly as None did).
# 7 = "Ground floor" — typical ground-floor heat loss
#
# Codes 4/5/8+ are not yet observed in any fixture; the strict-raise
@ -2650,7 +2655,7 @@ _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, Optional[str]] = {
1: "To external air",
2: "To unheated space",
3: "To unheated space",
6: None,
6: "(another dwelling below)",
7: "Ground floor",
}

View file

@ -887,6 +887,34 @@ class TestElmhurstGlazingTypeWrappedGap:
assert code == 2
class TestApiFloorTypeCode:
"""`_api_floor_type_str` maps the GOV.UK API integer floor_heat_loss
code to the floor-position string the cascade reads. Code 6 ("another
dwelling below") must surface "(another dwelling below)" so the
heat-transmission step can suppress that BP's floor as a party floor
(RdSAP 10 §3) it previously mapped to None and the floor leaked
heat-loss area. Cert 2115-4121-4711-9361-3686 (ground-floor flat over
another dwelling) under-rated ~23 SAP from the over-counted floor."""
def test_code_6_maps_to_another_dwelling_below(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_type_str(6)
# Assert — a party-floor signal the cascade consumes (not None).
assert result == "(another dwelling below)"
def test_code_7_still_maps_to_ground_floor(self) -> None:
# Arrange — regression guard: the ground-floor signal the §5 (12)
# suspended-timber rule keys on is unchanged.
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
# Act / Assert
assert _api_floor_type_str(7) == "Ground floor"
class TestApiFloorConstructionCode:
"""`_api_floor_construction_str` maps the GOV.UK API integer
floor_construction code to the description string the cascade's

View file

@ -937,8 +937,18 @@ def heat_transmission_from_cert(
rw_area_part if _bp_rr_roof_absorbs_rooflight(part, geom) else 0.0
)
roof_area = max(0.0, gross_roof_area - (rw_area_part - rw_area_on_rr))
# Per-BP floor exposure: a floor lodged "(another dwelling below)"
# (API floor_heat_loss=6) sits over another heated dwelling, so it
# is a party floor with no heat loss (RdSAP 10 §3) — suppress that
# BP's floor even when the dwelling-level `has_exposed_floor` flag
# is True. The flag is keyed only on the dwelling_type label, which
# defaults a "Ground-floor flat" to an exposed floor; the per-BP
# lodgement is authoritative. Mirrors the roof's "another dwelling
# above" override above. Cert 2115-4121-4711-9361-3686.
part_floor_is_party = "another dwelling below" in (part.floor_type or "").lower()
part_has_exposed_floor = exposure.has_exposed_floor and not part_floor_is_party
floor_area_total = _round_half_up(
geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0,
geom["ground_floor_area_m2"] if part_has_exposed_floor else 0.0,
_AREA_ROUND_DP,
)

View file

@ -155,6 +155,7 @@ def make_building_part(
roof_construction: Optional[int] = 4,
floor_dimensions: Optional[list[SapFloorDimension]] = None,
sap_room_in_roof: Optional[SapRoomInRoof] = None,
floor_type: Optional[str] = None,
) -> SapBuildingPart:
"""Build a SapBuildingPart with sensible SAP10 defaults."""
return SapBuildingPart(
@ -169,6 +170,7 @@ def make_building_part(
if floor_dimensions is not None
else [make_floor_dimension()],
sap_room_in_roof=sap_room_in_roof,
floor_type=floor_type,
)

View file

@ -921,6 +921,42 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None:
assert ground.roof_w_per_k == 0.0
def test_floor_over_another_dwelling_below_zeroes_floor_despite_exposed_flag() -> None:
# Arrange — a "Ground-floor flat" lodged with floor_heat_loss=6
# ("another dwelling below") sits over a heated dwelling (e.g. a
# basement flat), so its floor is a party floor (U=0, no heat loss)
# even though the dwelling-level exposure heuristic — keyed only on
# the "Ground-floor flat" label — defaults has_exposed_floor=True.
# The per-BP `floor_type` lodgement is authoritative and must
# suppress that BP's floor, mirroring the roof's "another dwelling
# above" party override. RdSAP 10 §3 — party floors between dwellings
# are not heat-loss elements. Cert 2115-4121-4711-9361-3686.
main = make_building_part(
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_type="(another dwelling below)",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=60.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=60.0, country_code="ENG", sap_building_parts=[main],
)
# Act — dwelling-level exposure still flags the floor as exposed.
result = heat_transmission_from_cert(
epc, exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=False),
)
# Assert — the per-BP "another dwelling below" override wins → no floor loss.
assert result.floor_w_per_k == 0.0
assert result.walls_w_per_k > 0
def test_ground_floor_flat_extension_with_flat_roof_exposes_extension_roof_only() -> None:
"""Per-BP roof exposure: an extension on a ground-floor flat can have
its own external (e.g. single-storey) roof even though the dwelling-