From 012cbd183fc31b7d44664e24e82976d8095bbe7f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 11:24:59 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.27:=20thread=20floor=5Fconstructi?= =?UTF-8?q?on=5Ftype=20into=20=5Fmain=5Ffloor=5Fu=5Fvalue=20=E2=80=94=20cl?= =?UTF-8?q?oses=20cert=209796=20+0.55=20=E2=86=92=20+0.00174?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , ...) 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 --- .../tests/test_summary_pdf_mapper_chain.py | 51 +++++++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 29 ++++++++++- .../rdsap/tests/test_cert_to_inputs.py | 48 ++++++++++++++++- .../rdsap/tests/test_golden_fixtures.py | 14 +++-- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index b98fe999..43436f48 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index fcf2ed75..e3fafb7f 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, ) diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 5f154fc1..3fbe3a95 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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 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: # Arrange — Direction check: chimneys add Table 2.1 volume to the # infiltration calc, so an otherwise identical dwelling with 2 open diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 02988ba3..600eccbb 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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(