mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Slice S0380.27: thread floor_construction_type into _main_floor_u_value — closes cert 9796 +0.55 → +0.00174
Per RdSAP10 §5 page 29 "Floor infiltration (suspended timber ground
floor only)":
Age band A-E:
a) if floor U-value < 0.5, assume "sealed" → 0.1
b) if retro-fit + no U → "sealed" → 0.1
otherwise "unsealed" → 0.2
The cascade routes the (12) sealed/unsealed verdict through
`_main_floor_u_value`, which calls `u_floor` to compute the BS EN ISO
13370 U-value the spec rule keys on. That helper was a stale duplicate
of the real heat-transmission path that did NOT respect the per-bp
`floor_construction_type` lodgement:
Pre-slice: u_floor(construction=int_or_None, description=None, ...)
Cascade: u_floor(construction=int_or_None, description="Suspended
timber" if floor_construction_type else <fallback>, ...)
For cert 9796-3058-6205-0346-9200 (Mid-Terrace bungalow age D,
46.87 m² / 15.0 m perimeter, suspended-timber lodged):
- Broken `_main_floor_u_value` routes through the solid default
(no description, construction=None) → BS EN ISO 13370 solid →
U=0.49 W/m²K.
- 0.49 < 0.5 → spec rule (a) fires → (12) = 0.1 (sealed).
- Real heat-transmission cascade routes through the suspended branch
via `effective_floor_description = floor_construction_type` →
U=0.56 → unsealed → (12) = 0.2.
The 0.1 ach gap then propagated:
(18) infiltration_rate 0.74 → ws 0.84 (cascade -0.10)
(25)m Jan 0.82 → ws 0.91 (cascade -0.09)
(38)m Jan 29.08 W/K → ws 32.37 (cascade -3.29 W/K)
(39) Jan 110.35 W/K → ws 113.64 (cascade -3.29 W/K)
HLP Jan 2.35 W/m²K → ws 2.42 (cascade -0.07)
T_h2 Jan 19.11°C → ws 19.07 (cascade +0.04)
MIT Jan 18.51°C → ws 18.45 (cascade +0.06)
SAP +0.55 vs worksheet 90.13.
Fix mirrors heat_transmission's `effective_floor_description` rule in
`_main_floor_u_value`: the per-bp `floor_construction_type` takes
precedence over a joined `epc.floors[].description` because it's the
explicit Elmhurst Summary §3/§9 surface. Inlined the description join
(vs importing `_joined_descriptions` from heat_transmission) so
cert_to_inputs stays free of cross-module private-symbol imports.
Cohort-2 outcome (38 certs, Summary path):
exact (<1e-4): 23 → 23
≤±0.07: 14 → **15** (+1: cert 9796 +0.55 → +0.00174)
±0.5..1: 1 → **0** (last cohort-2 mid-range gap closes)
The remaining cert 9796 +0.00174 SAP residual is the cohort-1 HP-COP
precision floor (the same +0.001..+0.04 SAP that the other 10
triple-glazed HP certs sit at; see handover thread 3).
Cohort-1 golden fixture cert 8135-1728-8500-0511-3296 (Semi-detached
age C, suspended-timber ground floor with floor_construction=2 lodged
but description=None pre-slice) had the same bug:
Pre-slice: u_floor returned 0.48 (solid branch via construction=2
present-but-not-suspended) → false sealed verdict (12)=0.1
Post-slice: u_floor returns 0.54 (suspended branch via description=
"Suspended timber") → correct unsealed verdict (12)=0.2
PE residual: -4.9611 → **-0.0748** kWh/m² (+4.89 closer to API EPC)
CO2 residual: -0.0678 → **+0.0246** t/yr (closer to API EPC)
SAP residual: 0 → 0 (unchanged, EPC integer)
Pin updated on cert 8135 to reflect the new (correct) cascade-vs-API
alignment; no other golden fixtures shifted.
Pyright net-zero per touched file:
cert_to_inputs.py: 35 → 35
tests/test_cert_to_inputs.py: 13 → 12 (suppressed pre-existing
private-import error on
_water_heating_worksheet_and_gains
at the same time as adding
suppressions for the two new
private imports)
tests/test_golden_fixtures.py: 1 → 1
tests/test_summary_pdf_mapper_chain.py: 0 → 0
Tests: 708 → 710 pass (+2 new: `_main_floor_u_value` routes
suspended-timber via per-bp lodgement; cert 9796 chain pin against
worksheet 90.1318 within ±0.07 ASHP-cohort spec floor), 10 expected
fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c144d444e2
commit
012cbd183f
4 changed files with 137 additions and 5 deletions
|
|
@ -340,6 +340,57 @@ def test_summary_2102_secondary_heating_routes_house_coal_for_open_fire() -> Non
|
||||||
assert epc.sap_heating.secondary_fuel_type == 11
|
assert epc.sap_heating.secondary_fuel_type == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_9796_full_chain_sap_within_spec_floor_of_worksheet() -> None:
|
||||||
|
# Arrange — cohort-2 cert 9796-3058-6205-0346-9200 (Summary_*.pdf /
|
||||||
|
# dr87-0001-*.pdf) is a Mid-Terrace bungalow age D with a Mitsubishi
|
||||||
|
# PUZ-WM50VHA ASHP (PCDB 104568) and a Suspended-timber ground floor
|
||||||
|
# (46.87 m² / 15.0 m heat-loss perimeter). The other PCDF 104568
|
||||||
|
# cohort certs (0380, 2800, 3336, 4800) are End-Terrace bungalows
|
||||||
|
# whose floor U lands well above 0.5; cert 9796's geometry is the
|
||||||
|
# only one where the (broken) cascade routes the U through the solid
|
||||||
|
# default → U=0.49 < 0.5 → spec rule (a) "U<0.5 → sealed" fires →
|
||||||
|
# (12) = 0.1 (sealed) instead of (12) = 0.2 (unsealed).
|
||||||
|
#
|
||||||
|
# Per RdSAP10 §5 page 29 "Floor infiltration (suspended timber
|
||||||
|
# ground floor only)":
|
||||||
|
# Age band A-E:
|
||||||
|
# a) if floor U-value < 0.5, assume "sealed" → 0.1
|
||||||
|
# b) if retro-fit + no U → "sealed" → 0.1
|
||||||
|
# otherwise "unsealed" → 0.2
|
||||||
|
# The cascade must use the SAME floor U-value the heat-transmission
|
||||||
|
# cascade computes (which respects `floor_construction_type`) — not
|
||||||
|
# a stale duplicate that ignores the per-bp lodgement.
|
||||||
|
#
|
||||||
|
# Pre-slice the 0.1 ach gap propagated:
|
||||||
|
# (18) infiltration_rate 0.74 → ws 0.84 (cascade -0.10)
|
||||||
|
# (25)m Jan 0.82 → ws 0.91 (cascade -0.09)
|
||||||
|
# (38)m Jan 29.08 W/K → ws 32.37 (cascade -3.29 W/K)
|
||||||
|
# (39) Jan 110.35 W/K → ws 113.64 (cascade -3.29 W/K)
|
||||||
|
# HLP Jan 2.35 W/m²K → ws 2.42 (cascade -0.07)
|
||||||
|
# T_h2 Jan 19.11°C → ws 19.07 (cascade +0.04)
|
||||||
|
# MIT Jan 18.51°C → ws 18.45 (cascade +0.06)
|
||||||
|
# SAP +0.55 vs worksheet 90.13.
|
||||||
|
# Worksheet "SAP value" line lodges unrounded SAP **90.1318**.
|
||||||
|
cert_dir = Path(
|
||||||
|
"sap worksheets/additional with api 2/9796-3058-6205-0346-9200"
|
||||||
|
)
|
||||||
|
summary_pdf = next(cert_dir.glob("Summary_*.pdf"))
|
||||||
|
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
|
||||||
|
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||||
|
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = calculate_sap_from_inputs(
|
||||||
|
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — ±0.07 ASHP-cohort spec-floor tolerance (matches the other
|
||||||
|
# PCDF 104568 cohort residuals; the remaining ~+0.001 SAP delta is
|
||||||
|
# the cohort-1 HP-COP precision-floor pattern, see handover thread 3).
|
||||||
|
worksheet_unrounded_sap = 90.1318
|
||||||
|
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < _ASHP_COHORT_CHAIN_TOLERANCE
|
||||||
|
|
||||||
|
|
||||||
def test_summary_7700_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
def test_summary_7700_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||||
# Arrange — cohort-2 cert 7700-3362-0922-7022-3563 (Summary_000905.pdf
|
# Arrange — cohort-2 cert 7700-3362-0922-7022-3563 (Summary_000905.pdf
|
||||||
# / dr87-0001-000905.pdf) is the first cohort fixture to exercise
|
# / dr87-0001-000905.pdf) is the first cohort fixture to exercise
|
||||||
|
|
|
||||||
|
|
@ -1667,6 +1667,18 @@ def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]:
|
||||||
|
|
||||||
Used by `_has_suspended_timber_floor_per_spec` to apply the RdSAP 10
|
Used by `_has_suspended_timber_floor_per_spec` to apply the RdSAP 10
|
||||||
§5 (12) rule, which keys on whether the floor U-value < 0.5 W/m²K.
|
§5 (12) rule, which keys on whether the floor U-value < 0.5 W/m²K.
|
||||||
|
|
||||||
|
Mirrors the `effective_floor_description` rule from
|
||||||
|
`heat_transmission_section_from_cert`: the per-bp
|
||||||
|
`floor_construction_type` lodgement ("Suspended timber" / "Solid")
|
||||||
|
takes precedence over the global `epc.floors[].description` because
|
||||||
|
it's the explicit per-part Elmhurst Summary §3/§9 lodgement. Without
|
||||||
|
it the cascade routes via `_DEFAULT_FLOOR_BY_AGE` (solid) and can
|
||||||
|
return a low U on geometries where the BS EN ISO 13370 calc gives
|
||||||
|
<0.5, incorrectly triggering RdSAP10 §5 (12) rule (a) "U<0.5 →
|
||||||
|
sealed" for what is actually a suspended-timber floor (cert 9796
|
||||||
|
fixture: cascade U=0.49 routed through solid default vs the real
|
||||||
|
suspended-timber U=0.56 — the worksheet's (12)=0.2 unsealed).
|
||||||
"""
|
"""
|
||||||
if not epc.sap_building_parts:
|
if not epc.sap_building_parts:
|
||||||
return None
|
return None
|
||||||
|
|
@ -1682,6 +1694,21 @@ def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]:
|
||||||
int(raw_floor_ins) if isinstance(raw_floor_ins, (int, float))
|
int(raw_floor_ins) if isinstance(raw_floor_ins, (int, float))
|
||||||
else (0 if raw_floor_ins == "NI" else None)
|
else (0 if raw_floor_ins == "NI" else None)
|
||||||
)
|
)
|
||||||
|
# Mirror heat_transmission's `effective_floor_description`: the per-bp
|
||||||
|
# `floor_construction_type` takes precedence over a joined
|
||||||
|
# `epc.floors[].description` since the per-part lodgement is the
|
||||||
|
# explicit Elmhurst Summary §3/§9 surface. Inline the join (vs
|
||||||
|
# importing from heat_transmission) to keep cert_to_inputs free of
|
||||||
|
# cross-module private symbol imports.
|
||||||
|
if main.floor_construction_type:
|
||||||
|
effective_floor_description = main.floor_construction_type
|
||||||
|
else:
|
||||||
|
descs = [
|
||||||
|
d for d in
|
||||||
|
(getattr(f, "description", None) for f in (epc.floors or []))
|
||||||
|
if d
|
||||||
|
]
|
||||||
|
effective_floor_description = " | ".join(descs) if descs else None
|
||||||
return u_floor(
|
return u_floor(
|
||||||
country=Country.from_code(epc.country_code) if epc.country_code else None,
|
country=Country.from_code(epc.country_code) if epc.country_code else None,
|
||||||
age_band=main.construction_age_band,
|
age_band=main.construction_age_band,
|
||||||
|
|
@ -1690,7 +1717,7 @@ def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]:
|
||||||
area_m2=ground_fd.total_floor_area_m2,
|
area_m2=ground_fd.total_floor_area_m2,
|
||||||
perimeter_m=ground_fd.heat_loss_perimeter_m,
|
perimeter_m=ground_fd.heat_loss_perimeter_m,
|
||||||
wall_thickness_mm=main.wall_thickness_mm,
|
wall_thickness_mm=main.wall_thickness_mm,
|
||||||
description=getattr(main, "floors_description", None),
|
description=effective_floor_description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ from domain.sap10_ml.tests._fixtures import (
|
||||||
)
|
)
|
||||||
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
|
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
|
||||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
_water_heating_worksheet_and_gains,
|
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
|
||||||
|
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
|
||||||
|
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
|
||||||
cert_to_demand_inputs,
|
cert_to_demand_inputs,
|
||||||
cert_to_inputs,
|
cert_to_inputs,
|
||||||
pcdb_combi_loss_override,
|
pcdb_combi_loss_override,
|
||||||
|
|
@ -541,6 +543,50 @@ def test_pv_generation_uses_postcode_climate_in_demand_cascade() -> None:
|
||||||
assert rating_gen != demand_gen
|
assert rating_gen != demand_gen
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_floor_u_value_routes_suspended_timber_via_floor_construction_type() -> None:
|
||||||
|
"""`_main_floor_u_value` must mirror the heat_transmission cascade's
|
||||||
|
`effective_floor_description` rule: when the per-bp
|
||||||
|
`floor_construction_type` lodgement is set ("Suspended timber" /
|
||||||
|
"Solid"), it overrides any global `epc.floors[].description`. Without
|
||||||
|
the override the cascade routes through `_DEFAULT_FLOOR_BY_AGE`
|
||||||
|
(solid) and can return U<0.5 on geometries where the real suspended-
|
||||||
|
timber U is ≥0.5, incorrectly triggering RdSAP10 §5 (12) rule (a)
|
||||||
|
"U<0.5 → sealed" for what is in fact an unsealed suspended-timber
|
||||||
|
floor (cert 9796 fixture: 46.87 m² / 15.0 m perimeter age D)."""
|
||||||
|
from dataclasses import replace
|
||||||
|
# Arrange — cert 9796 geometry: age D, 46.87 m² ground floor, 15.0 m
|
||||||
|
# heat-loss perimeter. The solid-default cascade returns U≈0.49 on
|
||||||
|
# this geometry; the suspended-timber cascade returns U≈0.56.
|
||||||
|
main_with_suspended_floor = replace(
|
||||||
|
make_building_part(
|
||||||
|
construction_age_band="D",
|
||||||
|
floor_dimensions=[
|
||||||
|
make_floor_dimension(
|
||||||
|
total_floor_area_m2=46.87, room_height_m=2.3,
|
||||||
|
party_wall_length_m=12.5, heat_loss_perimeter_m=15.0,
|
||||||
|
floor=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floor_type="Ground floor",
|
||||||
|
floor_construction_type="Suspended timber",
|
||||||
|
)
|
||||||
|
epc_suspended = make_minimal_sap10_epc(
|
||||||
|
total_floor_area_m2=46.87, country_code="ENG",
|
||||||
|
sap_building_parts=[main_with_suspended_floor],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
u_suspended = _main_floor_u_value(epc_suspended)
|
||||||
|
has_susp, sealed = _has_suspended_timber_floor_per_spec(epc_suspended)
|
||||||
|
|
||||||
|
# Assert — U lands on the suspended-timber cascade row, ≥0.5 → spec
|
||||||
|
# rule (a) does NOT fire → (12) = 0.2 (unsealed).
|
||||||
|
assert u_suspended is not None and u_suspended >= 0.5
|
||||||
|
assert has_susp is True
|
||||||
|
assert sealed is False
|
||||||
|
|
||||||
|
|
||||||
def test_open_chimneys_raise_infiltration_ach() -> None:
|
def test_open_chimneys_raise_infiltration_ach() -> None:
|
||||||
# Arrange — Direction check: chimneys add Table 2.1 volume to the
|
# Arrange — Direction check: chimneys add Table 2.1 volume to the
|
||||||
# infiltration calc, so an otherwise identical dwelling with 2 open
|
# infiltration calc, so an otherwise identical dwelling with 2 open
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
||||||
cert_number="8135-1728-8500-0511-3296",
|
cert_number="8135-1728-8500-0511-3296",
|
||||||
actual_sap=72,
|
actual_sap=72,
|
||||||
expected_sap_resid=+0,
|
expected_sap_resid=+0,
|
||||||
expected_pe_resid_kwh_per_m2=-4.9611,
|
expected_pe_resid_kwh_per_m2=-0.0748,
|
||||||
expected_co2_resid_tonnes_per_yr=-0.0678,
|
expected_co2_resid_tonnes_per_yr=+0.0246,
|
||||||
notes=(
|
notes=(
|
||||||
"Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges "
|
"Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges "
|
||||||
"blocked_chimneys_count=1. Slice 59 per-bp window apportionment "
|
"blocked_chimneys_count=1. Slice 59 per-bp window apportionment "
|
||||||
|
|
@ -185,7 +185,15 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
||||||
"SAP residual unchanged (cert rounds to 72 either way); PE "
|
"SAP residual unchanged (cert rounds to 72 either way); PE "
|
||||||
"-2.41 → -5.31 and CO2 -0.02 → -0.07. Slice 102f-prep.8: "
|
"-2.41 → -5.31 and CO2 -0.02 → -0.07. Slice 102f-prep.8: "
|
||||||
"shower_outlets=None → 0 mixers shifts PE -3.66 → -4.96, "
|
"shower_outlets=None → 0 mixers shifts PE -3.66 → -4.96, "
|
||||||
"CO2 -0.04 → -0.07."
|
"CO2 -0.04 → -0.07. Slice S0380.27: cert lodges "
|
||||||
|
"`floor_construction_type=Suspended timber` + age C with "
|
||||||
|
"geometry that gives solid-cascade U≈0.48 (false sealed "
|
||||||
|
"verdict via spec rule (a)) vs the real suspended-cascade "
|
||||||
|
"U=0.54 (correct unsealed verdict, (12) = 0.2). Threading "
|
||||||
|
"the lodged floor_construction_type into _main_floor_u_value "
|
||||||
|
"(now mirroring heat_transmission's effective_floor_description "
|
||||||
|
"rule) closes PE -4.96 → -0.07 and CO2 -0.07 → +0.02 — "
|
||||||
|
"cohort-wide cascade-vs-API alignment at <0.1 kWh/m² PE."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_GoldenExpectation(
|
_GoldenExpectation(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue