mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
fix(floor): floor_heat_loss=3 → above partially heated space, U=0.7 (RdSAP §3.12)
The API `floor_heat_loss` code is authoritative — confirmed by joining each single-BP cert's code to its independent `floors[].description` (which the gov register publishes alongside the code): code 1 ↔ "To external air" (exposed, 9/9) code 2 ↔ "To unheated space" (semi-exposed, 6/6) code 3 ↔ "(other premises below)" (partially htd, 9/9) code 6 ↔ "(another dwelling below)" (party, 176/176) code 7 ↔ "Solid"/"Suspended …" (ground, all) Code 3 was mis-mapped to "To unheated space" (semi-exposed) and, on mid-/top-floor flats, had its floor area zeroed entirely by the dwelling-level exposure heuristic. RdSAP 10 §3.12 (PDF p.25) classes a flat's floor over non-domestic "other premises … heated, but at different times" as "above a partially heated space" → the §5.14 (PDF p.47) constant U=0.7 W/m²K — distinct from semi-exposed (Table 20) and party (no loss). Fix: the mapper sets `is_above_partially_heated_space` on the floor=0 dimension for code 3 (string → "(other premises below)" for fidelity), and the heat-transmission step lets that per-BP lodgement override the flat suppression upward (mirroring the existing exposed / "another dwelling below" overrides). The cascade already routes is_above_partial → U=0.7. Re-pins golden cert 7536-3827: its Ext2 (bp3) lodges code 3, but the cert's lossy `floors[]` summary dropped that description, so a prior agent guessed "code 3 = ground" (U=1.12) and concluded the residual was an irreducible "register-rounding" artifact. It was this bug: Ext2 floor U 1.12 → 0.70, PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), SAP unchanged. Eval: 909 computed, 45.1% → 45.3% within 0.5, mean|err| 1.702 → 1.659, <1.0 59.5% → 60.2%. 13 code-3 certs improve (0380 +3.71 → -0.63, 0350 +7.82 → +0.83, 2610 +7.47 → -1.29); the few that overshoot were already failing and carry independent fabric bugs (9763's walls = 8 W/K for 60 m²). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
75ef250ec8
commit
8741fbdfac
5 changed files with 133 additions and 16 deletions
|
|
@ -2627,10 +2627,18 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
|
|||
# 1 = "To external air" — exposed floor (cantilever / passageway)
|
||||
# 2 = "To unheated space" — over garage / unheated basement /
|
||||
# crawlspace; cert 7536 Main lodges this
|
||||
# 3 = "To unheated space" — variant lodged by cert 7536 Ext2 with
|
||||
# the same top-level floors[] description
|
||||
# as code 2; route to the same cascade
|
||||
# signal until a fixture forces them apart
|
||||
# 3 = "(other premises below)" — the lowest floor sits over non-domestic
|
||||
# "other premises" (heated, but at different
|
||||
# times), so it is "above a partially heated
|
||||
# space" per RdSAP 10 §3.12 (PDF p.25) → the
|
||||
# §5.14 constant U=0.7 W/m²K. The independent
|
||||
# floors[].description resolves this: all 13
|
||||
# code-3 certs in the 2026 sample lodge
|
||||
# "(other premises below)". `_api_build_sap
|
||||
# _floor_dimensions` sets is_above_partially
|
||||
# _heated_space on the floor=0 dimension;
|
||||
# this string (!= "Ground floor", != "another
|
||||
# dwelling below") is inert metadata.
|
||||
# 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),
|
||||
|
|
@ -2669,7 +2677,7 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
|
|||
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, Optional[str]] = {
|
||||
1: "To external air",
|
||||
2: "To unheated space",
|
||||
3: "To unheated space",
|
||||
3: "(other premises below)",
|
||||
6: "(another dwelling below)",
|
||||
7: "Ground floor",
|
||||
8: "(another dwelling below)",
|
||||
|
|
@ -2721,6 +2729,19 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]:
|
|||
_API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1
|
||||
|
||||
|
||||
# API `floor_heat_loss` integer that signals a floor above a partially
|
||||
# heated space. The independent `floors[].description` field resolves the
|
||||
# code: floor_heat_loss=3 lodges "(other premises below)" (13/13 certs in
|
||||
# the 2026 sample). Per RdSAP 10 §3.12 (PDF p.25) a flat's floor is "above
|
||||
# a partially heated space if there are non-domestic premises below
|
||||
# (heated, but at different times)" — the "other premises" wording. That
|
||||
# routes the cascade to the §5.14 (PDF p.47) constant U=0.7 W/m²K via
|
||||
# `u_floor_above_partially_heated_space`, distinct from code 2's "To
|
||||
# unheated space" (semi-exposed → Table 20) and code 6's "(another dwelling
|
||||
# below)" (party floor, no heat loss).
|
||||
_API_FLOOR_HEAT_LOSS_ABOVE_PARTIALLY_HEATED: Final[int] = 3
|
||||
|
||||
|
||||
# GOV.UK API `built_form` integer → SAP10.2 sheltered_sides count per
|
||||
# RdSAP §S5. Detached has no neighbours shielding wind; terraced
|
||||
# variants pick up 1-3 sheltered sides via adjacent dwellings. Cross-
|
||||
|
|
@ -3013,6 +3034,9 @@ def _api_build_sap_floor_dimensions(
|
|||
fixture convention.
|
||||
"""
|
||||
is_exposed = floor_heat_loss == _API_FLOOR_HEAT_LOSS_EXPOSED
|
||||
is_above_partial = (
|
||||
floor_heat_loss == _API_FLOOR_HEAT_LOSS_ABOVE_PARTIALLY_HEATED
|
||||
)
|
||||
out: List[SapFloorDimension] = []
|
||||
for fd in fds or []:
|
||||
raw_height = _measurement_value(fd.room_height)
|
||||
|
|
@ -3026,6 +3050,7 @@ def _api_build_sap_floor_dimensions(
|
|||
floor_insulation=fd.floor_insulation,
|
||||
floor_construction=fd.floor_construction,
|
||||
is_exposed_floor=is_exposed and fd.floor == 0,
|
||||
is_above_partially_heated_space=is_above_partial and fd.floor == 0,
|
||||
))
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -932,6 +932,46 @@ class TestApiFloorTypeCode:
|
|||
# Act / Assert — no-heat-loss signal (not None, not "Ground floor").
|
||||
assert _api_floor_type_str(8) == "(another dwelling below)"
|
||||
|
||||
def test_code_3_maps_to_other_premises_below(self) -> None:
|
||||
# Arrange — code 3 ↔ "(other premises below)" (confirmed 9/9 on
|
||||
# single-bp certs in the 2026 API sample). RdSAP 10 §3.12 (PDF p.25)
|
||||
# classes a floor over non-domestic "other premises" (heated at
|
||||
# different times) as "above a partially heated space" → §5.14
|
||||
# constant U=0.7. The string is != "Ground floor" / "(another
|
||||
# dwelling below)", so it is inert metadata; the U-routing is driven
|
||||
# by the `is_above_partially_heated_space` floor-dimension flag.
|
||||
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act / Assert
|
||||
assert _api_floor_type_str(3) == "(other premises below)"
|
||||
|
||||
def test_code_3_sets_above_partially_heated_space_on_lowest_floor(self) -> None:
|
||||
# Arrange — the floor-dimension builder flags floor_heat_loss=3 →
|
||||
# is_above_partially_heated_space on the lowest storey (floor==0)
|
||||
# only, so the cascade routes that floor to U=0.7 (§5.14) and the
|
||||
# heat-transmission step keeps its area even on a flat whose
|
||||
# dwelling-level exposure defaults has_exposed_floor=False.
|
||||
from datatypes.epc.domain.mapper import _api_build_sap_floor_dimensions # pyright: ignore[reportPrivateUsage]
|
||||
from datatypes.epc.schema.rdsap_schema_21_0_1 import (
|
||||
SapFloorDimension as ApiSapFloorDimension,
|
||||
)
|
||||
|
||||
def fd(floor: int) -> ApiSapFloorDimension:
|
||||
return ApiSapFloorDimension(
|
||||
floor=floor,
|
||||
room_height=2.5,
|
||||
total_floor_area=50.0,
|
||||
party_wall_length=0.0,
|
||||
heat_loss_perimeter=28.0,
|
||||
)
|
||||
|
||||
# Act
|
||||
dims = _api_build_sap_floor_dimensions([fd(0), fd(1)], floor_heat_loss=3)
|
||||
|
||||
# Assert — lowest floor flagged, upper storey not.
|
||||
assert dims[0].is_above_partially_heated_space is True
|
||||
assert dims[1].is_above_partially_heated_space is False
|
||||
|
||||
|
||||
class TestApiFloorConstructionCode:
|
||||
"""`_api_floor_construction_str` maps the GOV.UK API integer
|
||||
|
|
|
|||
|
|
@ -972,16 +972,18 @@ def heat_transmission_from_cert(
|
|||
# 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()
|
||||
# A floor lodged as an *exposed* floor (API floor_heat_loss=1 →
|
||||
# `is_exposed_floor`, "an exposed floor if there is an open space
|
||||
# below" per RdSAP 10 §3.12, PDF p.25) carries heat loss even when
|
||||
# the dwelling-level flat heuristic (`_dwelling_exposure`) defaults
|
||||
# a mid-/top-floor flat to has_exposed_floor=False on the assumption
|
||||
# its floor sits over another *heated* dwelling. The per-BP lodgement
|
||||
# is authoritative: it overrides the suppression upward, mirroring
|
||||
# how the "another dwelling below" party signal overrides it down.
|
||||
# A floor lodged as a heat-loss floor — *exposed* (API
|
||||
# floor_heat_loss=1 → `is_exposed_floor`, "an exposed floor if there
|
||||
# is an open space below") or *above a partially heated space* (API
|
||||
# floor_heat_loss=3, "(other premises below)" → `is_above_partial`)
|
||||
# per RdSAP 10 §3.12 (PDF p.25) — carries heat loss even when the
|
||||
# dwelling-level flat heuristic (`_dwelling_exposure`) defaults a
|
||||
# mid-/top-floor flat to has_exposed_floor=False on the assumption its
|
||||
# floor sits over another *heated* dwelling. The per-BP lodgement is
|
||||
# authoritative: it overrides the suppression upward, mirroring how
|
||||
# the "another dwelling below" party signal overrides it downward.
|
||||
part_has_exposed_floor = (
|
||||
exposure.has_exposed_floor or is_exposed_floor
|
||||
exposure.has_exposed_floor or is_exposed_floor or is_above_partial
|
||||
) and not part_floor_is_party
|
||||
floor_area_total = _round_half_up(
|
||||
geom["ground_floor_area_m2"] if part_has_exposed_floor else 0.0,
|
||||
|
|
|
|||
|
|
@ -370,9 +370,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="7536-3827-0600-0600-0276",
|
||||
actual_sap=68,
|
||||
expected_sap_resid=+1,
|
||||
expected_pe_resid_kwh_per_m2=-6.1952,
|
||||
expected_co2_resid_tonnes_per_yr=-0.1639,
|
||||
expected_pe_resid_kwh_per_m2=-5.6414,
|
||||
expected_co2_resid_tonnes_per_yr=-0.1492,
|
||||
notes=(
|
||||
"FLOOR-CODE-3 SLICE (re-pinned): the prior 'residual is "
|
||||
"irreducible register-rounding, DO NOT chase' conclusion below "
|
||||
"was WRONG. Ext2 (bp3) lodges floor_heat_loss=3 = '(other "
|
||||
"premises below)' — confirmed authoritative 9/9 on single-bp "
|
||||
"certs (code 1↔'To external air', 2↔'To unheated space', "
|
||||
"3↔'(other premises below)', 6↔'(another dwelling below)', "
|
||||
"7↔Solid/Suspended). Per RdSAP 10 §3.12 (PDF p.25) that is "
|
||||
"'above a partially heated space if there are non-domestic "
|
||||
"premises below' → the §5.14 constant U=0.7 W/m²K, NOT the "
|
||||
"ground-floor 1.12 the case-15/17 repro assumed (the cert's "
|
||||
"lossy floors[] summary dropped bp3's description, so the prior "
|
||||
"agent mis-read code 3 as 'ground'). Fix routes code 3 → "
|
||||
"is_above_partially_heated_space: Ext2 floor U 1.12 → 0.70, "
|
||||
"PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), "
|
||||
"SAP integer 69 unchanged → resid +1. HISTORICAL NOTES BELOW. "
|
||||
"Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, "
|
||||
"Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and "
|
||||
"Slice 60 (dwelling-wide thermal bridging y from primary bp's "
|
||||
|
|
|
|||
|
|
@ -1033,6 +1033,41 @@ def test_exposed_floor_on_flat_carries_heat_loss_despite_unexposed_flag() -> Non
|
|||
assert result.floor_w_per_k == pytest.approx(21.6, abs=0.1)
|
||||
|
||||
|
||||
def test_above_partially_heated_floor_on_flat_carries_07_loss_despite_unexposed_flag() -> None:
|
||||
# Arrange — a mid-/top-floor flat whose lowest floor is lodged "above a
|
||||
# partially heated space" (API floor_heat_loss=3, "(other premises
|
||||
# below)") sits over non-domestic premises heated at different times.
|
||||
# RdSAP 10 §3.12 + §5.14 (PDF p.25/47) give such a floor the constant
|
||||
# U=0.7 W/m²K. As with the exposed-floor case, the dwelling-level flat
|
||||
# heuristic defaults has_exposed_floor=False (assuming a heated dwelling
|
||||
# below); the per-BP `is_above_partially_heated_space` lodgement is
|
||||
# authoritative and overrides the suppression upward.
|
||||
main = make_building_part(
|
||||
construction_age_band="B",
|
||||
wall_construction=4, wall_insulation_type=4,
|
||||
party_wall_construction=1, roof_construction=4,
|
||||
floor_type="(other premises below)",
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
main.sap_floor_dimensions[0].is_above_partially_heated_space = True
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[main],
|
||||
)
|
||||
|
||||
# Act — dwelling-level exposure flags the floor as NOT exposed (flat).
|
||||
result = heat_transmission_from_cert(
|
||||
epc, exposure=DwellingExposure(has_exposed_floor=False, has_exposed_roof=True),
|
||||
)
|
||||
|
||||
# Assert — §5.14 constant U=0.7 × 50 m² = 35.0 W/K, not the suppressed 0.0.
|
||||
assert abs(result.floor_w_per_k - 35.0) <= 0.1
|
||||
|
||||
|
||||
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