mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice S-B27: Table 19 footnote (2) — floor "NI" + insulated description
The cert's `floor_insulation_thickness` field carries "NI" (Not
Indicated) on 58% of corpus certs — by far the most common value. For
~2 413 of those (12% of corpus) the description also says "Solid,
insulated (assumed)" or "Suspended, insulated (assumed)" — the
assessor saw insulation but didn't measure the thickness. Our
`_parse_thickness_mm("NI")` returns 0, which feeds `u_floor` as an
explicit "0 mm" → r_f=0 → uninsulated-floor U-value. Wrong.
RdSAP 10 §5.12 Table 19 footnote (2) (page 46): "For floors which
have retrofitted insulation, use the greater of 50 mm and the
thickness according to the age band". `u_floor` now accepts a
`description` kwarg; when `_described_as_insulated(description)` is
true and the lodged thickness is missing/zero, ins_mm =
max(50, age-band default).
Geometry sanity-check, 100 m² × 40 m perimeter, w=0.3 (B=5):
- Uninsulated solid floor: d_t = 0.615, U = 0.60 W/m²K
- 50 mm assumption: d_t = 2.758, U = 0.31 W/m²K
Parity probe at 300 certs, seed=7:
PE MAE 45.37 → 44.19 (-1.18)
PE bias 39.75 → 38.56 (-1.19)
Band J bias +41.2 → +29.7 (-11.5)
Band K bias +34.1 → +22.4 (-11.7)
Band L bias +19.6 → +11.3 (-8.3)
Band M bias +86.3 → +55.1 (-31.2)
Bands A-H mostly unchanged (max(50, 0) = 50 either way; description
overrides on older stock are rarer in this sample)
The K-L-M dwellings improved most because for them the age-band
default insulation (100-140 mm) is now applied instead of 0 mm.
Cumulative across S-B23 → S-B27:
PE MAE 57.28 → 44.19 (-13.09)
PE bias 51.56 → 38.56 (-13.00)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
361f91546b
commit
1f49fa03cd
4 changed files with 120 additions and 2 deletions
|
|
@ -455,11 +455,21 @@ def u_floor(
|
|||
area_m2: Optional[float],
|
||||
perimeter_m: Optional[float],
|
||||
wall_thickness_mm: Optional[int],
|
||||
description: Optional[str] = None,
|
||||
) -> float:
|
||||
"""RdSAP10 ground-floor U-value via BS EN ISO 13370 solid-floor branch.
|
||||
|
||||
Suspended-floor branch is approximated as solid since the difference at
|
||||
the feature-engineering granularity is < 0.1 W/m^2K for typical UK floors.
|
||||
|
||||
`description` is the joined surveyor text from `floors[i].description`.
|
||||
When it asserts retrofit insulation ("Solid, insulated (assumed)" /
|
||||
"Suspended, insulated (assumed)" / similar) and the assessor hasn't
|
||||
lodged a numeric thickness, RdSAP 10 §5.12 Table 19 footnote (2)
|
||||
applies: "use the greater of 50 mm and the thickness according to the
|
||||
age band". The cert encodes "thickness unknown" as either a missing
|
||||
field (`insulation_thickness_mm=None`) or "NI" which
|
||||
`_parse_thickness_mm` returns as 0.
|
||||
"""
|
||||
if area_m2 is None or perimeter_m is None or perimeter_m <= 0:
|
||||
return 0.7
|
||||
|
|
@ -468,8 +478,17 @@ def u_floor(
|
|||
r_si = 0.17
|
||||
r_se = 0.04
|
||||
ins_mm = insulation_thickness_mm
|
||||
if ins_mm is None and age_band is not None:
|
||||
ins_mm = _FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0)
|
||||
age_default_mm = (
|
||||
_FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0)
|
||||
if age_band is not None
|
||||
else 0
|
||||
)
|
||||
if ins_mm is None:
|
||||
ins_mm = age_default_mm
|
||||
if (ins_mm is None or ins_mm == 0) and _described_as_insulated(description):
|
||||
# Table 19 footnote (2): "use the greater of 50 mm and the
|
||||
# thickness according to the age band".
|
||||
ins_mm = max(50, age_default_mm)
|
||||
r_f = ((ins_mm or 0) / 1000.0) / 0.035
|
||||
d_t = w + soil_g * (r_si + r_f + r_se)
|
||||
b = 2.0 * area_m2 / perimeter_m
|
||||
|
|
|
|||
|
|
@ -578,6 +578,60 @@ def test_u_roof_explicit_thickness_beats_description() -> None:
|
|||
# ----- Floors -----
|
||||
|
||||
|
||||
def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None:
|
||||
# Arrange — 2 413 corpus certs (~12%) lodge floors with
|
||||
# floor_insulation_thickness="NI" (Not Indicated, which our
|
||||
# _parse_thickness_mm returns as 0) AND a description "Solid,
|
||||
# insulated (assumed)" or "Suspended, insulated (assumed)". The
|
||||
# assessor sees insulation but hasn't measured the thickness.
|
||||
# RdSAP 10 §5.12 Table 19 footnote (2):
|
||||
# "For floors which have retrofitted insulation, use the greater
|
||||
# of 50 mm and the thickness according to the age band."
|
||||
# Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies.
|
||||
# Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758
|
||||
# (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1)
|
||||
# / (π×5 + 2.758) ≈ 0.31 W/m²K.
|
||||
|
||||
# Act
|
||||
result = u_floor(
|
||||
country=Country.ENG,
|
||||
age_band="B",
|
||||
construction=None,
|
||||
insulation_thickness_mm=0, # parsed from "NI"
|
||||
area_m2=100.0,
|
||||
perimeter_m=40.0,
|
||||
wall_thickness_mm=300,
|
||||
description="Solid, insulated (assumed)",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.31, abs=0.02)
|
||||
|
||||
|
||||
def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None:
|
||||
# Arrange — 8 221 corpus certs lodge "Solid, no insulation
|
||||
# (assumed)" with thickness="NI". The Table 19 footnote (2) override
|
||||
# must not fire on these: the "no insulation" substring takes
|
||||
# precedence over the "insulated" substring per
|
||||
# `_described_as_insulated`. Same geometry as the cycle-1 test;
|
||||
# uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0).
|
||||
|
||||
# Act
|
||||
result = u_floor(
|
||||
country=Country.ENG,
|
||||
age_band="B",
|
||||
construction=None,
|
||||
insulation_thickness_mm=0, # parsed from "NI"
|
||||
area_m2=100.0,
|
||||
perimeter_m=40.0,
|
||||
wall_thickness_mm=300,
|
||||
description="Solid, no insulation (assumed)",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.60, abs=0.02)
|
||||
|
||||
|
||||
def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None:
|
||||
# Arrange — solid floor, age C, England.
|
||||
# BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ def heat_transmission_from_cert(
|
|||
country = Country.from_code(epc.country_code)
|
||||
roof_description = _joined_descriptions(epc.roofs)
|
||||
wall_description = _joined_descriptions(epc.walls)
|
||||
floor_description = _joined_descriptions(epc.floors)
|
||||
|
||||
door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2
|
||||
window_u = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window(
|
||||
|
|
@ -233,6 +234,7 @@ def heat_transmission_from_cert(
|
|||
insulation_thickness_mm=floor_ins_thickness,
|
||||
area_m2=floor_area, perimeter_m=floor_perimeter,
|
||||
wall_thickness_mm=part.wall_thickness_mm,
|
||||
description=floor_description,
|
||||
)
|
||||
upw = u_party_wall(party_wall_construction=party_construction)
|
||||
y = thermal_bridging_y(age_band=age_band)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,49 @@ from domain.sap.worksheet.heat_transmission import (
|
|||
)
|
||||
|
||||
|
||||
def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnote() -> None:
|
||||
# Arrange — 2 413 corpus certs lodge floors with thickness="NI" and
|
||||
# description "Solid, insulated (assumed)". The retrofit-insulation
|
||||
# signal in the description must flow from epc.floors[0].description
|
||||
# through heat_transmission_from_cert into u_floor so the 50 mm
|
||||
# assumption fires per RdSAP 10 §5.12 Table 19 footnote (2).
|
||||
# Geometry: 100 m² floor, 40 m perimeter, w=0.3 → B=5, U ≈ 0.31.
|
||||
# floor_w_per_k expected = 0.31 × 100 ≈ 31 W/K (cf. uninsulated
|
||||
# case which would give 0.60 × 100 = 60 W/K).
|
||||
main = make_building_part(
|
||||
identifier="Main Dwelling",
|
||||
construction_age_band="B",
|
||||
wall_construction=3,
|
||||
wall_insulation_type=4,
|
||||
party_wall_construction=1,
|
||||
roof_construction=4,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=100.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=100.0,
|
||||
country_code="ENG",
|
||||
sap_building_parts=[main],
|
||||
)
|
||||
epc.floors = [
|
||||
EnergyElement(
|
||||
description="Solid, insulated (assumed)",
|
||||
energy_efficiency_rating=3,
|
||||
environmental_efficiency_rating=3,
|
||||
),
|
||||
]
|
||||
|
||||
# Act
|
||||
result = heat_transmission_from_cert(epc)
|
||||
|
||||
# Assert
|
||||
assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0)
|
||||
|
||||
|
||||
def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None:
|
||||
# Arrange — 128 corpus certs lodge solid-brick walls with
|
||||
# wall_insulation_type=4 ("as-built / assumed") AND description
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue