mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
6b04514645
commit
3aed8f858a
5 changed files with 93 additions and 12 deletions
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue