mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
e136e937d6
commit
9b0c590bf8
3 changed files with 84 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue