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 c1ae8653..ceae7169 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -56,6 +56,7 @@ _SUMMARY_000487_PDF = _FIXTURES / "Summary_000487.pdf" _SUMMARY_000490_PDF = _FIXTURES / "Summary_000490.pdf" _SUMMARY_000516_PDF = _FIXTURES / "Summary_000516.pdf" _SUMMARY_001479_PDF = _FIXTURES / "Summary_001479.pdf" +_SUMMARY_000897_PDF = _FIXTURES / "Summary_000897.pdf" # GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the # Summary_001479.pdf fixture. Together they drive the API ≡ Summary @@ -325,6 +326,32 @@ def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None: assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 +def test_summary_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None: + # Arrange — cert 0330-2249-8150-2326-4121 (Summary_000897.pdf / + # dr87-0001-000897.pdf) is the second boiler cert under per-cert + # mapper validation: mains-gas boiler (PCDB idx 10241), mid-terrace + # 2-bp dwelling, TFA 69.14 m². Worksheet PDF "SAP value" line lodges + # unrounded SAP **61.5993**. Same load-bearing role as cert 001479 + # (the first boiler) — Summary path proves itself against the + # worksheet, then becomes the canonical reference for the API path. + # Expected RED at Δ +0.4667 at handover-baseline (Summary mapper + # cascade SAP 62.0660); mapper gaps to close are §11 glazing_type=14 + # (windows HLC +6.71 W/K) and the §4 hot-water cascade (kWh +1060). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000897_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 — 1e-4 pin, no widening, no xfail (project memory + # `feedback_zero_error_strict`). + worksheet_unrounded_sap = 61.5993 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + def test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None: # Arrange — cert 001479 has both an Elmhurst Summary PDF and a GOV.UK # EPB API JSON (ref 0535-9020-6509-0821-6222). The Summary cascade diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 0e6d682c..d654a776 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -98,9 +98,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0300-2747-7640-2526-2135", actual_sap=78, - expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=+1.0093, - expected_co2_resid_tonnes_per_yr=-0.8321, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+7.7553, + expected_co2_resid_tonnes_per_yr=-0.2526, notes=( "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "(no Table 4b code). Cert lodges open_flues_count=1 + " @@ -108,7 +108,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "fuel 26). Slice 58 cascade routed secondary fuel cost through " "the lodged fuel_type (rather than hardcoding the electric " "tariff), tightening this cert's SAP residual −7 → +2 — the " - "biggest single SAP improvement on the golden cohort to date." + "biggest single SAP improvement on the golden cohort to date. " + "Slice 96 (RdSAP 10 §5.11 Table 18 column (3) flat-roof " + "defaults) lifted Ext1's flat-roof U from the pitched-column-1 " + "0.40 fall-through to the spec-correct 2.30 (age D), " + "tightening SAP residual +1 → 0." ), ), _GoldenExpectation( diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index f59e89a9..9dd20874 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -496,7 +496,15 @@ def heat_transmission_from_cert( effective_roof_description = ( None if roof_thickness == 0 else roof_description ) - ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description) + # RdSAP 10 §5.11 Table 18 page 45: column (3) "Flat roof" applies + # when the per-bp roof construction lodges as a flat roof and the + # cert doesn't supply an insulation thickness. The pitched-roof + # column (1) age-band fallback (0.40 for ages A-G) catastrophically + # under-states a flat roof's heat loss for old age bands where the + # spec value is 2.30 (A-D) / 1.50 (E) / 0.68 (F) / 0.40 (G). + roof_type_lower = (part.roof_construction_type or "").lower() + is_flat_roof = "flat" in roof_type_lower + ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof) # Floor U-value routing (in priority order): # 1. Basement floor — Table 23 F-column override (whole floor=0). # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index c4559c55..03e9373a 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -390,6 +390,21 @@ _ROOF_BY_AGE: Final[dict[str, float]] = { "K": 0.16, "L": 0.16, "M": 0.15, } +# Table 18 column (3): flat-roof default U by age band when thickness unknown. +# RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults +# bottom out at 0.40 because "between joists insulation" is the implicit +# Table-16 reference; the flat-roof column (3) drops directly to the +# Table 16 row-0 / "no insulation" value (2.30) for old age bands and +# follows Table 16's thickness ladder for modern ones. A flat roof +# without a measured insulation thickness lodgement therefore cannot +# share the pitched-roof age-band fallback — for an age D dwelling the +# spec value is 2.30, not 0.40 (5.75x understatement of heat loss). +_FLAT_ROOF_BY_AGE: Final[dict[str, float]] = { + "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50, + "F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.25, + "K": 0.25, "L": 0.18, "M": 0.15, +} + # Table 18 column (4): "Room-in-roof, all elements" default U by age band # when no detailed RR lodgement is available. Footnote (1) on each entry # confirms "value from the table applies for unknown and as built". @@ -418,6 +433,7 @@ def u_roof( age_band: Optional[str], insulation_thickness_mm: Optional[int], description: Optional[str] = None, + is_flat_roof: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. @@ -428,7 +444,9 @@ def u_roof( Table 18 age-band defaults assume joist insulation ≥100 mm, which is wrong for catastrophic heritage roofs the EPC explicitly describes as uninsulated. - 3. Table 18 age-band default. + 3. Table 18 age-band default — column (1) "Pitched, insulation between + joists" by default; column (3) "Flat roof" when `is_flat_roof=True`. + Spec §5.11 Table 18 page 45. """ measured = _measured_u_from_description(description) if measured is not None: @@ -459,6 +477,8 @@ def u_roof( return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 + if is_flat_roof: + return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) return _ROOF_BY_AGE.get(age_band.upper(), 0.4) diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index ad185a1a..84a31ec0 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -565,6 +565,52 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: assert result == pytest.approx(0.4, abs=0.001) +def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None: + # Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof": + # age band D, thickness unknown → U = 2.30 W/m²K. Column (1) + # (pitched-between-joists default) returns 0.40 for the same age + # band; routing must pick column (3) when the per-bp roof + # construction lodges as flat. + + # Act + result = u_roof( + country=Country.ENG, age_band="D", insulation_thickness_mm=None, + is_flat_roof=True, + ) + + # Assert + assert abs(result - 2.30) <= 1e-4 + + +def test_u_roof_flat_age_band_g_returns_table18_col3_value() -> None: + # Arrange — Table 18 column (3) flat-roof default is 0.40 for age G, + # the cross-over point where the flat-roof and pitched-roof columns + # agree. Confirms the dict is populated across the full age range. + + # Act + result = u_roof( + country=Country.ENG, age_band="G", insulation_thickness_mm=None, + is_flat_roof=True, + ) + + # Assert + assert abs(result - 0.40) <= 1e-4 + + +def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None: + # Arrange — Table 18 column (3) flat-roof default is 0.18 for age L, + # the modern band where both columns agree. + + # Act + result = u_roof( + country=Country.ENG, age_band="L", insulation_thickness_mm=None, + is_flat_roof=True, + ) + + # Assert + assert abs(result - 0.18) <= 1e-4 + + def test_u_roof_description_no_insulation_overrides_age_band_default() -> None: # Arrange — surveyor description on a Victorian roof says uninsulated; # Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm