fix(heat-transmission): bill a ground-floor flat's ground floor (RdSAP 10 §3.12)

The flat floor-exposure heuristic keys on dwelling_type: a flat defaults
to has_exposed_floor=False (assuming a heated dwelling below). The
Elmhurst Summary path lodges a ground-floor flat's vertical position as a
"Ground floor" floor_type rather than the API floor_heat_loss=1 exposed
code, and the mapper can label such a flat "Top-floor flat" — so the
cascade dropped the ground floor entirely (a ground floor is in contact
with the ground and carries heat loss).

Treat a "ground floor" floor_type as a heat-loss floor, overriding the
dwelling-level suppression upward — mirroring the existing "another
dwelling below" party override downward.

Worksheet-validated to 1e-4 on simulated case 45 (a ground-floor flat
the mapper labelled "Top-floor flat"): floor (28a) 0 -> 25.38 W/K,
fabric (33) 75.63 -> 101.0104, HTC (39) 112.93 -> 145.3579, all matching
the P960 exactly; SAP 67.81 -> 62.52. RdSAP-21.0.1 corpus within-0.5
69.5% -> 69.7% (MAE 0.859 -> 0.854). Floors ratcheted. Pinned in
test_heat_transmission (ground-floor billed + party-floor suppressed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-18 13:16:44 +00:00
parent e136e937d6
commit 9b0c590bf8
3 changed files with 84 additions and 1 deletions

View file

@ -1105,6 +1105,16 @@ 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 part whose floor_type is a GROUND floor sits in contact with the
# ground (RdSAP 10 §3.12) and is therefore a heat-loss floor, even when
# the dwelling-level flat heuristic (`_dwelling_exposure`) defaults a
# flat to has_exposed_floor=False. The Elmhurst Summary path lodges a
# ground-floor flat's position as a "Ground floor" floor_type (not the
# API floor_heat_loss=1 exposed code), so without this signal the
# cascade dropped its ground floor entirely — simulated case 45 (a
# ground-floor flat the mapper labelled "Top-floor flat"): worksheet
# (28a) = 47.0 × 0.54 = 25.38 W/K billed as 0, over-rating by +7 SAP.
part_floor_is_ground = "ground floor" in (part.floor_type or "").lower()
# 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
@ -1117,6 +1127,7 @@ def heat_transmission_from_cert(
# the "another dwelling below" party signal overrides it downward.
part_has_exposed_floor = (
exposure.has_exposed_floor or is_exposed_floor or is_above_partial
or part_floor_is_ground
) 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,

View file

@ -157,6 +157,70 @@ def test_mixed_flat_pitched_roof_does_not_contaminate_pitched_u_value() -> None:
assert abs(result.roof_w_per_k - 44.6) <= 2.0
def test_ground_floor_flat_bills_floor_despite_flat_dwelling_type() -> None:
# Arrange — a ground-floor flat whose dwelling_type the mapper labelled
# "Top-floor flat" (so the dwelling-level exposure heuristic
# `_dwelling_exposure` suppresses the floor on the assumption a heated
# dwelling sits below), but whose building part lodges a "Ground floor"
# floor_type. A ground floor is in contact with the ground (RdSAP 10
# §3.12) -> heat-loss floor. The Elmhurst Summary path lodges this as a
# "Ground floor" floor_type (not the API floor_heat_loss=1 exposed code),
# so without the per-part ground signal the cascade dropped the floor.
# Worksheet-validated by simulated case 45: (28a) = 47.0 × U=0.54 = 25.38
# W/K, billed as 0 before this fix (+7 SAP).
ground = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="C",
floor_type="Ground floor",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=47.0, room_height_m=2.4,
heat_loss_perimeter_m=15.8, party_wall_length_m=0.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=47.0, country_code="ENG",
dwelling_type="Top-floor flat", property_type="Flat",
sap_building_parts=[ground],
)
# Act
result = heat_transmission_from_cert(epc)
# Assert — the ground floor carries heat loss (≈ 47 × 0.54), not 0.
assert result.floor_w_per_k > 20.0
def test_top_floor_flat_with_party_floor_stays_suppressed() -> None:
# Arrange — the contrast: a flat lodging "(another dwelling below)" sits
# over a heated dwelling, so its floor is a party floor with no heat loss
# (RdSAP 10 §3). The ground-floor override must NOT fire — proving the
# discriminator is the floor_type, not the flat label.
party = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="C",
floor_type="To another dwelling below",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=47.0, room_height_m=2.4,
heat_loss_perimeter_m=15.8, party_wall_length_m=0.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=47.0, country_code="ENG",
dwelling_type="Top-floor flat", property_type="Flat",
sap_building_parts=[party],
)
# Act
result = heat_transmission_from_cert(epc)
# Assert — party floor, no heat loss.
assert result.floor_w_per_k == 0.0
def test_part_geometry_floorless_part_honours_full_key_contract() -> None:
# Arrange — a building part lodged with NO sap_floor_dimensions (e.g.
# a party-wall-only or RR-only extension; observed on 5 certs in a

View file

@ -119,7 +119,15 @@ _CORPUS = Path(
# 100010129331 (roof 110.5 -> 31.3 W/K, +13.1 -> -0.05 SAP). within-0.5
# 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 -> 13.6); 3-part cohort 56% ->
# 61%. Pinned in test_heat_transmission (by_kind split + no-contamination).
_MIN_WITHIN_HALF_SAP = 0.69
# GROUND-FLOOR FLAT FLOOR EXPOSURE (RdSAP 10 §3.12): a ground-floor flat whose
# dwelling_type the mapper labelled "Top-floor flat" had its ground floor (in
# contact with the ground -> heat loss) dropped, because the flat exposure
# heuristic keys on dwelling_type and the Summary path lodges the position as a
# "Ground floor" floor_type (not the API floor_heat_loss=1 code). Treating a
# "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated
# case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% ->
# 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission.
_MIN_WITHIN_HALF_SAP = 0.695
_MAX_SAP_MAE = 0.86
_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current