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:
Khalim Conn-Kowlessar 2026-05-28 11:24:59 +00:00
parent c144d444e2
commit 012cbd183f
4 changed files with 137 additions and 5 deletions

View file

@ -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
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:
# Arrange — cohort-2 cert 7700-3362-0922-7022-3563 (Summary_000905.pdf
# / dr87-0001-000905.pdf) is the first cohort fixture to exercise

View file

@ -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
§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:
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))
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(
country=Country.from_code(epc.country_code) if epc.country_code else None,
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,
perimeter_m=ground_fd.heat_loss_perimeter_m,
wall_thickness_mm=main.wall_thickness_mm,
description=getattr(main, "floors_description", None),
description=effective_floor_description,
)

View file

@ -33,7 +33,9 @@ from domain.sap10_ml.tests._fixtures import (
)
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
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_inputs,
pcdb_combi_loss_override,
@ -541,6 +543,50 @@ def test_pv_generation_uses_postcode_climate_in_demand_cascade() -> None:
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 / 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:
# Arrange — Direction check: chimneys add Table 2.1 volume to the
# infiltration calc, so an otherwise identical dwelling with 2 open

View file

@ -175,8 +175,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="8135-1728-8500-0511-3296",
actual_sap=72,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-4.9611,
expected_co2_resid_tonnes_per_yr=-0.0678,
expected_pe_resid_kwh_per_m2=-0.0748,
expected_co2_resid_tonnes_per_yr=+0.0246,
notes=(
"Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges "
"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 "
"-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, "
"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(