diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf new file mode 100644 index 00000000..1f21db8c Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf new file mode 100644 index 00000000..dbb3f4c8 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf new file mode 100644 index 00000000..c8cb35ce Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf differ 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 e37db750..2104cdaa 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -99,6 +99,9 @@ _SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmh _SUMMARY_001431_CASE20_PDF = _FIXTURES / "Summary_001431_case20.pdf" # sim case 20 (storage heaters + RR type-2 + wrapped "Double between 2002 and 2021" glazing) _SUMMARY_001431_TOPFLOOR_PDF = _FIXTURES / "Summary_001431_topfloor_flat.pdf" # gas-boiler-upgrade recommendation "after" — top-floor flat, PS sloping roof; exercises the Date-Built age-band + flat-position layout regressions _SUMMARY_001431_LPG_PDF = _FIXTURES / "Summary_001431_lpg_boiler.pdf" # lpg-boiler recommendation "before" — §14 SAP code 115, §15 "Bottled gas"; exercises the bottled-LPG main-fuel mapping +_SUMMARY_001431_CASE41_RAFTERS_PDF = _FIXTURES / "Summary_001431_case41_rafters.pdf" # sim case 41 — 4-bp roof: Main joists 200mm, Ext1 rafters 200mm, Ext2 joists unknown, Ext3 rafters As Built (RdSAP 10 §5.11.2 Table 16 col 2 + Table 18 col 2) +_SUMMARY_001431_CASE42_50MM_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_50mm_rafters.pdf" # sim case 42 — single-bp roof: rafters 50mm (Table 16 col 2 → 0.88) +_SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_unknown_rafters.pdf" # sim case 42 — single-bp roof: rafters unknown thickness (Table 18 col 2 band C → 2.30) # GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the # Summary_001479.pdf fixture. Together they drive the API ≡ Summary @@ -178,6 +181,69 @@ def test_summary_001431_case20_fabric_heat_loss_matches_worksheet_line_33() -> N assert abs(ht.fabric_heat_loss_w_per_k - 285.9847) <= 1e-4 +def test_summary_001431_case41_roof_drives_rafters_column_per_part() -> None: + # Arrange — sim case 41's 4 building parts exercise both RdSAP 10 roof + # columns per-part (RdSAP 10 §5.11.2 Table 16 + §5.11 Table 18, + # PDF p.42-45). The P960 §3 line (30) "External roof" A×U per part: + # Main joists 200mm → 0.21 × 59.5 = 12.4950 (Table 16 col 1) + # Ext1 rafters 200mm → 0.29 × 10.0 = 2.9000 (Table 16 col 2) + # Ext2 joists unknown→ 0.40 × 10.0 = 4.0000 (Table 18 col 1, band E) + # Ext3 rafters AsBlt → 0.68 × 8.0 = 5.4400 (Table 18 col 2, band F) + # Total (sum of (30)) = 24.8350 W/K. Before the rafters column the two + # rafter parts were mis-billed at the joists U (Ext1 0.21, Ext3 0.40). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_CASE41_RAFTERS_PDF) + epc = EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + assert abs(ht.roof_w_per_k - 24.8350) <= 1e-4 + + +def test_summary_001431_case42_rafters_50mm_uses_table16_column_2() -> None: + # Arrange — sim case 42's single-bp roof lodged "R Rafters" + 50 mm. + # RdSAP 10 §5.11.2 Table 16 (PDF p.43) column (2) "insulation at + # rafters" 50 mm → U=0.88 (vs the joists column (1) 0.68). P960 §3 (30) + # = 0.88 × 59.5 = 52.3600 W/K. + pages = _summary_pdf_to_textract_style_pages( + _SUMMARY_001431_CASE42_50MM_RAFTERS_PDF + ) + epc = EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + assert abs(ht.roof_w_per_k - 52.3600) <= 1e-4 + + +def test_summary_001431_case42_rafters_unknown_uses_table18_column_2() -> None: + # Arrange — sim case 42's single-bp roof lodged "R Rafters" with an + # unknown thickness, age band C. RdSAP 10 §5.11 Table 18 (PDF p.45) + # column (2) "insulation at rafters" applies "for unknown and as built" + # (footnote 1) → band A-D = 2.30 (NOT the joists column (1) 100 mm + # default 0.40, which only applies to the "between joists or unknown" + # column). Worksheet-confirmed by the case-42 variant set. P960 §3 (30) + # = 2.30 × 59.5 = 136.8500 W/K. + pages = _summary_pdf_to_textract_style_pages( + _SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF + ) + epc = EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + assert abs(ht.roof_w_per_k - 136.8500) <= 1e-4 + + def test_summary_001431_topfloor_extracts_main_property_age_band() -> None: # Arrange — the gas-boiler-upgrade recommendation "after" Summary # renders "3.0 Date Built:" glued to its "Main Property" row header diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ebdb2b52..6084addc 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -41,7 +41,7 @@ from __future__ import annotations from dataclasses import dataclass from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Final, Optional +from typing import Any, Final, Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, @@ -349,6 +349,27 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]: return " | ".join(parts) +def _roof_insulation_at_rafters(location: Optional[Union[int, str]]) -> bool: + """True when a building part's roof insulation sits at the rafters + (the sloping side of the roof) rather than between the ceiling joists. + + `roof_insulation_location` is the authoritative per-part signal — it + carries the gov-EPC API integer code (1 = Rafters, per the empirically + watertight single-roof corpus map) on the API path and the stripped + Elmhurst Summary label ("Rafters" from "R Rafters") on the Summary + path. Both resolve here so `u_roof` selects the RdSAP 10 §5.11.2 + Table 16 column (2) / Table 18 rafters column instead of the loft- + joist column (1). The flat deduplicated `epc.roofs[]` description list + cannot give this per-part — 190/329 multi-part certs have + len(roofs) != len(parts) — so the per-part location is the only + reliable discriminator (worksheet-validated by simulated case 41).""" + if location is None: + return False + if isinstance(location, int): + return location == 1 + return "rafter" in location.strip().lower() + + def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]: """Join roof descriptions for the MAIN (non-RR) roof U-value, dropping "Roof room(s)" entries. @@ -873,7 +894,17 @@ def heat_transmission_from_cert( # col (1) per the cohort, so only the literal "sloping ceiling" # string triggers the col (3) age-band default in `u_roof`. is_pitched_sloping_ceiling = "sloping ceiling" 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, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling) + # RdSAP 10 §5.11.2 Table 16 column (2) / Table 18 rafters column — + # a roof lodged insulated AT RAFTERS (per-part + # `roof_insulation_location` == 1 / "R Rafters") sits on the + # shallower sloping side, so the same insulation depth yields a + # higher U than the loft-joist column (1). Driven per-part because + # the deduplicated `epc.roofs[]` description list cannot attribute + # a location to each building part. + insulation_at_rafters = _roof_insulation_at_rafters( + getattr(part, "roof_insulation_location", None) + ) + ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling, insulation_at_rafters=insulation_at_rafters) # RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP # output, surfaced by the gov-EPC API as `roof_u_value`) is used # directly in place of the §5.11 construction-default cascade. The gov diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index ea1188db..b2e76b8f 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -747,6 +747,34 @@ _ROOF_BY_AGE: Final[dict[str, float]] = { "K": 0.16, "L": 0.16, "M": 0.15, } +# Table 16 column (2): insulation AT RAFTERS (sloping side of the roof, +# rather than between the ceiling joists). RdSAP 10 §5.11.2 Table 16 +# (PDF p.42-43). The rafter cavity is shallower than a loft void, so the +# same insulation depth yields a HIGHER U than the column (1) joist row +# (e.g. 200 mm: rafters 0.29 vs joists 0.21). Thickness mm -> U. +_ROOF_RAFTERS_BY_THICKNESS: Final[list[tuple[int, float]]] = [ + (0, 2.30), (12, 1.75), (25, 1.30), (50, 0.88), (75, 0.67), + (100, 0.54), (125, 0.45), (150, 0.39), (175, 0.32), (200, 0.29), + (225, 0.25), (250, 0.23), (270, 0.21), (300, 0.19), (350, 0.16), + (400, 0.14), +] + +# Table 18 rafters column: pitched-roof "insulation at rafters" default U +# by age band when the thickness cannot be determined. RdSAP 10 §5.11 +# Table 18 (PDF p.45). Identical to the joist column (1) for bands A-G +# (2.30 → 0.40), then diverges higher (H 0.35 vs 0.30, I 0.35 vs 0.26, +# J/K 0.20 vs 0.16, L 0.18 vs 0.16). Unlike the loft-joist default this +# does NOT collapse to the optimistic 0.40 "assume modern retrofit" floor +# at old bands — a rafter cavity cannot be topped up from the loft, so an +# unknown-thickness rafter roof keeps the as-built age-band U (band F +# 0.68, band E 1.50, A-D 2.30). Worksheet-validated by simulated case 41 +# Ext3 (band F, R Rafters, As Built → P960 §3 (30) U=0.68). +_ROOF_RAFTERS_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.20, + "K": 0.20, "L": 0.18, "M": 0.18, +} + # 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 @@ -793,6 +821,7 @@ def u_roof( is_flat_roof: bool = False, is_sloping_ceiling: bool = False, is_pitched_sloping_ceiling: bool = False, + insulation_at_rafters: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. @@ -829,7 +858,27 @@ def u_roof( (code 5) are deliberately excluded — they stay on column (1) per the cohort evidence above. Worksheet-validated by simulated case 15 (the 7536 replica): Ext1 band L → 0.18, Ext2 band F → 0.68. + + `insulation_at_rafters` selects the RdSAP 10 §5.11.2 Table 16 column + (2) thickness ladder and the Table 18 rafters age-band column instead + of the loft-joist column (1). A roof lodged insulated AT RAFTERS + (`roof_insulation_location == 1` on the API path, "R Rafters" on the + Summary path) sits on the sloping side of the roof — a shallower + cavity than a loft void, so the same insulation depth yields a higher + U (200 mm: 0.29 vs the joists 0.21). Ignored for flat / sloping- + ceiling roofs (the rafter distinction is a pitched-with-loft concept). + Worksheet-validated by simulated case 41 Ext1 (band C, R Rafters, + 200 mm → 0.29) and Ext3 (band F, R Rafters, As Built → 0.68). """ + # RdSAP 10 §5.11.2 Table 16 / §5.11 Table 18 — pick the rafters + # column when the insulation sits at the rafters rather than the + # loft joists. Flat / sloping-ceiling geometries keep their own + # dedicated tables (rafters is meaningless there). + use_rafters = insulation_at_rafters and not (is_flat_roof or is_sloping_ceiling) + roof_by_thickness = ( + _ROOF_RAFTERS_BY_THICKNESS if use_rafters else _ROOF_BY_THICKNESS + ) + roof_by_age = _ROOF_RAFTERS_BY_AGE if use_rafters else _ROOF_BY_AGE measured = _measured_u_from_description(description) if measured is not None: # Full-SAP cert lodges a measured roof U-value in the description @@ -852,7 +901,7 @@ def u_roof( # genuine "no insulation" lodgement, which keeps 2.30 (below). The # discriminator is the deterministic "Unknown" text RdSAP renders # for an undetermined-thickness observation. - table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else _ROOF_BY_AGE + table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else roof_by_age return table_18.get(age_band.upper(), 0.4) if ( is_sloping_ceiling @@ -877,9 +926,10 @@ def u_roof( # uninsulated 2.30 W/m²K. return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level" if insulation_thickness_mm is not None: - # nearest tabulated thickness <= supplied - u = _ROOF_BY_THICKNESS[0][1] - for t, val in _ROOF_BY_THICKNESS: + # nearest tabulated thickness <= supplied (Table 16 column (1) + # joists or column (2) rafters per `insulation_at_rafters`) + u = roof_by_thickness[0][1] + for t, val in roof_by_thickness: if insulation_thickness_mm >= t: u = val return u @@ -923,7 +973,7 @@ def u_roof( return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 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) + return roof_by_age.get(age_band.upper(), 0.4) # RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 1018ce30..8d4e3612 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -961,6 +961,69 @@ def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None: assert result == pytest.approx(0.21, abs=0.001) +def test_u_roof_at_rafters_explicit_thickness_uses_table16_column_2() -> None: + # Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) + # "insulation at rafters". A roof lodged insulated AT RAFTERS + # (roof_insulation_location == 1, "R Rafters" on the Summary path) + # takes the rafters thickness ladder, NOT the column (1) joist row: + # at 200 mm the rafters U is 0.29 W/m²K vs the joists 0.21 — a ~38% + # heat-loss understatement when the joists column is mis-used. The + # joists column (1) stays 0.21 for the same thickness. + + # Act + at_rafters = u_roof( + country=Country.ENG, age_band="C", insulation_thickness_mm=200, + insulation_at_rafters=True, + ) + at_joists = u_roof( + country=Country.ENG, age_band="C", insulation_thickness_mm=200, + insulation_at_rafters=False, + ) + + # Assert + assert abs(at_rafters - 0.29) <= 0.001 + assert abs(at_joists - 0.21) <= 0.001 + + +def test_u_roof_at_rafters_thickness_ladder_matches_table16_column_2() -> None: + # Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) rows: + # 50 mm → 0.88, 100 mm → 0.54, 150 mm → 0.39, 270 mm → 0.21. Each is + # higher than the joists column (1) value at the same thickness (the + # rafter cavity is shallower so the same insulation depth yields a + # higher U). + + # Act / Assert + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=50, insulation_at_rafters=True) - 0.88) <= 0.001 + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=100, insulation_at_rafters=True) - 0.54) <= 0.001 + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=150, insulation_at_rafters=True) - 0.39) <= 0.001 + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=270, insulation_at_rafters=True) - 0.21) <= 0.001 + + +def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() -> None: + # Arrange — RdSAP 10 §5.11 Table 18 (PDF p.45) rafters age-band + # column. A rafter-insulated roof with no determinable thickness + # ("R Rafters" + "As Built" → thickness None) takes the rafters + # age-band default. Band F → 0.68 (== the joists value at F), band H + # → 0.35 (vs joists 0.30), band J → 0.20 (vs joists 0.16). Unlike a + # loft-joist roof the rafter cavity cannot be topped up, so the + # optimistic 0.40 "assume modern retrofit" joist floor does NOT apply + # at old bands — band C stays 2.30 (vs the joists-unknown 0.40). + # Worksheet-validated by simulated case 41 Ext3 (band F, R Rafters, + # As Built → P960 §3 (30) U=0.68). + + # Act + band_f = u_roof(country=Country.ENG, age_band="F", insulation_thickness_mm=None, insulation_at_rafters=True) + band_h = u_roof(country=Country.ENG, age_band="H", insulation_thickness_mm=None, insulation_at_rafters=True) + band_j = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None, insulation_at_rafters=True) + band_c = u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=None, insulation_at_rafters=True) + + # Assert + assert abs(band_f - 0.68) <= 0.001 + assert abs(band_h - 0.35) <= 0.001 + assert abs(band_j - 0.20) <= 0.001 + assert abs(band_c - 2.30) <= 0.001 + + def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: # Arrange — nothing known. diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 3153f1bc..16e362f9 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -151,6 +151,44 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0) +def test_roof_insulation_location_rafters_drives_table16_column_2_api_int_path() -> None: + # Arrange — the gov-EPC API lodges roof_insulation_location as an + # integer (1 = Rafters per the empirically watertight corpus map). A + # roof insulated AT RAFTERS with 200 mm takes RdSAP 10 §5.11.2 Table 16 + # (PDF p.43) column (2) → U=0.29, NOT the joists column (1) 0.21 — the + # rafter cavity is shallower so the same depth yields a higher U. The + # per-part location is the authoritative signal (the deduplicated + # epc.roofs[] list cannot attribute a location per building part). + # Geometry: 100 m² plan → roof area 100 m². rafters: 0.29 × 100 = 29 + # W/K (vs the joists 0.21 × 100 = 21 W/K). + main = make_building_part( + construction_age_band="C", + wall_construction=3, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + main.roof_insulation_location = 1 # gov-API int: Rafters + main.roof_insulation_thickness = "200mm" + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert abs(result.roof_w_per_k - 29.0) <= 1e-4 + + def test_lodged_roof_u_value_overrides_construction_default() -> None: # Arrange — RdSAP 10 §5.1: where an element's U-value is known from the # assessment (documentary evidence / the lodged RdSAP output) it is used diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 13e9f6e6..fbf10705 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -67,6 +67,23 @@ _CORPUS = Path( # energy were 5% high; actual SAP bias is +0.145). # So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is # no one-slice factor fix. RATCHET any ceiling up when a slice tightens it. +# +# RAFTERS ROOF U-TABLE (RdSAP 10 §5.11.2 Table 16 col 2 + §5.11 Table 18 col 2): +# this slice billed roofs lodged insulated AT RAFTERS (roof_insulation_location +# == 1) on the spec rafters column instead of the joists column. Within-0.5 went +# 66.9% -> 66.5% (MAE 1.039 -> 1.064) — a SPEC-CORRECT move, NOT a regression to +# chase. The calculator is worksheet-validated to 1e-4 on simulated case 41 +# (4-bp: measured rafters 200mm -> 0.29; rafters As-Built band F -> 0.68) and +# case 42 (6 variants: rafters 50mm -> 0.88; rafters unknown band C -> 2.30 per +# Table 18 footnote 1 "applies for unknown and as built"). The dip is a gov +# open-data REDACTION artifact: all 15 corpus rafter certs carry NO thickness +# (blanked to None) yet lodge roof energy_efficiency_rating 2-4 (insulated), +# proving they had a SPECIFIED thickness the open API redacted. With the +# thickness gone the spec's unknown-rafter default (2.30) correctly fires but +# over-states those certs' real (insulated) roof. Recovering them needs a +# roof-EER -> assumed-thickness inference on the API path (future slice), NOT a +# change to the spec-correct U-table. Do NOT revert the rafters column to "fix" +# the gauge. _MIN_WITHIN_HALF_SAP = 0.65 _MAX_SAP_MAE = 1.08 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current