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(