diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 01b50deb..55dd04d6 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -340,6 +340,12 @@ class ElmhurstSiteNotesExtractor: dry_lined=self._local_bool( lines, f"Alternative Wall {n} Dry-lining" ), + # RdSAP 10 Table 4 (p.22): a sheltered alt sub-area + # (adjacent to an unheated buffer, e.g. a flat corridor + # wall) adds R=0.5 m²K/W → U = 1/(1/U + 0.5). + sheltered=self._local_bool( + lines, f"Alternative Wall {n} Sheltered Wall" + ), )) return result @@ -1001,7 +1007,17 @@ class ElmhurstSiteNotesExtractor: joined to the data line (no separate prefix line exists), so the only signal of window-transition is the orientation tokens rotating: orient_suffix(k) → orient_prefix(k+1). Falls through - to `next_data_idx` when neither marker is present.""" + to `next_data_idx` when neither marker is present. + + (c) A standalone wall-location line ("Alternative wall", "External + wall", "Party wall") in the gap belongs to the NEXT window's + prefix — it is that window's §11 Location cell, wrapped above its + W×H×A data row. When the next window is single-glazed its prefix + line carries no glazing-type word (branch a never fires), so + without this the "Alternative wall" line is swallowed into the + current window's suffix and the next window defaults to "External + wall" (simulated case 34: 2 of 4 single-glazed alt-wall windows + mis-allocated → wrong corridor-wall net area).""" scan_start = manuf_idx + 4 seen_orient = False for j in range(scan_start, next_data_idx): @@ -1009,6 +1025,8 @@ class ElmhurstSiteNotesExtractor: first_word = stripped.split(" ", 1)[0] if first_word in self._GLAZING_TYPE_PREFIX_WORDS: return j + if "wall" in stripped.lower(): + return j if stripped in self._ORIENTATION_TOKENS: if seen_orient: return j @@ -1531,6 +1549,9 @@ class ElmhurstSiteNotesExtractor: if cylinder_thermostat is None: if "Cylinder thermostat (Already installed)" in self._lines: cylinder_thermostat = True + # §15.1 "Immersion Heater" lodging ("Dual" / "Single"). Scoped to + # the §15.1..§15.2 slice so the lookup can't collide elsewhere. + immersion_type = self._local_val(cylinder_lines, "Immersion Heater") return WaterHeating( water_heating_code=self._str_val("Water Heating Code"), water_heating_sap_code=self._int_val("Water Heating SapCode"), @@ -1540,6 +1561,7 @@ class ElmhurstSiteNotesExtractor: cylinder_insulation_label=cylinder_insulation_label, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, cylinder_thermostat=cylinder_thermostat, + immersion_type=immersion_type, ) def _extract_baths_and_showers(self) -> BathsAndShowers: @@ -1727,6 +1749,25 @@ class ElmhurstSiteNotesExtractor: )) return arrays + def _extract_main_age_band(self) -> str: + """Read the Main Property age band from the §3.0 Date Built block. + + "Main Property" also heads the §4 Dimensions table, so a global + `_str_val("Main Property")` collides with it: when the layout + glues "3.0 Date Built:" onto the "Main Property" row token (the + recommendation worksheets do), the first standalone "Main + Property" match is the dimensions header — yielding its next + token ("Floor") instead of the age band. Scope the read to the + Date-Built block and take the first age row — a line beginning + with a single A-M band letter + space (e.g. "C 1930-1949", + "A before 1900", "J 2003-2006"). Building-part name rows + ("Main Property", "1st Extension", "Main Prop. Room(s) in + Roof") never start that way, and the Main row precedes any + extension / room-in-roof rows.""" + block = self._between("3.0 Date Built", "4.0 Dimensions") + m = re.search(r"^([A-M] .+)$", block, re.MULTILINE) + return " ".join(m.group(1).split()) if m else "" + def extract(self) -> ElmhurstSiteNotes: emissions_raw = self._next_val("Emissions (t/year)") co2 = float(emissions_raw.split()[0]) if emissions_raw else 0.0 @@ -1744,7 +1785,7 @@ class ElmhurstSiteNotesExtractor: number_of_storeys=self._int_val("Storeys"), habitable_rooms=self._int_val("Habitable Rooms"), heated_habitable_rooms=self._int_val("Heated Habitable Rooms"), - construction_age_band=self._str_val("Main Property"), + construction_age_band=self._extract_main_age_band(), dimensions=self._extract_dimensions(), has_conservatory=self._bool_val("Is there a conservatory?"), walls=self._extract_walls(), diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf new file mode 100644 index 00000000..7b4cbcae Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_lpg_boiler.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_lpg_boiler.pdf new file mode 100644 index 00000000..2b7a7287 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_lpg_boiler.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_topfloor_flat.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_topfloor_flat.pdf new file mode 100644 index 00000000..e61f3466 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_topfloor_flat.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf b/backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf new file mode 100644 index 00000000..33cf586d Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf differ diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 07e02215..7cf89c8f 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -961,6 +961,24 @@ def test_heating_systems_corpus_residual_matches_pin( ) +def test_solid_fuel_5_captures_section_15_1_dual_immersion() -> None: + # Arrange — solid fuel 5 (cert 001431: House Coal main SAP 153, WHC 903 + # electric immersion HW, 18-hour meter, 110 L Normal cylinder). The + # Elmhurst Summary §15.1 "Hot Water Cylinder" block lodges "Immersion + # Heater: Dual". The extractor must surface it and the mapper map it to + # the SAP10 `immersion_heating_type` code 1 (dual) per RdSAP 10 §10.5. + summary_pdf, _p960 = _variant_paths('solid fuel 5') + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + + # Act + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert site_notes.water_heating.immersion_type == "Dual" + assert epc.sap_heating.immersion_heating_type == 1 + + def test_oil_6_no_room_thermostat_applies_table_4c2_minus_5pp_space_efficiency() -> None: # Arrange — oil 6 (B30K standard liquid-fuel boiler, Table 4b code # 126 winter 80 / summer 68) lodges "Main Heating Controls Sap: SAP 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 7a54fdfd..e37db750 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -42,7 +42,15 @@ from datatypes.epc.domain.mapper import ( EpcPropertyDataMapper, UnmappedApiCode, UnmappedElmhurstLabel, + _elmhurst_dwelling_type, # pyright: ignore[reportPrivateUsage] _elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage] + _elmhurst_immersion_type_code, # pyright: ignore[reportPrivateUsage] + _map_elmhurst_alternative_wall, # pyright: ignore[reportPrivateUsage] +) +from datatypes.epc.surveys.elmhurst_site_notes import ( + AlternativeWall as ElmhurstAlternativeWall, + FloorDetails as ElmhurstFloorDetails, + RoofDetails as ElmhurstRoofDetails, ) from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import ( @@ -78,12 +86,19 @@ _SUMMARY_000904_PDF = _FIXTURES / "Summary_000904.pdf" # cert 9285 _SUMMARY_000900_PDF = _FIXTURES / "Summary_000900.pdf" # cert 2225 _SUMMARY_000898_PDF = _FIXTURES / "Summary_000898.pdf" # cert 2636 _SUMMARY_000902_PDF = _FIXTURES / "Summary_000902.pdf" # cert 9418 +# simulated case 34 (cert 001431 reconfigured as a slimline electric-storage +# flat with an unheated corridor / sheltered alternative wall + 4 alt-wall +# windows). Regression net for the flat-roof, sheltered-wall, and §11 +# alt-wall-window-allocation fixes. +_SUMMARY_CASE34_PDF = _FIXTURES / "Summary_case34_storage_flat.pdf" _SUMMARY_000889_PDF = _FIXTURES / "Summary_000889.pdf" # cert 2536 (Normal cylinder) _SUMMARY_000884_PDF = _FIXTURES / "Summary_000884.pdf" # cert 9421 (Normal cylinder) _SUMMARY_000910_PDF = _FIXTURES / "Summary_000910.pdf" # cert 0036 (Flat, party wall U=0) _SUMMARY_000890_PDF = _FIXTURES / "Summary_000890.pdf" # cert 7800 (two electric showers) _SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmhurst-only) _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 # GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the # Summary_001479.pdf fixture. Together they drive the API ≡ Summary @@ -163,6 +178,105 @@ 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_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 + # on one layout line, so the FIRST standalone "Main Property" token + # is the §4 dimensions-table header (followed by "Floor"). The + # extractor must read the age band from the Date-Built block, not the + # first global "Main Property" match — the worksheet lodges age band + # C (1930-1949). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF) + + # Act + survey = ElmhurstSiteNotesExtractor(pages).extract() + + # Assert + assert survey.construction_age_band == "C 1930-1949" + + +def test_summary_001431_lpg_boiler_maps_main_fuel_to_bottled_lpg() -> None: + # Arrange — the lpg-boiler recommendation "before" Summary lodges + # §14.0 SAP code 115 (a Table 4b gas-family boiler row), §15.0 + # "Water Heating Fuel Type: Bottled gas", and §14.2 "Main gas: Yes". + # The boiler burns bottled LPG, not mains gas; the mapper must + # resolve the carrier from the "Bottled gas" label, NOT default to + # mains gas via the (contradictory) meter flag. Table-route code 3 = + # bottled LPG main heating (Table 32/12 10.30/9.46 p/kWh) — NOT code + # 5, which collides with anthracite (`canonical_fuel_code`). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + main = epc.sap_heating.main_heating_details[0] + assert main.main_fuel_type == 3 + assert epc.sap_heating.water_heating_fuel == 3 + + +def test_summary_001431_lpg_boiler_full_chain_sap_matches_worksheet_pdf() -> None: + # Arrange — the lpg-boiler "before" worksheet (P960-0001-001431): + # pre-1998 LPG boiler (SAP code 115, eff 61%) + 210 L cylinder, NO + # cylinder thermostat, control 2113 (room thermostat + TRVs, no + # programmer). RdSAP 10 §10.5 (p.57) "Hot water separately timed": + # a no-programmer + pre-1998 boiler is NOT separately timed, so the + # Table 2b temperature factor (53) is 0.78 (= 0.60 × 1.3), not + # 0.702 (× 0.9). Worksheet §11a lodges unrounded SAP -6.6499. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_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 + worksheet_unrounded_sap = -6.6499 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + +def test_summary_001431_topfloor_flat_classified_as_top_floor() -> None: + # Arrange — the recommendation "after" Summary lodges §6.0 "Position + # of flat in block of flats: Top Floor": floor "A Another dwelling + # below" (party) + roof "PS Pitched, sloping ceiling" (an exposed + # external roof, NOT a room-in-roof). The mapper must classify it + # Top-floor (roof exposed) — not Mid-floor — so the cascade charges + # the roof heat-loss term. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.dwelling_type == "Top-floor flat" + + +def test_summary_001431_topfloor_full_chain_sap_matches_worksheet_pdf() -> None: + # Arrange — gas-boiler-upgrade-with-cylinder recommendation "after" + # worksheet (P960-0001-001431). Top-floor flat, PS sloping roof at + # U=2.3 (age C, uninsulated) → (30) roof 241.68 W/K, (33) fabric + # 320.06, (37) HLC 348.76. Worksheet §11a lodges unrounded SAP + # 56.3649. Exercises both upstream fixes: the Date-Built age band + # (roof U 2.3 not 0.4) and the top-floor flat classification (roof + # not dropped). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_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 + worksheet_unrounded_sap = 56.3649 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + def test_summary_000474_mapper_produces_three_building_parts() -> None: # Arrange — cert U985-0001-000474 is a mid-terrace with 3 building # parts (Main + 2 extensions) per the hand-built worksheet fixture @@ -1439,6 +1553,164 @@ def test_summary_mapper_raises_on_unmapped_cylinder_insulation_label() -> None: assert excinfo.value.value == "Polyester wool" +def test_case34_alt_wall_windows_all_allocated_to_alternative_wall() -> None: + # Arrange — simulated case 34 lodges 4 windows on "Alternative wall 1" + # (0.70 + 1.75 + 1.18 + 1.00 = 4.63 m²) and 6 on the external wall. The + # §11 layout interleaves the wrapped "Alternative wall / 1" Location cell + # around each window's data row; for single-glazed alt windows the + # location line carries no glazing-type word, so the partition swallowed + # it into the previous window's suffix and the window defaulted to + # "External wall" — mis-deducting its opening from the wrong wall. + from domain.sap10_calculator.worksheet.heat_transmission import ( + _window_on_alt_wall, # pyright: ignore[reportPrivateUsage] + ) + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_CASE34_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + alt_area = sum( + round(float(w.window_width) * float(w.window_height), 2) + for w in (epc.sap_windows or []) + if _window_on_alt_wall(w) + ) + alt_count = sum(1 for w in (epc.sap_windows or []) if _window_on_alt_wall(w)) + + # Assert — all 4 alt-wall windows recovered (worksheet alt openings 4.63). + assert alt_count == 4 + assert abs(alt_area - 4.63) <= 0.01 + + +def test_map_elmhurst_alternative_wall_carries_sheltered_flag() -> None: + # Arrange — Elmhurst Summary §7 lodges "Alternative Wall N Sheltered + # Wall: Yes" for a sub-area adjacent to an unheated buffer (e.g. a flat's + # corridor wall). RdSAP 10 Table 4 (p.22) gives a sheltered wall an added + # R=0.5 m²K/W → U=1/(1/U+0.5). The cascade applies this via + # SapAlternativeWall.is_sheltered (the API path already wires it); the + # Elmhurst path must surface it too. Surfaced by simulated case 34 (cert + # 001431 flat: 6.02 m² corridor wall billed at full U=1.50 instead of + # the sheltered 0.86 → +3.85 W/K, -1.61 SAP). + sheltered = ElmhurstAlternativeWall( + area_m2=12.5, wall_type="CA Cavity", insulation="A As Built", + thickness_unknown=False, thickness_mm=250, u_value_known=False, + dry_lined=False, sheltered=True, + ) + plain = ElmhurstAlternativeWall( + area_m2=12.5, wall_type="CA Cavity", insulation="A As Built", + thickness_unknown=False, thickness_mm=250, u_value_known=False, + dry_lined=False, sheltered=False, + ) + + # Act + mapped_sheltered = _map_elmhurst_alternative_wall(sheltered) + mapped_plain = _map_elmhurst_alternative_wall(plain) + + # Assert + assert mapped_sheltered.is_sheltered is True + assert mapped_plain.is_sheltered is False + + +def test_elmhurst_dwelling_type_single_storey_flat_with_exposed_roof_is_top_floor() -> None: + # Arrange — a single-storey flat exposed BOTH top (external pitched + # roof, access to loft) AND bottom (floor over partially-heated space, + # not another dwelling) — simulated case 34 (cert 001431). Pre-fix the + # absence of a "dwelling below" routed it to "Ground-floor flat", which + # the cascade's `_dwelling_exposure` maps to has_exposed_roof=False, + # dropping the 91.95 W/K external roof (+21.76 SAP over-prediction). An + # exposed (non-party) roof means the flat is on the top storey → + # "Top-floor flat"; its exposed floor is recovered downstream by the + # per-BP is_above_partially_heated_space override. + roof = ElmhurstRoofDetails( + roof_type="PA Pitched (slates/tiles), access to loft", + insulation="N None", u_value_known=False, + ) + floor = ElmhurstFloorDetails( + location="P Above partially heated space", + floor_type="", insulation="A As built", u_value_known=False, + ) + + # Act + result = _elmhurst_dwelling_type( + built_form="Mid-Terrace", property_type="Flat", + floor=floor, roof=roof, room_in_roof=None, + ) + + # Assert + assert result == "Top-floor flat" + + +def test_elmhurst_dwelling_type_party_roof_flat_stays_ground_floor() -> None: + # Arrange — a genuine ground-floor flat with a dwelling ABOVE lodges a + # party roof ("A Another dwelling above") + a real ground floor. It must + # stay "Ground-floor flat" (roof party, floor exposed) — the fix must + # not over-promote party-roof flats to top-floor. + roof = ElmhurstRoofDetails( + roof_type="A Another dwelling above", + insulation="N None", u_value_known=False, + ) + floor = ElmhurstFloorDetails( + location="G Ground floor", + floor_type="", insulation="A As built", u_value_known=False, + ) + + # Act + result = _elmhurst_dwelling_type( + built_form="Mid-Terrace", property_type="Flat", + floor=floor, roof=roof, room_in_roof=None, + ) + + # Assert + assert result == "Ground-floor flat" + + +def test_elmhurst_glazing_type_code_strips_interleaved_alternative_wall() -> None: + # Arrange — when a property lodges an Alternative Wall (cert 001431 + # storage-heater variants, "simulated case 34"), pdftotext interleaves + # the §11 "Alternative wall 1" location column into the wrapped + # glazing-type cell, e.g. "Double between 2002 Alternative wall and 2021 + # 1 Alternative wall". The wall-location fragments are not part of the + # glazing type — the helper must recover "Double between 2002 and 2021". + + # Act + code = _elmhurst_glazing_type_code( + "Double between 2002 Alternative wall and 2021 1 Alternative wall" + ) + + # Assert + assert code == _elmhurst_glazing_type_code("Double between 2002 and 2021") + + +def test_elmhurst_immersion_type_code_maps_dual_and_single() -> None: + # Arrange — Elmhurst Summary §15.1 "Immersion Heater" lodges "Dual" + # or "Single". RdSAP 10 §10.5 (PDF p.54): an immersion is "assumed + # dual" on a dual/off-peak meter; the SAP10 cascade code is 1 = dual, + # 2 = single (cert_to_inputs `_IMMERSION_TYPE_DUAL`). + + # Act + dual = _elmhurst_immersion_type_code("Dual", cylinder_present=True) + single = _elmhurst_immersion_type_code("Single", cylinder_present=True) + no_cylinder = _elmhurst_immersion_type_code("Dual", cylinder_present=False) + absent = _elmhurst_immersion_type_code(None, cylinder_present=True) + + # Assert + assert dual == 1 + assert single == 2 + assert no_cylinder is None + assert absent is None + + +def test_elmhurst_immersion_type_code_raises_on_unmapped_label() -> None: + # Arrange — a lodged §15.1 "Immersion Heater" label outside the + # {Dual, Single} set must strict-raise (mirror of the cylinder-size / + # cylinder-insulation helpers) rather than silently drop the field. + + # Act / Assert + with pytest.raises(UnmappedElmhurstLabel) as excinfo: + _elmhurst_immersion_type_code("Triple", cylinder_present=True) + assert excinfo.value.field == "immersion_type" + assert excinfo.value.value == "Triple" + + def test_all_seven_ashp_cohort_certs_extract_without_unmapped_label_raise() -> None: # Arrange — coverage forcing function: every cohort cert must # extract through `from_elmhurst_site_notes` without triggering an @@ -1539,6 +1811,20 @@ def test_elmhurst_glazing_label_full_coverage_per_sap10_table_6b() -> None: ) +def test_elmhurst_glazing_label_strips_wrapped_building_part_fragment() -> None: + # Arrange — pdftotext wraps the §11 building-part column (e.g. "1st" + # for the 1st Extension) onto the glazing-TYPE token even when no + # glazing-GAP descriptor ("16 mm") sits between them, so the lodged + # label reads "Double between 2002 and 2021 1st". The fragment is a + # building-part marker, not part of the glazing type — it must be + # stripped so the label resolves to its base code. Worksheet + # `simulated case 33` (direct-acting electric boiler + immersion) + # surfaced this. + # Act / Assert — base "Double between 2002 and 2021" → code 3. + assert _elmhurst_glazing_type_code("Double between 2002 and 2021 1st") == 3 + assert _elmhurst_glazing_type_code("Single glazing 2nd") == 1 + + def test_extension_party_wall_type_read_independently_of_as_main_wall() -> None: # Arrange — RdSAP 10 §3.3: "As Main Wall: Yes" inherits only the # external wall CONSTRUCTION; the party wall type is lodged diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 598139a8..a3fd091b 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -138,6 +138,10 @@ class SapHeating: cylinder_size: Optional[Union[int, str]] = ( None # int code from API; str (e.g. "Normal (90-130 litres)") from site notes ) + # RdSAP 10 §10.5 Table 28 — the lodged measured cylinder volume in + # litres, present only when `cylinder_size` is the "Exact" descriptor + # (gov-API code 6, field `cylinder_size_measured`). None otherwise. + cylinder_volume_measured_l: Optional[int] = None water_heating_code: Optional[int] = None # TODO: make enum? water_heating_fuel: Optional[int] = None # TODO: make enum? immersion_heating_type: Optional[Union[int, str]] = None # TODO: make enum? @@ -441,6 +445,14 @@ class SapAlternativeWall: # Mirrors `SapBuildingPart.wall_thickness_mm` per the # [[feedback-no-misleading-insulation-type]] convention. wall_thickness_mm: Optional[int] = None + # RdSAP 10 Table 4 (p.22) "Sheltered" wall: a sub-area adjacent to + # an unheated buffer space (stair core, adjoining structure) carries + # an added external surface resistance of R=0.5 m²K/W, so its U is + # reduced to U_sheltered = 1/(1/U + 0.5) — the same adjustment the + # main wall applies for `gable_wall_type=2`. The gov-EPC API lodges + # this per alt-wall as `sheltered_wall="Y"`; the Summary/Elmhurst path + # leaves it False (sheltering rides through the lodged U-value there). + is_sheltered: bool = False # Explicit basement determination. RdSAP10 `wall_construction == 6` is # canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement # heuristic hijacked it because Elmhurst lodges both "SY System build" @@ -498,10 +510,19 @@ class SapBuildingPart: # (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence # R-value path when a measured wall thickness is lodged alongside it. wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None + # RdSAP 10 §5.1 — the assessor's lodged main-wall U-value (W/m²K), surfaced + # by the gov-EPC API as `wall_u_value`. Authoritative when the open data + # redacts the backing insulation; overrides the §5.6/§5.7 construction- + # default cascade for the main wall (alt-wall sub-areas keep their own U). + wall_u_value: Optional[float] = None sap_alternative_wall_1: Optional[SapAlternativeWall] = None sap_alternative_wall_2: Optional[SapAlternativeWall] = None floor_heat_loss: Optional[int] = None + # RdSAP 10 §5.1 — the assessor's lodged ground-floor U-value (W/m²K), + # surfaced by the gov-EPC API as `floor_u_value`. Overrides the BS EN ISO + # 13370 / Table 19 ground-floor cascade when present. + floor_u_value: Optional[float] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = ( None # TODO: make enum/mapping? @@ -519,6 +540,12 @@ class SapBuildingPart: roof_construction_type: Optional[str] = ( None # str from site notes e.g. "PS Pitched, sloping ceiling" ) + # RdSAP 10 §5.1 — the assessor's lodged roof U-value (W/m²K). The gov-EPC + # API surfaces it as `roof_u_value`; it is the RdSAP-assessed output for + # the roof and overrides the §5.11 construction-default cascade in + # `heat_transmission` (the open data can redact the backing insulation + # thickness, so the cascade otherwise mis-derives an uninsulated U). + roof_u_value: Optional[float] = None roof_insulation_location: Optional[Union[int, str]] = ( None # TODO: make enum/mapping? ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2c762a9e..06771a7e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2,7 +2,7 @@ import re from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast +from typing import Any, Dict, Final, List, Optional, Sequence, TypeVar, Union, cast from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc_property_data import ( @@ -309,6 +309,7 @@ class EpcPropertyDataMapper: built_form=built_form, property_type=property_type, floor=survey.floor, + roof=survey.roof, room_in_roof=survey.room_in_roof, ) @@ -588,7 +589,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -779,7 +780,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -978,7 +979,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1175,7 +1176,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1410,7 +1411,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1571,6 +1572,7 @@ class EpcPropertyDataMapper: has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", cylinder_size=schema.sap_heating.cylinder_size, + cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, immersion_heating_type=schema.sap_heating.immersion_heating_type, @@ -1643,7 +1645,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1703,6 +1705,12 @@ class EpcPropertyDataMapper: bp.construction_age_band, bp.sloping_ceiling_insulation_thickness, ), + # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U + # overrides the §5.6/§5.7/§5.11 construction-default cascade + # (gov open data can redact the backing insulation). + roof_u_value=bp.roof_u_value, + wall_u_value=bp.wall_u_value, + floor_u_value=bp.floor_u_value, sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, is_flat=schema.property_type == 2, @@ -1711,10 +1719,11 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", ) if bp.sap_alternative_wall_1 else None @@ -1723,10 +1732,11 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", ) if bp.sap_alternative_wall_2 else None @@ -1810,6 +1820,18 @@ class EpcPropertyDataMapper: open_chimneys_count=schema.open_chimneys_count, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=schema.draughtproofed_door_count, + # Mechanical ventilation PCDB plumbing — feeds the §5 Table 4f + # line (230a) decentralised-MEV fan electricity + # (`SFPav × 1.22 × V`, PCDB Tables 322/329). Without the index + # the cascade's `_mev_decentralised_kwh_per_yr_from_cert` + # short-circuits to 0, leaving the MEV fan running cost off the + # bill (the +0.9 SAP over-rate residual on the MEV cohort once + # the §2 ventilation heat-loss was fixed). Duct type selects the + # Table 329 in-use factor (1=Flexible / 2=Rigid). + mechanical_ventilation_index_number=( + schema.mechanical_ventilation_index_number + ), + mechanical_vent_duct_type=schema.mechanical_vent_duct_type, # Lighting led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, @@ -1860,6 +1882,7 @@ class EpcPropertyDataMapper: has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning == "true", cylinder_size=schema.sap_heating.cylinder_size, + cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, immersion_heating_type=schema.sap_heating.immersion_heating_type, @@ -1931,7 +1954,7 @@ class EpcPropertyDataMapper: SapBuildingPart( identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, - wall_construction=bp.wall_construction, + wall_construction=_api_wall_construction_code(bp.wall_construction), wall_insulation_type=bp.wall_insulation_type, wall_thickness_measured=bp.wall_thickness_measured == "Y", party_wall_construction=_api_party_wall_construction_int( @@ -1991,6 +2014,12 @@ class EpcPropertyDataMapper: bp.construction_age_band, bp.sloping_ceiling_insulation_thickness, ), + # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U + # overrides the §5.6/§5.7/§5.11 construction-default cascade + # (gov open data can redact the backing insulation). + roof_u_value=bp.roof_u_value, + wall_u_value=bp.wall_u_value, + floor_u_value=bp.floor_u_value, sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, is_flat=schema.property_type == 2, @@ -1999,10 +2028,11 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", ) if bp.sap_alternative_wall_1 else None @@ -2011,10 +2041,11 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", ) if bp.sap_alternative_wall_2 else None @@ -2148,6 +2179,14 @@ class EpcPropertyDataMapper: # default 2 → over-counted shelter factor → -2.42 # ACH/month infiltration shortfall). sheltered_sides=_api_sheltered_sides(schema.built_form), + # RdSAP-Schema-21 `mechanical_ventilation` enum → §2 (24a..d) + # MV-kind dispatch. Without this an MEV / PIV-from-outside + # dwelling defaulted to NATURAL and under-stated its + # ventilation heat loss (+1.90 SAP over-rate on the n=20 + # MEV cohort). + mechanical_ventilation_kind=_api_mechanical_ventilation_kind( + schema.mechanical_ventilation + ), ), ) @@ -2594,6 +2633,33 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { } +_WallConstructionCode = TypeVar("_WallConstructionCode") + + +def _api_wall_construction_code( + code: _WallConstructionCode, +) -> _WallConstructionCode: + """Translate the gov-EPC API `wall_construction` enum to the + calculator's WALL_* code-space where the two DIVERGE. + + The gov enum (confirmed by the description-vs-code audit) is + 1=granite, 2=sandstone, 3=solid brick, 4=cavity, 5=timber frame (all + ALIGN with the WALL_* constants), 6=basement, 8=system built, 9=cob. + Only code 9 needs remapping HERE: it collides with the calculator's + `WALL_CURTAIN`=9 (which the Summary path's "CW" mapping legitimately + uses, paired with a `curtain_wall_age`). The gov API has no curtain + code, so an API `wall_construction` of 9 is unambiguously cob → remap to + `WALL_COB`=7 at this boundary, before it can hit the curtain dispatch in + `u_wall`. Gov 8 (system built) is left as-is — `u_wall` resolves it and + calc `WALL_PARK_HOME`=8 is never dispatched; gov 6 (basement) is left to + the basement machinery. Non-int values pass through unchanged; the input + type is preserved so each call site's typed `wall_construction` field + stays satisfied.""" + if code == 9: + return cast(_WallConstructionCode, 7) + return code + + # Elmhurst wall-insulation-type codes mapped to the SAP10 integer enum # documented at domain.sap10_ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY. # Two-letter dual codes ("FE", "FI") encode a cavity-wall that received a @@ -2614,26 +2680,53 @@ _ELMHURST_INSULATION_CODE_TO_SAP10: Dict[str, int] = { } +# Elmhurst roof codes that denote a party ceiling (another/same dwelling +# or non-residential space ABOVE), so the flat's roof is NOT a heat-loss +# surface: S (Same dwelling above), A (Another dwelling above), NR +# (Non-residential space above). Every other roof code (F / PN / PA / PS) +# is an exposed external roof — the dwelling is on the top storey. +_ELMHURST_PARTY_ROOF_CODES: frozenset[str] = frozenset({"S", "A", "NR"}) + + +def _elmhurst_roof_is_exposed(roof: Optional[ElmhurstRoofDetails]) -> bool: + """Whether a flat's roof is an exposed (heat-loss) external roof. + + The dual of the floor's "Another dwelling below" signal: the roof is + a party ceiling only when its Elmhurst code is S / A / NR (a dwelling + or non-residential space above). A plain external roof — including a + "PS Pitched, sloping ceiling" with no room-in-roof — is exposed, so + the flat sits on the top storey.""" + if roof is None: + return False + return _leading_code(roof.roof_type) not in _ELMHURST_PARTY_ROOF_CODES + + def _elmhurst_dwelling_type( *, built_form: str, property_type: str, floor: Optional[ElmhurstFloorDetails], + roof: Optional[ElmhurstRoofDetails], room_in_roof: Optional[ElmhurstRoomInRoof], ) -> str: """Compose `EpcPropertyData.dwelling_type` from the Elmhurst Summary's - property-type + attachment + floor-location + RR presence. + property-type + attachment + floor-location + roof-type + RR presence. For HOUSES: returns `f"{built_form} {property_type.lower()}"` — the historical contract ("Mid-Terrace house", "Detached house"). For FLATS: derives the floor-position prefix ("Top-floor", - "Mid-floor", "Ground-floor") from `floor.location` + RR presence: - - floor lodges "dwelling below" → roof exposed (RR present or - external roof) → Top-floor; roof party (no RR/external) → - Mid-floor; + "Mid-floor", "Ground-floor") from `floor.location` + roof exposure: + - floor lodges "dwelling below" → roof exposed (RR present OR an + external roof type, per `_elmhurst_roof_is_exposed`) → Top-floor; + roof party (dwelling above, no RR) → Mid-floor; - floor not over another dwelling → Ground-floor. + Reading the roof TYPE (not just room-in-roof presence) is the dual of + reading the floor location: a top-floor flat can have a plain external + sloping ceiling and no room-in-roof, which the RR-only test wrongly + routed to Mid-floor (dropping the roof heat-loss term). + The cascade's `_dwelling_exposure` (cert_to_inputs.py) is prefix- matched on the lowercase result; correct flat-prefix detection is the gate for floor / roof party-surface routing (RdSAP 10 §5). @@ -2642,8 +2735,17 @@ def _elmhurst_dwelling_type( return f"{built_form} {property_type.lower()}".strip() floor_loc = (floor.location if floor is not None else "") or "" has_dwelling_below = "dwelling below" in floor_loc.lower() - has_exposed_roof = room_in_roof is not None - if has_dwelling_below and has_exposed_roof: + has_exposed_roof = room_in_roof is not None or _elmhurst_roof_is_exposed(roof) + # An exposed (non-party) roof puts the flat on the TOP storey, whether + # or not a dwelling sits below it. A single-storey flat exposed both top + # (external roof) and bottom (floor over partially-heated space, no + # dwelling below) is still top-floor for roof purposes — its exposed + # floor is recovered by the per-BP is_above_partially_heated_space / + # is_exposed_floor override in §3. Keying "Top-floor" on + # has_dwelling_below dropped that roof, routing such flats to + # "Ground-floor flat" → has_exposed_roof=False → no roof heat loss + # (simulated case 34, cert 001431: 91.95 W/K roof dropped, +21.76 SAP). + if has_exposed_roof: position = "Top-floor" elif has_dwelling_below: position = "Mid-floor" @@ -3103,6 +3205,55 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]: return _API_BUILT_FORM_TO_SHELTERED_SIDES[built_form] +# GOV.UK API `mechanical_ventilation` integer (RdSAP-Schema-21 enum) → +# `MechanicalVentilationKind` enum name picking the SAP 10.2 §2 (24a..d) +# effective-air-change formula. Mirrors the Elmhurst-path +# `_ELMHURST_MV_TYPE_TO_KIND` so both source paths converge on the same +# cascade dispatch. +# 0 natural → None (NATURAL, 24d) +# 1 mechanical ventilation, no HR (MV) → MV (24b) +# 2 mechanical extract, decentralised (MEVdc)→ EXTRACT_OR_PIV_OUTSIDE (24c) +# 3 mechanical extract, centralised (MEV c) → EXTRACT_OR_PIV_OUTSIDE (24c) +# 5 positive input from loft → None — RdSAP 10 §2.6 treats +# loft-sourced PIV as natural +# (no added system air change) +# 6 positive input from outside → EXTRACT_OR_PIV_OUTSIDE (24c) +# Code 4 (MVHR, 24a) is DEFERRED: its (24a)m formula needs the lodged +# heat-recovery efficiency (`mvhr_efficiency_pct`, PCDB Table 326) which the +# API→cascade path does not yet plumb; mapping it to MVHR with a null +# efficiency would mis-model it as MV (no recovery), so it stays NATURAL +# until the efficiency is wired. The extract systems (2/3/6) carry no +# efficiency, so this slice closes the clean +1.90 SAP over-rate on the +# MEV/PIV-outside cohort (n=20, 5% within 0.5) — they were silently +# defaulting to NATURAL, under-stating ventilation heat loss. +_API_MECHANICAL_VENTILATION_TO_KIND: Final[dict[int, Optional[str]]] = { + 0: None, + 1: "MV", + 2: "EXTRACT_OR_PIV_OUTSIDE", + 3: "EXTRACT_OR_PIV_OUTSIDE", + 4: None, # MVHR — efficiency plumbing deferred; treat as natural + 5: None, + 6: "EXTRACT_OR_PIV_OUTSIDE", +} + + +def _api_mechanical_ventilation_kind(mechanical_ventilation: object) -> Optional[str]: + """Translate the API `mechanical_ventilation` integer to a + `MechanicalVentilationKind` enum name for the §2 cascade dispatch. + + Strict-coverage: a lodged integer outside the mapped set raises + `UnmappedApiCode` rather than silently defaulting to NATURAL (which + under-states ventilation heat loss for any mechanical system). + Non-int / None lodging stays as no-lodging (NATURAL).""" + if isinstance(mechanical_ventilation, str) and mechanical_ventilation.isdigit(): + mechanical_ventilation = int(mechanical_ventilation) + if not isinstance(mechanical_ventilation, int): + return None + if mechanical_ventilation not in _API_MECHANICAL_VENTILATION_TO_KIND: + raise UnmappedApiCode("mechanical_ventilation", mechanical_ventilation) + return _API_MECHANICAL_VENTILATION_TO_KIND[mechanical_ventilation] + + # ADR-0027 (Reduced-Field Synthesis): RdSAP 20.0.0 lodges a glazed_area *band* + # floor area, never window m². Synthesised total glazing = ratio x TFA; the ratio # is the MEDIAN glazing-area/floor-area of the 1000 real 21.0.1 certs (mean 0.155; @@ -3168,14 +3319,28 @@ def _synthesise_reduced_field_windows( ] +# ADR-0028: multiple_glazing_type "ND" (Not Defined) → the DG-modal +# default (cascade code 2 → daylight g_L 0.80), as for 18.0/19.0/17.x. +_RDSAP20_ND_GLAZING_TYPE: int = 2 + + def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]: """ADR-0027/0028 seam: 20.0.0 glazed_type codes 1-8+ND are identical to 21.0.1's, so route multiple_glazing_type through the verified cascade (fixes - code 1 "DG pre-2002" read as single), then call the shared core.""" + code 1 "DG pre-2002" read as single), then call the shared core. The "ND" + string (no cascade mapping) falls back to the DG-modal default, mirroring + `_synthesise_18_0_sap_windows` — without this the cascade raised + UnmappedApiCode on every ND-glazed 20.0.0 cert.""" + mgt = schema.multiple_glazing_type + glazing_type = ( + _api_cascade_glazing_type(mgt) + if isinstance(mgt, int) + else _RDSAP20_ND_GLAZING_TYPE + ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, - _api_cascade_glazing_type(schema.multiple_glazing_type), + glazing_type, ) @@ -3298,9 +3463,10 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: # "Fully double glazed" with a worksheet-resolved U=2.7. Per Table 24 # row 2 (DG pre-2002, gap 16+, PVC/wooden) the spec answer is U=2.7, # so GOV.UK API code 1 is a schema sibling of code 3 (both alias the -# "DG pre-2002 / unknown install date" row). The wider SAP10.2 -# glazing-type enum (4-12, 15+) is not yet mapped — incremental -# coverage as new fixtures surface them. +# "DG pre-2002 / unknown install date" row). The full RdSAP-21 +# glazing-type enum (single / secondary / triple, codes 4-12 + 15) is +# now mapped from Table 24 below — single-glazed windows previously fell +# through to the cascade default U=2.5 instead of their spec 4.8. # # Spec source: RdSAP 10 Table 24 "Window characteristics" page 49 — # DG pre-2002 spec U varies by gap (6mm=3.1, 12mm=2.8, 16+=2.7); the @@ -3308,20 +3474,48 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: # is lodged, falling back to the type-only default for missing gaps. _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # (u_value, solar_transmittance/g_⊥, frame_factor) - 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install - # date — Table 24 row 2 (PVC/wooden), 12mm - # gap default. Schema sibling of code 3. - 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) - 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) - 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 - 14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022 - # (Table 24 last row: 2022+ E/W / 2023+ Sc - # / 2022+ NI — same U/g as code 13 per - # spec; the integer codes 13/14 are - # schema siblings within the post-2022 - # product family. Cert 0380 lodges code - # 14 on all windows; worksheet uses U=1.4 - # = post-curtain 1.3258.) + 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install + # date — Table 24 row 2 (PVC/wooden), 12mm + # gap default. Schema sibling of code 3. + 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) + 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) + 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 + 14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022 + # (Table 24 last row: 2022+ E/W / 2023+ Sc + # / 2022+ NI — same U/g as code 13 per + # spec; the integer codes 13/14 are + # schema siblings within the post-2022 + # product family. Cert 0380 lodges code + # 14 on all windows; worksheet uses U=1.4 + # = post-curtain 1.3258.) + # + # SINGLE / SECONDARY / TRIPLE glazing — RdSAP 10 Table 24 (spec p.50). + # Previously unmapped (`_api_glazing_transmission` returned None) so the + # cascade silently routed e.g. a single-glazed window to the u_window + # all-None default 2.5 instead of its true 4.8 — over-rating single- + # glazed dwellings (cert 0370-2933, 7 single windows, +17 SAP). Codes + # per datatypes/epc/domain/epc_codes.csv `glazed_type` (RdSAP-Schema-21). + 4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24 + # Secondary "Normal emissivity" default (2.9). + 5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any + # period" (PVC/wooden 4.8, g 0.85). + 6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table + # 24 Triple pre-2002 12mm-gap default (2.1). + 7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on + # the reduced-data path → double pre-2002 / + # unknown-date family default (2.8), as code 3. + 8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown- + # date family default (2.1), as code 6. + 9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double + # or triple, 2002+ (pre-2022), any gap" (2.0). + 10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple + # pre-2002 12mm-gap default (2.1). + 11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity — + # Table 24 Secondary "Normal emissivity" (2.9). + 12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24 + # Secondary "Low emissivity" (2.2). + 15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row + # (4.8) when no measured U is lodged, as code 5. } @@ -3341,6 +3535,22 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ (3, 6): (3.1, 0.76, 0.70), (3, 12): (2.8, 0.76, 0.70), (3, "16+"): (2.7, 0.76, 0.70), + # Double glazed, known data (code 7) — aliases the double pre-2002 / + # unknown-date Table 24 row (same as codes 1/3) when no measured U. + (7, 6): (3.1, 0.76, 0.70), + (7, 12): (2.8, 0.76, 0.70), + (7, "16+"): (2.7, 0.76, 0.70), + # Triple glazed pre-2002 / unknown / known-data (codes 6/8/10) — + # Table 24 Triple pre-2002 row varies by gap (6mm=2.4, 12mm=2.1, 16+=2.0). + (6, 6): (2.4, 0.68, 0.70), + (6, 12): (2.1, 0.68, 0.70), + (6, "16+"): (2.0, 0.68, 0.70), + (8, 6): (2.4, 0.68, 0.70), + (8, 12): (2.1, 0.68, 0.70), + (8, "16+"): (2.0, 0.68, 0.70), + (10, 6): (2.4, 0.68, 0.70), + (10, 12): (2.1, 0.68, 0.70), + (10, "16+"): (2.0, 0.68, 0.70), } @@ -3348,15 +3558,20 @@ def _api_glazing_transmission( glazing_type: Optional[int], glazing_gap: object, ) -> Optional[tuple[float, float, float]]: - """Resolve (U, g, frame_factor) for an API window. Per-gap override - takes precedence over the type-only default; returns None when the - glazing_type isn't yet in the lookup.""" + """Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24. + Per-gap override takes precedence over the type-only default. Returns None + only when `glazing_type` is absent (None → cascade default). A glazing_type + PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing + the window to the u_window all-None default U=2.5 — the forcing function + that surfaced single-glazing (code 5 → 4.8) instead of letting it hide.""" if glazing_type is None: return None gap_key = (glazing_type, glazing_gap) if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] - return _API_GLAZING_TYPE_TO_TRANSMISSION.get(glazing_type) + if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: + return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] + raise UnmappedApiCode("glazing_type", glazing_type) # GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade @@ -3377,13 +3592,29 @@ def _api_glazing_transmission( # remap — incremental coverage as new fixtures surface them. _API_TO_SAP10_CASCADE_GLAZING_CODE: Dict[int, int] = { 1: 2, # RdSAP 21 DG pre-2002 → cascade DG (g_L=0.80, not single 0.90) + # RdSAP-21 codes 4 and 5 DIVERGE from the cascade enum in the 1-6 range + # (cascade 4 = double low-E soft-coat, 5 = secondary). Without these the + # g-tables read the wrong slot: a single-glazed window (RdSAP-21 5) took + # the cascade-5 secondary g (g⊥ 0.76 / g_L 0.80) for solar + daylight + # gains instead of single (0.85 / 0.90), and a secondary-glazed window + # (RdSAP-21 4) took cascade-4 double-low-E (0.63). Correctness fix + # (gains are second-order — negligible SAP impact; the dominant single- + # glazing error was the U-value, closed in the Table 24 transmission map). + 4: 5, # RdSAP 21 secondary glazing → cascade secondary slot (0.76/0.80) + 5: 1, # RdSAP 21 single glazing → cascade single slot (0.85/0.90) } def _api_cascade_glazing_type(api_glazing_type: int) -> int: """Canonicalise an API-lodged RdSAP 21 glazing-type code to the SAP - 10.2 Table 6b cascade enum that `_G_LIGHT_BY_GLAZING_CODE` reads. - Pass-through for codes already coincident with the cascade table.""" + 10.2 Table 6b cascade enum the g-value tables (`_G_LIGHT_BY_GLAZING_CODE` + / `_G_PERPENDICULAR_BY_GLAZING_TYPE`) read. Divergent codes are remapped; + coincident codes pass through. An unknown glazing code raises + `UnmappedApiCode` (mirrors `_api_glazing_transmission`) so a new code + surfaces rather than silently reading a wrong g-value slot. The known + set is the RdSAP-21 glazing enum the transmission table already covers.""" + if api_glazing_type not in _API_GLAZING_TYPE_TO_TRANSMISSION: + raise UnmappedApiCode("glazing_type (cascade g)", api_glazing_type) return _API_TO_SAP10_CASCADE_GLAZING_CODE.get(api_glazing_type, api_glazing_type) @@ -4210,6 +4441,11 @@ def _map_elmhurst_alternative_wall( wall_insulation_thickness=None, wall_thickness_mm=measured_thickness_mm, is_basement=_elmhurst_wall_is_basement(a.wall_type), + # Summary §7 "Alternative Wall N Sheltered Wall: Yes" → RdSAP 10 + # Table 4 (p.22) sheltered U = 1/(1/U + 0.5), applied by the + # cascade's `_alt_wall_w_per_k`. Mirror of the API path's + # `sheltered_wall == "Y"` wiring. + is_sheltered=a.sheltered, ) @@ -4924,6 +5160,17 @@ _ELMHURST_MAIN_FUEL_TO_SAP10: Dict[str, int] = { # existing oddity as "Oil" → 8; both labels are unused by any live # fixture). Live form on Elmhurst worksheets is "Bulk LPG". "Bulk LPG": 27, + # Elmhurst Summary §14.0 / §15.0 lodging form for BOTTLED LPG + # (cylinders) — the recommendation worksheets lodge "Bottled gas" as + # the §15.0 "Water Heating Fuel Type" for an SAP-code-115 boiler. + # 3 = API/epc-codes `main_fuel` code for bottled LPG main heating, + # which routes via `API_FUEL_TO_TABLE_32`/`API_FUEL_TO_TABLE_12` → + # Table-code 3 (bottled LPG main heating, 10.30 / 9.46 p/kWh). NOT + # the legacy "LPG bottled": 5 above — API code 5 = anthracite, and + # `canonical_fuel_code` resolves the same-valued Table-32 code 5 to + # anthracite (3.64 p/kWh), so a 5 here would mis-price the dwelling + # as cheap solid fuel (the cohort-2100 -61-SAP collision class). + "Bottled gas": 3, # Elmhurst Summary §15.0 "Water Heating Fuel Type" labels for the # bio-liquid fuels added to the EES dict above. Values are Table 32 # codes verbatim (no API enum collision). Spec: SAP 10.2 Table 12 @@ -5099,6 +5346,35 @@ def _elmhurst_pump_age_int(age_str: Optional[str]) -> Optional[int]: return 2 +# SAP 10.2 Table 4a "Room heaters" section — the secondary-heating SAP +# code → FUEL CATEGORY map. Per RdSAP 10 §10.4.1 + SAP 10.2 §12 (PDF +# p.34, "Secondary heating systems and applicable fuel types are taken +# from the room heaters section of Table 4a") each code carries an +# appliance type whose APPLICABLE FUELS are a category (gas / liquid / +# solid / electric); the SPECIFIC sub-fuel within that category (mains +# gas vs LPG vs biogas) is lodged SEPARATELY and is NOT recoverable from +# the Summary, which exports only the code. We therefore resolve each +# code to its category's MODAL fuel. Codes mirror the Table 4a efficiency +# rows in `domain.sap10_ml.sap_efficiencies` (601-613 gas, 621-625 +# liquid, 631-636 solid, 691-694/699/701 electric). +_ELMHURST_SECONDARY_GAS_CODES: Final[frozenset[int]] = frozenset( + {601, 602, 603, 604, 605, 606, 607, 609, 610, 611, 612, 613} +) +_ELMHURST_SECONDARY_LIQUID_CODES: Final[frozenset[int]] = frozenset( + {621, 622, 623, 624, 625} +) +_ELMHURST_SECONDARY_SOLID_CODES: Final[frozenset[int]] = frozenset( + {631, 632, 633, 634, 635, 636} +) +_ELMHURST_SECONDARY_ELECTRIC_CODES: Final[frozenset[int]] = frozenset( + {691, 692, 693, 694, 699, 701} +) +# Modal Table 32 / `_ELMHURST_MAIN_FUEL_TO_SAP10` fuel code per category. +_SECONDARY_FUEL_MAINS_GAS: Final[int] = 26 # Table 32 code 1, 3.48 p/kWh +_SECONDARY_FUEL_HEATING_OIL: Final[int] = 28 # → Table 32 code 4, 5.44 p/kWh +_SECONDARY_FUEL_HOUSE_COAL: Final[int] = 11 # Table 32 code 11, 3.67 p/kWh + + def _elmhurst_secondary_fuel_from_sap_code( sap_code: Optional[int], ) -> Optional[int]: @@ -5109,29 +5385,44 @@ def _elmhurst_secondary_fuel_from_sap_code( electricity when `secondary_fuel_type` is None — correct for the portable-electric default but wrong for fuel-fired room heaters. - SAP 10.2 Table 4a Category 10 ("Room heaters") code blocks: - 601-613: Gas (mains gas / LPG / biogas) — column A is mains gas; - column B for LPG. Cohort default is mains gas - (`_ELMHURST_MAIN_FUEL_TO_SAP10["Mains gas"] = 26`). - 621-625: Liquid fuel room heaters (oil / bioethanol). Cohort - not yet exercised; deferred until a fixture surfaces. - 631-634: Solid fuel room heaters (open fire, closed room - heater with/without boiler). House coal is the modal - default per Table 12 secondary rate (3.67 p/kWh). - 691-699: Electric room heaters. Cascade default (None) routes - to standard electricity (13.19 p/kWh). + Each code resolves to its SAP 10.2 Table 4a fuel CATEGORY's modal + fuel (per RdSAP 10 §10.4.1 — the specific sub-fuel is a separate + lodgement the Summary omits): + - gas room heaters (601-613) → mains gas (26) + - liquid room heaters (621-625) → heating oil (28) + - solid room heaters (631-636) → house coal (11) + - electric room heaters (691-699, → None (cascade electricity + 701) default; electricity IS the fuel) + + A code in the room-heater range (601-701) that is NOT a recognised + Table 4a row RAISES `UnmappedElmhurstLabel` rather than silently + mis-fuelling — per the strict-raise pattern. Codes outside the range + (e.g. None / no secondary) return None. Cohort cert 2102-3018-0205-7886-5204 surfaces the 631 ("Open fire - in grate") path — pre-slice the cascade defaulted to electricity - at 13.19 p/kWh, over-charging secondary by ~£340/yr and pushing - SAP -15.81 below the worksheet's 63.87. + in grate") path — pre-fix the cascade defaulted to electricity at + 13.19 p/kWh, over-charging secondary by ~£340/yr and pushing SAP + -15.81 below the worksheet's 63.87. + + SUB-FUEL CAVEAT: the gas block 601-613 resolves to the modal mains + gas; an LPG or biogas live-effect fire (worksheet "simulated case 37" + lodged biogas at 7.60 p/kWh vs mains gas 3.48 p/kWh) is + indistinguishable here — the sub-fuel is not in the Summary export. """ if sap_code is None: return None - if 601 <= sap_code <= 613: - return 26 # Mains gas, matching `_ELMHURST_MAIN_FUEL_TO_SAP10` - if 631 <= sap_code <= 634: - return 11 # House coal (Coal in `_ELMHURST_MAIN_FUEL_TO_SAP10`) + if sap_code in _ELMHURST_SECONDARY_GAS_CODES: + return _SECONDARY_FUEL_MAINS_GAS + if sap_code in _ELMHURST_SECONDARY_LIQUID_CODES: + return _SECONDARY_FUEL_HEATING_OIL + if sap_code in _ELMHURST_SECONDARY_SOLID_CODES: + return _SECONDARY_FUEL_HOUSE_COAL + if sap_code in _ELMHURST_SECONDARY_ELECTRIC_CODES: + return None # Electric room heaters → cascade electricity default + if 601 <= sap_code <= 701: + # A room-heater-range code we don't recognise — raise rather than + # let the cascade silently bill it as electricity. + raise UnmappedElmhurstLabel("secondary_heating.sap_code", str(sap_code)) return None @@ -5261,7 +5552,12 @@ _GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = frozenset(range(101, # of these so it can't mis-assign electricity from a separate immersion # (where §15.0 lodges the immersion's fuel, not the boiler's) — that # case still strict-raises `MissingMainFuelType` to force a mapper fix. -_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 5, 6, 7, 26, 27}) +# 3 = bottled LPG main heating ("Bottled gas" label); the other LPG +# carriers are the legacy API LPG codes (5/6/7) + the live "Bulk LPG" +# (27). All count as a gas/LPG carrier so `_elmhurst_gas_boiler_main_fuel` +# adopts a §15.0-lodged bottled-LPG water fuel for the boiler's space- +# heating carrier instead of falling through to the mains-gas meter flag. +_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 3, 5, 6, 7, 26, 27}) # SAP10 main-fuel code for mains gas (`_ELMHURST_MAIN_FUEL_TO_SAP10` # "Mains gas"). Used when a Table 4b gas boiler's carrier can't be read @@ -5552,6 +5848,15 @@ _ELMHURST_CYLINDER_NO_INSULATION_LABELS: frozenset[str] = frozenset( ) +# Elmhurst §15.1 "Immersion Heater" label → SAP10 `immersion_heating_type` +# cascade code. RdSAP 10 §10.5 (PDF p.54) + cert_to_inputs +# `_IMMERSION_TYPE_DUAL`/`_IMMERSION_TYPE_SINGLE`: 1 = dual, 2 = single. +_ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10: Dict[str, int] = { + "Dual": 1, + "Single": 2, +} + + # Elmhurst §15.0 "Water Heating Fuel Type" labels that route to solid- # fuel Table 32 codes (Anthracite, House coal, Wood logs/pellets, etc.). # Used by `_resolve_elmhurst_inaccessible_cylinder_size` to detect the @@ -5675,6 +5980,23 @@ def _elmhurst_cylinder_insulation_code( return code +def _elmhurst_immersion_type_code( + immersion_type_label: Optional[str], cylinder_present: bool, +) -> Optional[int]: + """Map an Elmhurst §15.1 "Immersion Heater" label ("Dual" / "Single") + to the SAP10 `immersion_heating_type` cascade code (1 = dual, 2 = + single per RdSAP 10 §10.5 p.54). Returns None when no cylinder is + present or the label is genuinely absent. Raises `UnmappedElmhurstLabel` + when the label IS lodged but isn't in the mapping dict — same + strict-fallback as `_elmhurst_cylinder_insulation_code`.""" + if not cylinder_present or immersion_type_label is None: + return None + code = _ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10.get(immersion_type_label) + if code is None: + raise UnmappedElmhurstLabel("immersion_type", immersion_type_label) + return code + + def _resolve_elmhurst_inaccessible_cylinder_insulation( age_band: str, ) -> tuple[int, int]: @@ -5828,6 +6150,30 @@ _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE: Final[re.Pattern[str]] = re.compile( _ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE: Final[re.Pattern[str]] = re.compile( r"\s+\d+\s*mm\b.*$" ) +# Fallback only: pdftotext can wrap the §11 building-part column onto the +# glazing-TYPE token WITHOUT an intervening glazing-gap descriptor, e.g. +# "Double between 2002 and 2021 1st" (the "1st" marks the 1st Extension). +# The ordinal / "Main" fragment is a building-part marker, not part of the +# glazing type — strip it and retry. No glazing-type key ends in an ordinal +# or "Main", so this is loss-free. Surfaced by `simulated case 33`. +_ELMHURST_GLAZING_LABEL_TRAILING_BP_RE: Final[re.Pattern[str]] = re.compile( + r"\s+(?:\d+(?:st|nd|rd|th)|Main)$" +) +# Fallback only: when a property lodges an Alternative Wall, pdftotext +# INTERLEAVES the §11 location column ("Alternative wall 1") into the +# wrapped glazing-TYPE cell, e.g. "Double between 2002 Alternative wall +# and 2021 1 Alternative wall" (cert 001431 storage-heater variants, +# `simulated case 34`). The greedy trailing-suffix strip truncates at the +# first "Alternative wall" (losing "and 2021"), so remove EVERY +# wall-location fragment + any stray 1-2 digit location index globally and +# retry. Loss-free: no glazing-type key contains a wall-location phrase or +# a bare 1-2 digit number (install-date years are 4 digits). +_ELMHURST_GLAZING_LABEL_EMBEDDED_WALL_RE: Final[re.Pattern[str]] = re.compile( + r"\s*(?:External|Alternative|Party)\s+wall(?:\s+\d+)?" +) +_ELMHURST_GLAZING_LABEL_STRAY_LOCATION_DIGIT_RE: Final[re.Pattern[str]] = re.compile( + r"\b\d{1,2}\b" +) def _elmhurst_glazing_type_code(label: Optional[str]) -> int: @@ -5852,6 +6198,25 @@ def _elmhurst_glazing_type_code(label: Optional[str]) -> int: code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(degapped) if code is not None: return code + # Fallback: strip a trailing wrapped building-part fragment (ordinal / + # "Main") and retry. + debp = _ELMHURST_GLAZING_LABEL_TRAILING_BP_RE.sub("", cleaned).strip() + if debp != cleaned: + code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(debp) + if code is not None: + return code + # Fallback: remove INTERLEAVED wall-location fragments from the raw + # label (Alternative/External/Party wall + stray location index) and + # collapse whitespace. Operates on `label`, not the greedily-truncated + # `cleaned`, so "Double between 2002 ... and 2021" survives. + dewall = _ELMHURST_GLAZING_LABEL_EMBEDDED_WALL_RE.sub(" ", label) + dewall = _ELMHURST_GLAZING_LABEL_STRAY_LOCATION_DIGIT_RE.sub(" ", dewall) + dewall = _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE.sub("", dewall) + dewall = re.sub(r"\s+", " ", dewall).strip() + if dewall != cleaned: + code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(dewall) + if code is not None: + return code raise UnmappedElmhurstLabel("glazing_type", label) @@ -6231,6 +6596,14 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: and survey.water_heating.cylinder_thermostat is not None else None ), + # §15.1 "Immersion Heater" (Dual / Single) → SAP10 + # `immersion_heating_type` code (1 = dual, 2 = single). Drives the + # Table 13 high-rate-fraction split for WHC-903 electric immersion + # DHW on a 7-/10-hour off-peak tariff (18-/24-hour bill 100% low). + immersion_heating_type=_elmhurst_immersion_type_code( + survey.water_heating.immersion_type, + survey.water_heating.hot_water_cylinder_present, + ), water_heating_code=survey.water_heating.water_heating_sap_code, water_heating_fuel=water_heating_fuel, secondary_heating_type=mh.secondary_heating_sap_code, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 747702bb..75cb929d 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -734,6 +734,36 @@ class TestFromRdSapSchema21_0_1: # --------------------------------------------------------------------------- +class TestApiWallConstructionCode: + """The gov-EPC API `wall_construction` enum diverges from the + calculator's WALL_* code-space at code 9: gov 9 = "Cob" but calc + `WALL_CURTAIN` = 9 (set by the Summary path's "CW" mapping). The gov + API has no curtain code, so an API code 9 must be remapped to + `WALL_COB` = 7 before it reaches `u_wall`'s curtain dispatch (which + would otherwise bill the wall at the curtain default 2.0 W/m²K).""" + + def test_gov_api_cob_code_9_remaps_to_wall_cob_7(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_wall_construction_code(9) + + # Assert — 9 (gov cob) → 7 (WALL_COB), dodging WALL_CURTAIN=9. + assert result == 7 + + def test_aligned_and_system_built_codes_pass_through_unchanged(self) -> None: + # Arrange — codes 1-5 already align; gov 8 (system built) is left + # for u_wall to resolve (calc WALL_PARK_HOME=8 is never dispatched); + # gov 6 (basement) is left to the basement machinery. + from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + + # Act / Assert + for code in (1, 2, 3, 4, 5, 6, 8): + assert _api_wall_construction_code(code) == code + assert _api_wall_construction_code(None) is None + + class TestApiResolveWallInsulationThickness: """`wall_insulation_thickness == "measured"` resolves to the separate `wall_insulation_thickness_measured` field (previously dropped by @@ -1133,6 +1163,144 @@ class TestApiRoofConstructionCode: assert result == "Pitched, sloping ceiling" +class TestApiGlazingTransmissionTable24: + """`_api_glazing_transmission` must resolve the SINGLE / SECONDARY / + TRIPLE glazing RdSAP-21 enum codes to their RdSAP 10 Table 24 (spec + page 50) (U, g, frame-factor) — not leave them unmapped (None), which + silently routed single-glazed windows to the cascade default U=2.5 + instead of their true 4.8, over-rating single-glazed dwellings (cert + 0370-2933, 7 single-glazed windows, +17 SAP).""" + + def test_single_glazing_code_5_is_table_24_u_4p8(self) -> None: + # Arrange — RdSAP 21 glazing_type 5 = "single glazing"; Table 24 + # row "Single / Any period" → U 4.8 (PVC/wooden), g 0.85. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(5, None) + + # Assert + assert result == (4.8, 0.85, 0.70) + + def test_single_glazing_known_data_code_15_is_table_24_u_4p8(self) -> None: + # Arrange — code 15 = "single glazing, known data"; same Table 24 + # Single row when no measured U is lodged on the reduced-data path. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(15, None) + + # Assert + assert result == (4.8, 0.85, 0.70) + + def test_secondary_glazing_normal_emissivity_code_11_is_u_2p9(self) -> None: + # Arrange — code 11 = "secondary glazing, normal emissivity"; + # Table 24 Secondary "Normal emissivity" row → U 2.9, g 0.85. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(11, None) + + # Assert + assert result == (2.9, 0.85, 0.70) + + def test_secondary_glazing_low_emissivity_code_12_is_u_2p2(self) -> None: + # Arrange — code 12 = "secondary glazing, low emissivity"; Table 24 + # Secondary "Low emissivity" row → U 2.2, g 0.85. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(12, None) + + # Assert + assert result == (2.2, 0.85, 0.70) + + def test_triple_glazing_2002_to_2022_code_9_is_u_2p0(self) -> None: + # Arrange — code 9 = "triple glazing, installed 2002-2022"; Table 24 + # "Double or triple, 2002+ (pre-2022), any gap" → U 2.0, g 0.72. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(9, None) + + # Assert + assert result == (2.0, 0.72, 0.70) + + def test_triple_glazing_pre_2002_code_10_default_gap_is_u_2p1(self) -> None: + # Arrange — code 10 = "triple glazing, pre-2002"; Table 24 Triple + # pre-2002 12mm-gap default → U 2.1, g 0.68. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(10, None) + + # Assert + assert result == (2.1, 0.68, 0.70) + + def test_double_glazing_2002_plus_code_2_unchanged(self) -> None: + # Arrange — regression guard: the already-mapped double-glazing + # 2002+ entry (U 2.0, g 0.72) is untouched by the single/secondary/ + # triple extension. + from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_glazing_transmission(2, None) + + # Assert + assert result == (2.0, 0.72, 0.70) + + +class TestApiCascadeGlazingCodeDivergentRemap: + """`_api_cascade_glazing_type` must translate the RdSAP-21 glazing codes + that DIVERGE from the SAP 10.2 Table 6b cascade enum the g-value tables + (`_G_PERPENDICULAR_BY_GLAZING_TYPE` / `_G_LIGHT_BY_GLAZING_CODE`) are + keyed on. Only code 1 was ever remapped; codes 4 and 5 sit in the 1-6 + range where the two enums disagree — RdSAP-21 4=secondary / 5=single, + cascade 4=double-low-E / 5=secondary — so an API single-glazed window + (5) read the cascade-5 (secondary) g slot for solar + daylight gains.""" + + def test_single_glazing_code_5_remaps_to_cascade_single_slot_1(self) -> None: + # Arrange — RdSAP-21 code 5 = single glazing; cascade single slot + # is 1 (g⊥ 0.85, g_L 0.90), not cascade 5 (secondary, 0.76/0.80). + from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_cascade_glazing_type(5) + + # Assert + assert result == 1 + + def test_secondary_glazing_code_4_remaps_to_cascade_secondary_slot_5(self) -> None: + # Arrange — RdSAP-21 code 4 = secondary glazing; cascade secondary + # slot is 5 (g⊥ 0.76, g_L 0.80), not cascade 4 (double low-E 0.63). + from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_cascade_glazing_type(4) + + # Assert + assert result == 5 + + def test_double_pre_2002_code_1_remap_unchanged(self) -> None: + # Arrange — regression guard: the existing code-1 remap (→2) stands. + from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_cascade_glazing_type(1) + + # Assert + assert result == 2 + + def test_rdsap21_native_codes_pass_through(self) -> None: + # Arrange — codes 9-15 already coincide with the g-table's RdSAP-21 + # extension slots, so they must pass through untranslated. + from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + + # Act / Assert + assert _api_cascade_glazing_type(14) == 14 + assert _api_cascade_glazing_type(9) == 9 + + # --------------------------------------------------------------------------- # Schema 20.0.0 — Reduced-Field Synthesis (ADR-0027) # diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index e1d0e2cf..289d415a 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -713,3 +713,78 @@ class TestUnmeasurableWallThickness: def test_wall_thickness_mm_is_none(self, result: EpcPropertyData) -> None: assert result.sap_building_parts[0].wall_thickness_mm is None + + +class TestElmhurstSecondaryFuelFromSapCode: + """`_elmhurst_secondary_fuel_from_sap_code` must RAISE on an unmapped + fuel-fired Table 4a Category-10 room-heater code rather than return + None and let the cascade silently bill it as electricity (13.19 + p/kWh). The Summary lodges only the secondary SAP code, not its fuel. + """ + + def test_gas_room_heater_resolves_to_mains_gas_modal_default(self) -> None: + # Arrange — SAP code 605 (flush-fitting live-effect gas fire), in + # the 601-613 gas block. The Summary cannot distinguish mains gas + # from LPG/biogas, so the modal default is mains gas (26). + from datatypes.epc.domain.mapper import ( + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fuel = _elmhurst_secondary_fuel_from_sap_code(605) + + # Assert + assert fuel == 26 + + def test_electric_room_heater_returns_none_for_electricity_default(self) -> None: + # Arrange — SAP code 693 (electric room heater); electricity IS its + # fuel, so None (→ cascade electricity default) is correct, NOT a + # silent mis-fuel. + from datatypes.epc.domain.mapper import ( + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fuel = _elmhurst_secondary_fuel_from_sap_code(693) + + # Assert + assert fuel is None + + def test_liquid_fuel_room_heater_resolves_to_heating_oil(self) -> None: + # Arrange — SAP code 621 (liquid-fuel room heater) → its Table 4a + # category's modal fuel, heating oil (28 → Table 32 code 4), NOT a + # silent electricity fallback. + from datatypes.epc.domain.mapper import ( + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fuel = _elmhurst_secondary_fuel_from_sap_code(621) + + # Assert + assert fuel == 28 + + def test_solid_fuel_room_heater_resolves_to_house_coal(self) -> None: + # Arrange — SAP code 631 (open fire in grate) → house coal (11). + from datatypes.epc.domain.mapper import ( + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fuel = _elmhurst_secondary_fuel_from_sap_code(631) + + # Assert + assert fuel == 11 + + def test_unrecognised_room_heater_range_code_raises(self) -> None: + # Arrange — SAP code 620 sits in the room-heater range (601-701) but + # is not a recognised Table 4a row; rather than silently mis-fuel it + # must raise. + from datatypes.epc.domain.mapper import ( + UnmappedElmhurstLabel, + _elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + with pytest.raises(UnmappedElmhurstLabel): + _elmhurst_secondary_fuel_from_sap_code(620) diff --git a/datatypes/epc/schema/rdsap_schema_20_0_0.py b/datatypes/epc/schema/rdsap_schema_20_0_0.py index dbd5feef..9a4b78f2 100644 --- a/datatypes/epc/schema/rdsap_schema_20_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_20_0_0.py @@ -267,7 +267,9 @@ class RdSapSchema20_0_0: energy_rating_current: int lighting_cost_current: float main_heating_controls: List[EnergyElement] - multiple_glazing_type: int + # "ND" (Not Defined) appears in the corpus alongside the 1-8 integer + # codes — type as Union to match the data (mirrors RdSAP-Schema-18.0). + multiple_glazing_type: Union[int, str] open_fireplaces_count: int heating_cost_potential: float hot_water_cost_current: float diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index da2125be..e70d7b52 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -76,6 +76,9 @@ class SapHeating: secondary_fuel_type: Optional[int] = None secondary_heating_type: Optional[int] = None cylinder_insulation_thickness: Optional[int] = None + # RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged + # only when `cylinder_size` is the "Exact" descriptor (code 6). + cylinder_size_measured: Optional[int] = None @dataclass @@ -220,6 +223,7 @@ class SapAlternativeWall: wall_insulation_type: int wall_thickness_measured: str wall_insulation_thickness: Optional[str] = None + sheltered_wall: Optional[str] = None @dataclass @@ -261,6 +265,16 @@ class SapBuildingPart: # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None + # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U, + # authoritative when the open data redacts the backing insulation + # thickness. Consumed by `heat_transmission` as a §5.1 documentary-evidence + # override. Previously undeclared → dropped by `from_dict`. + roof_u_value: Optional[float] = None + # Lodged main-wall / ground-floor U-values (W/m²K) — same §5.1 documentary- + # evidence override; authoritative when the open data redacts the backing + # insulation. Previously undeclared → dropped by `from_dict`. + wall_u_value: Optional[float] = None + floor_u_value: Optional[float] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index c5f456de..48843b05 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -81,6 +81,9 @@ class SapHeating: secondary_fuel_type: Optional[int] = None secondary_heating_type: Optional[int] = None cylinder_insulation_thickness: Optional[int] = None + # RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged + # only when `cylinder_size` is the "Exact" descriptor (code 6). + cylinder_size_measured: Optional[int] = None @dataclass @@ -258,6 +261,7 @@ class SapAlternativeWall: wall_insulation_type: int wall_thickness_measured: str wall_insulation_thickness: Optional[str] = None + sheltered_wall: Optional[str] = None @dataclass @@ -299,6 +303,17 @@ class SapBuildingPart: # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None + # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U. The + # gov open data can redact the backing insulation thickness, so this is the + # authoritative per-element value; consumed by `heat_transmission` as a + # §5.1 documentary-evidence override. Previously undeclared → dropped by + # `from_dict` (cert 7921-0052-0940-5007-0663 lodges roof_u_value=0.2). + roof_u_value: Optional[float] = None + # Lodged main-wall / ground-floor U-values (W/m²K) — same §5.1 documentary- + # evidence override as roof_u_value; authoritative when the open data + # redacts the backing insulation. Previously undeclared → dropped. + wall_u_value: Optional[float] = None + floor_u_value: Optional[float] = None @dataclass diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 2fa55acc..3d5b2b21 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -71,6 +71,11 @@ class AlternativeWall: thickness_mm: Optional[int] u_value_known: bool dry_lined: bool = False + # Summary §7 "Alternative Wall N Sheltered Wall: Yes/No". RdSAP 10 + # Table 4 (p.22): a sheltered sub-area (adjacent to an unheated buffer + # such as a flat's corridor) carries an added R=0.5 m²K/W → U = + # 1/(1/U + 0.5). Drives SapAlternativeWall.is_sheltered. + sheltered: bool = False @dataclass @@ -367,6 +372,11 @@ class WaterHeating: # §15.1 "Cylinder Thermostat" lodging (Yes / No). False or absent # keeps the cascade's no-thermostat Table 2b temperature factor. cylinder_thermostat: Optional[bool] = None + # §15.1 "Immersion Heater" lodging ("Dual" / "Single"). Drives the + # SAP10 `immersion_heating_type` code (1 = dual, 2 = single) used by + # the Table 13 high-rate-fraction DHW-cost split. None when no + # cylinder is present or the line is absent. + immersion_type: Optional[str] = None @dataclass diff --git a/docs/HANDOVER_API_PROFILING.md b/docs/HANDOVER_API_PROFILING.md index 9fd1527c..0b37765b 100644 --- a/docs/HANDOVER_API_PROFILING.md +++ b/docs/HANDOVER_API_PROFILING.md @@ -1,5 +1,11 @@ # Handover — API SAP accuracy (session 3): raises cleared, now profile-driven +> **➡️ SESSION 10 STARTS HERE: `docs/HANDOVER_SUMMARY_AUDIT.md`.** HEAD `872bc585`, **56.8% +> within-0.5** (909 computed / 0 raises). Session 9 ran FIVE data-driven audit angles — all +> converged on "remaining gap is diffuse" — and shipped the glazing Table-24 win (+16 certs) + +> HW-only heat-network DLF. The data-driven seam is mined out; **session 10 switches to the +> summary-report-based per-cert worksheet audit.** Read that doc first. + **Branch:** `feature/per-cert-mapper-validation` (long-lived working branch — **NEVER PR to main**; the user pushes/PRs when ready). **HEAD `a8e5563a`+** (the profiler commit), local-only ahead of origin. @@ -13,17 +19,216 @@ deproven approaches + the meter/shower data-fidelity findings), and the earlier `energy_rating_current`. Headline gauge: `PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py`. -| metric | now (`a8e5563a`) | -|--------|------------------| -| **% \|err\| < 0.5** | **45.1%** | -| % \|err\| < 1.0 | 59.4% | -| mean \|err\| | 1.702 | -| mean signed | −0.006 (balanced) | -| computed / raises | **909 / 0** | -| unsupported_schema | 100 (deferred — see below) | +| metric | session-7 (`3e05c95e`) | session-8 (`71b378b9`) | session-8b (`e7af6fda`) | **session-8c (`e6dda705`)** | +|--------|------------------|------------------|------------------|------------------| +| **% \|err\| < 0.5** | 54.24% | 55.01% | 55.12% | **54.90%** ⚠ | +| % \|err\| < 1.0 | 69.64% | 70.08% | 70.08% | **70.19%** | +| % \|err\| < 2.0 | 83.50% | ~83.6% | ~83.6% | **84.60%** | +| mean \|err\| | 1.248 | 1.233 | 1.232 | **1.224** | +| median \|err\| | 0.457 | 0.448 | 0.446 | **0.448** | +| computed / raises | 909 / 0 | 909 / 0 | 909 / 0 | **909 / 0** | +| unsupported_schema | 100 (deferred) | 100 (deferred) | 100 (deferred) | 100 (deferred) | -45% is still poor. The systematic bias is gone; remaining error is per-cert scatter + the -profile-surfaced buckets below. +### SESSION-8c — Table 4g default SFP for index-less MEV (HEADLINE TRADE-OFF), `e6dda705` +The 11 index-less MEV certs (mostly GAS houses, `mechanical_ventilation=2`, no PCDB +index) billed ZERO fan electricity → +2.2 over-rate (signed +1.23, median +2.19). +SAP 10.2 §2.6.3 / Table 4g note 1 (p.176): default SFP **0.8 W/(l/s)** used directly +as SFPav in (230a). `_mev_decentralised_kwh_per_yr_from_cert` now falls back to it +for a mechanical-EXTRACT system when no Table 322 record resolves. Cohort closed ++1.23 → +0.18 signed. **TRADE-OFF: within-0.5 55.12% → 54.90% (−2)** — the fan energy +is only ~half each cert's over-rate, so the cohort lands at ~+1.0 (still outside 0.5) +while 2 borderline offsetting-error certs cross out; within-1.0/within-2.0/mean|err| +all improve. Spec-correct, applied uniformly per the determinism principle. **NEXT: +the unmasked ~+1.0 residual on gas-house MEV — the OTHER half (not electric-flat +fabric, since these are gas houses).** Goldens green, pyright net-zero. + +### SESSION-8b — MEV fan electricity (PCDB index plumbed), `e7af6fda` +Follow-up to 8: the +0.9 residual on MEV after the §2 heat-loss fix was the FAN +electricity (§5 Table 4f (230a) `SFPav × 1.22 × V`, PCDB Tables 322/329). +`_mev_decentralised_kwh_per_yr_from_cert` already composes it but reads +`epc.mechanical_ventilation_index_number` + `mechanical_vent_duct_type`, which the +API builder never set → `pcdf_id is None` zeroed the fan energy on every API cert. +Wired both through the 21.0.1 construction. The 9 MEV certs with a PCDB index +closed +0.90 → +0.13; the **11 index-less MEV certs still sit at +1.36** — they +need the SAP Table 4h DEFAULT specific fan power (no PCDB record), a clean next +slice (verify the default SFP value against spec first). New e2e test + golden +fixture (cert 1300, dMEV index 500777). NB the 21.0.0 API builder didn't get the +8/8b ventilation edits (only 21.0.1, the corpus schema). + +### SESSION-8 — API `mechanical_ventilation` enum never mapped, `71b378b9` +Re-profiling after the sheltering fix surfaced `mechanical_ventilation=2` as a +clean systematic over-rate (n=20, signed +1.90, 5% within 0.5, every cert +positive). ROOT: `from_api_response` DROPPED the doc-level +`mechanical_ventilation` field, so `mechanical_ventilation_kind` was always None +and the §2 (24a..d) cascade defaulted to NATURAL — under-stating the air-change +rate for every mechanical system (only the Elmhurst path mapped it). New +`_api_mechanical_ventilation_kind` maps the RdSAP-Schema-21 enum → +MechanicalVentilationKind: 0→NATURAL, 1 MV→"MV", 2/3 MEV + 6 PIV-outside → +"EXTRACT_OR_PIV_OUTSIDE", 5 PIV-loft→NATURAL; **code 4 (MVHR) DEFERRED** (needs +the lodged HR efficiency, PCDB Table 326, the API path doesn't plumb — stays +NATURAL rather than mis-modelling as MV). Unmapped → `UnmappedApiCode`. Extract +cohort +1.90→+0.9 median (within-0.5 5%→35%), 20 improved / 3 regressed. The +**+0.9 residual is the MEV FAN ELECTRICITY** (§2.6.4 SFP, PCDB Table 322 +decentralised-MEV — 9 of 20 carry `mechanical_ventilation_index_number`): a +clean next slice. Goldens + regression green, pyright net-zero. + +### SESSION-7 — sheltered alternative walls (RdSAP Table 4 R=0.5), `3e05c95e` +The headline-moving audit. User: 53% is poor enough to indicate a MAJOR error +— audit again. The decisive diagnostic CHAIN (reusable): +1. **Error by `dwelling_type`** → flats are the drag (houses 60-66% within 0.5, + flats 28-47%; top-floor flat −1.19, mid-floor 28% within 0.5). +2. **Split flats by `mains_gas`** → ELECTRIC flats are the killer (gas flats + ~45%, electric flats 13-19%; top-floor electric mean|err| 3.62). +3. **Invert the SAP equation** (ECF = 0.42·cost/(TFA+45) → `sap_rating`) to get + `our_cost / lodged_cost` → electric flats over-cost ~3% median (houses 1.00), + amplified by the 4× electric price + steep low-band log curve (+3% ≈ +1.5 SAP + at band 40). +4. **Under-rate tracks space-heating kWh/m² precisely** — accurate certs 14-110, + under-rating certs 130-289 (cert 2021: 11 275 kWh for 39 m²) → over-stated + FABRIC, not tariff (storage flats on the correct 5.5p rate under-rate just as + much as room-heater flats at 13p). +5. **Field-by-field on the worst** → cert 0340-2976 (band-A flat) computed wall + 128 W/K though its main wall is a FILLED cavity (U 0.7); the excess was a + SECOND `u_wall` call — a `sap_alternative_wall_1` timber-frame sub-area at + U=2.5 lodging **`sheltered_wall="Y"`**. + +ROOT: the gov-EPC API lodges `sheltered_wall="Y"` per alt-wall, but it was +DROPPED by the schema + domain dataclasses, so `_alt_wall_w_per_k` billed the +sub-area at its full exposed U. RdSAP 10 Table 4 (PDF p.22) "Sheltered": added +external resistance R=0.5 m²K/W → U_sheltered = 1/(1/U + 0.5) — the SAME +adjustment the MAIN wall already applies for `gable_wall_type=2` +(`gable_wall_sheltered`, `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). Threaded +end-to-end: schema (21.0.0/21.0.1) + domain `SapAlternativeWall.is_sheltered` +(default False → Summary/Elmhurst path unchanged, goldens untouched) + +`from_api_response` `"Y"→True` + `_alt_wall_w_per_k` applies the 0.5 resistance +(lodged-U + basement alts return before it). 140 certs (15% of corpus) carry a +sheltered alt-wall; they under-rated at median −0.82 / signed −1.33 / 23% within +0.5. Eval: 102 improved / 38 regressed (offsetting-error cases — applied +spec-uniformly per the determinism principle); +10 net within-0.5. Goldens + +full regression green, pyright net-zero. **OPEN audit leftovers: electric-flat +tail persists (2021 −3.9 genuine uninsulated solid brick = data-fidelity; +top-floor "Flat, limited insulation" roofs); detached houses 45% (balanced +bidirectional scatter); the 38 sheltering regressions = offsetting errors +elsewhere; main-wall (non-gable) sheltering not audited; multi-element wall +description joined-string-to-all-BPs (113 certs, mild).** + +### SESSION-6 — community fuels 30/31/32 collide with electricity codes, `a7761ea8` +Picked up the deferred "fuel-collision part 2". The profiler's strongly-biased +`main_control=2306` bucket (n=11, signed −3.75, nearly uniform) was a PROXY: every +cert in it is `sap_main_heating_code=301` (community heating). ROOT: gov-API +`main_fuel_type` codes **30=waste-combustion / 31=biomass / 32=biogas** (all +"(community)" in `epc_codes.csv`) collide in VALUE with the Table-32 electricity +codes 30 (standard) / 31 (7h-low) / 32 (7h-high). All three sit in +`_ELECTRIC_FUEL_CODES`, so `is_electric_fuel_code` flagged a community-scheme main as +electric and `_is_electric_main` routed its cost through the off-peak electricity +branch — BYPASSING `_heat_network_factor_fuel_code`. Cert 8536 (biomass community) +billed 5.5 p grid electricity instead of the 4.24 p heat-network rate → −17.2 SAP. +Per SAP 10.2 Table 12 the community waste/biomass/biogas rows are 42/43/44 (the same +rows the backwards-compat enum codes 11/12/13 already map to). Added 30→42, 31→43, +32→44 to `API_FUEL_TO_TABLE_12` + `API_FUEL_TO_TABLE_32`. +**CRITICAL — the remap is GATED, NOT global.** A prototype putting 30/31/32 in +`canonical_fuel_code` regressed certs 2211 (+16 SAP) and 3420 (+7): the cascade uses +the bare Table-32 code 30 internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (the RdSAP +no-water-heating whc=999 immersion default writes `water_heating_fuel=30`), so a +blanket remap mis-prices genuine grid electricity as community waste. New +`_heat_network_community_fuel_code(fuel, main)` translates only when +`_is_heat_network_main(main)` is true, wired into `_main_fuel_code` AND +`_water_heating_fuel_code` (water gated via `_water_heating_main(epc)`). +**STRICT-RAISE (user-requested):** a heat-network main lodging a colliding community +fuel the table doesn't cover raises `UnmappedSapCode` rather than silently falling +through to the same-numbered electricity code (currently can't fire — all of +{30,31,32} are mapped — but guards future community fuels 56/57/58/99). Cert 8536 +−17.25 → −6.51 (residual now flat fabric), 5036 −6.29 → +1.36; mean|err| +1.329→1.312, within-1.0/2.0 up, within-0.5 held. 3 AAA tests, regression green +(only pre-existing 2 stone-wall U + a flaky historic-epc ordering test), pyright +net-zero. **STILL DEFERRED: dual-fuel API-9; the per-cert FABRIC tail (8536's −6.5 +is now flat fabric, 2100 demand 110905); the 100 unsupported-schema certs.** + +### SESSION-5 — fuel-code collision (anthracite/coal), `19235d11` +The re-audit traced the cohort's WORST cert (2100 anthracite, −61) + the −20/−21 coal cluster to +the **fuel-code collision** (`reference_fuel_code_collision`): the shared price/CO2/PE lookups check +Table-32/12-code membership BEFORE translating the gov-API fuel enum, so API-5 (anthracite) priced at +the bulk-LPG rate (12.19 p) and API-33 (coal) at the electricity-10h rate (7.5 p). KEY constraint +(goldens caught it): code 33 is ALSO the electricity-10h TARIFF code used by the dual-rate CO2/PE +split — so the fix CANNOT live in the shared table functions (breaks golden 000565). Instead +`canonical_fuel_code` (table_32) normalises the colliding SOLID-fuel enums at the **fuel-TYPE +boundary** (`_main_fuel_code`/`_water_heating_fuel_code`): 5→15 anthracite 3.64 p, 33→11 house coal +3.67 p; also fixes `is_electric_fuel_code(33)` mis-flagging coal as electric. Scoped to {5, 33} +(unambiguous large mispricings). mean|err| 1.424→1.329 (2100 −61→−11, residual now FABRIC: 110 905 +kWh demand = a separate area over-statement); within-0.5 flat at 53.1%. **DEFERRED follow-ups:** +dual-fuel API-9 (0.45 p delta, net-neutral, shifts certs in an un-understood direction — needs its own +look); community API-20/25/31 (route through the heat-network standing/CO2 path, NOT `unit_price` — +cert 8536 fuel-31 still mis-prices at 5.5 p electricity → −17). Method: the `decompose_api_cost_error.py` +heat:high/low tail + field-by-field audit of the worst certs surfaced it; `is_electric_fuel_code` +collisions are the tell. + +### THE UNIFYING PRINCIPLE (user, load-bearing) — "unknown insulation → as-built, NOT uninsulated" +An EPC insulation field that is UNDETERMINED (thickness `'ND'`/`'AB'`/absent → parsed None, or +description "as built / (assumed)") must map to the **age-band default** ("as built"), which is +INSULATED at newer bands — never to the uninsulated row. The recurring bug shape: a fixed +uninsulated U (cavity Filled-row, roof Table-16 2.30) is MASKED at old bands (where the age +default coincides with uninsulated) and only diverges (catastrophic under-rate) at newer bands. +All three session-5 fixes are instances. Review status across elements (all now conform): +- **Flat roofs** — FIXED `58cff932` (this slice). **Pitched roofs** — "unknown"→Table 18 (`a64e857b`); + "no insulation" only appears at bands A/B where 2.30 IS the age default (verified, no new-band bug). +- **Cavity walls** — FIXED `2e466ed1`. **System/timber/solid walls** — already on the as-built age + row (verified: bidirectional scatter, not a one-cause under-rate). **Floors** — undetermined + thickness already routes to the Table 19 age default (I=25/J=75/K=100 mm; verified). +- CAVEAT: do NOT broadly reroute PITCHED `'ND'`/`'NI'`→Table 18 (the parsed-0 `'NI'` case) — that + was empirically net-negative (pitched "no insulation" lodgements genuinely use 2.30 even at newer + bands; the description is load-bearing for pitched lofts). The principle holds for flat roofs, + cavity, floors; pitched lofts are the documented exception. + +## SESSION-5 UPDATE (HEAD `2e466ed1`) — whc=903 immersion HW + as-built cavity-U both closed + +**Shipped (47.6 → 52.1%, two spec-grounded fixes):** + +**(2) `2e466ed1` as-built "insulated (assumed)" cavity → Cavity-as-built row, not Filled cavity +(48.6 → 52.1%, the bigger win).** Robust-sweep lead: `wall_desc="Cavity wall, as built, insulated +(assumed)"` median +0.26, n=145, but split by age band it was a CLEAN G/H signal (G +1.38 n37, +H +1.61 n18; I-L neutral). RdSAP 10 Table 6 (England, p.41) "Filled cavity" row carries the † footnote +("assumed as built") ONLY at bands I-M, where it equals "Cavity as built"; at A-H the filled row is a +GENUINE fill. An as-built cavity (type 4) must use "Cavity as built" at all bands (G/H 0.60 not 0.35). +This was the SAME latent A-H bug slice S0380.210 fixed for "partial insulation (assumed)" but left for +"insulated (assumed)" by a legacy convention. Retired `_cavity_described_as_filled`; genuine fills +(wall_insulation_type=2) still hit the filled row. Per-band confirmation: I-M unchanged, G/H corrected +exactly. Bucket within-0.5 47% → 66%; eval +32 net (36 improved, 4 regressed — offsetting-error +electric-storage flats). 3 tests updated to the corrected behaviour (the legacy tests literally said +"we follow the legacy convention for parity"). + +**(1) `43d4c67d` WHC-903 electric immersion off-peak HW → SAP 10.2 Table 13 high-rate fraction +(47.6 → 48.6%).** The session-4 robust-sweep `whc=903` +lead (median +0.87, n=84). +- `43d4c67d` **WHC-903 electric immersion off-peak HW → SAP 10.2 Table 13 high-rate fraction.** + Was billing 100% at the off-peak low rate; Table 12a "Immersion water heater" row (p.191) routes + the WH column to Table 13 (p.197). New `tables/table_13.py` evaluates the Note-2 equations (f of + cylinder volume V, occupancy N, single/dual immersion), clamped [0,1]; 18-/24-hour use the 10-hour + column (Note 1). Wired into `_hot_water_fuel_cost_gbp_per_kwh` (threads V / N / immersion-single + from the caller; absent any → old 100%-low fallback, no regression). Off-peak WHC-903 cohort + (n=57): within-0.5 16% → 33%, median |err| 1.56 → 0.86. +- **IMMERSION CODE MAPPING CORRECTED: `immersion_heating_type` 1 = DUAL, 2 = SINGLE.** The + session-3 handover lead #3's "(1=single, 2=dual)" was UNVERIFIED and BACKWARDS. Confirmed via + RdSAP 10 §10.5 (p.54 — immersion "assumed dual" on a dual/off-peak meter) cross-checked with the + cohort: code 1 sits 3.6:1 on dual meters (40 vs 11), code 2 on single meters (22 vs 16). Dual + carries Table 13's small fraction → matches the over-rating direction; the single mapping + overshot in a prototype (cohort within-0.5 16% → 14%). The description-vs-code-audit lesson + again: skeptical of unverified handover code-semantics claims. +- **Next robust leads (post-BOTH-fixes sweep, ranked by net directional skew + MEDIAN):** every + top bucket is now an UNDER-rate cluster (negative median = fabric/flat scatter, per-cert not one-bug): + `property_type=2` flats med −0.39 (n=283, netDir +75), roof "(another dwelling above)" −0.46 (n=182), + `wall_desc="Solid brick, as built, no insulation"` −0.22 (n=114). No clean OVER-rate single-cause + bucket remains (cavity-insulated dropped to −0.13, main_heat_cat=7 to −0.31, whc=903 off the top — + all addressed). The flats under-rate is the biggest front but DIFFUSE (fabric/tariff per-cert) — likely + needs worksheets, not one rule. The 100 unsupported-schema certs remain the deferred big ticket. + METHOD NOTE: the cavity win came from splitting the +0.26 bucket BY AGE BAND — the mild median hid a + sharp G/H spike. When a description bucket has a modest median but a plausible single mechanism, + re-split by age band / sub-field before dismissing it as scatter. + +**SESSION-4 shipped (45.1 → 47.6%):** four spec-grounded fixes + closed one false lead. +See the `## SESSION-4 …` blocks below and the auto-memory for full detail. The systematic bias +is gone; the winning method this session was the **description-vs-code audit** + an +**outlier-robust categorical sweep** (rank by net directional skew + MEDIAN, not mean — the +mean-based metric is fooled by multi-cause outliers). 47.6% is still the target's halfway point. ## WHAT SHIPPED THIS SESSION (7 slices, all green, pyright net-zero) 1. `e41a0bc0` **PCDB heat pump w/o SAP code → Table 12a ASHP_APP_N SH split** (0.80 high-rate). @@ -39,7 +244,42 @@ profile-surfaced buckets below. (4)(5)(6) cleared **all 4 raises** — eval now has zero raises. 7. `(profiler)` **`scripts/profile_api_error.py`** — the new diagnostic (below). -## SESSION-4 UPDATE (HEAD `8741fbdf`) — read before re-working the leads below +## SESSION-4 UPDATE (HEAD `faf29942`) — read before re-working the leads below + +### Shipped this session (45.1 → 47.6%) +1. `b40e0f67` **exposed-floor-on-flats** (floor_heat_loss=1) — §3.12; per-BP override of the + dwelling-level flat suppression. +2. `8741fbdf` **floor_heat_loss=3 → above partially heated space, U=0.7** (§3.12/§5.14) + + re-pinned golden 7536 (its "irreducible residual" was THIS bug). +3. `5e7ef5c7` **boiler interlock for TRVs+bypass controls 2107/2111** (§9.4.11) — biggest single + win (+1.6pts). The no-interlock set was keyed off the wrong signal (the "+0.6 °C" annotation); + 2107/2111 lack a room thermostat → −5pp + Table 4f ×1.3 pump. +4. `faf29942` **description-lodged secondary heating** (§A.2.2/Table 11) — gas/oil boilers with an + API-description-only secondary ("Portable electric heaters (assumed)", code field None) + dropped the secondary (sec_kWh=0); now `_has_lodged_secondary_description` fires Table 11. + Also added cat-8 (electric underfloor) Table-11 fraction 0.10. +- `560c912c`/`d0f57a0e` docs: **roof_construction=8 lead CLOSED as data-fidelity** (not a bug — see + the roof-8 section below; user worksheet sim-case-29 proved we ≡ Elmhurst). + +### The two methods that worked (reuse these) +- **Description-vs-code audit:** join each int code (`floor_heat_loss`, `roof_construction`, + `wall_construction`, secondary type) to its authoritative `…[].description`, **on single-element + certs only** (multi-element `[]` arrays are LOSSY). Mis-maps fall out (floor-3, secondary). +- **Outlier-robust categorical sweep** (`/tmp/cat_audit2.py`): rank field-values by **net + directional skew** (#under−0.5 minus #over+0.5) + **MEDIAN** error. The mean-based directionality + metric (`/tmp/cat_audit.py`) gets FOOLED by multi-cause outliers (e.g. "Solid brick no insulation" + looked systematic at mean −1.07 but median is −0.22 = scatter; 2100 −61/RR drove it). + +### Open robust leads (verify with `/tmp/cat_audit2.py` — they shift; check MEDIAN not mean) +- `whc=903` electric-immersion HW: **median +0.87, n=84** — likely off-peak immersion handling + (the handover noted WHC 903 raises NotImplementedError on the Table-12a off-peak-immersion row). +- `main_heat_cat=7` electric storage: median +1.05, n=41 — over-rate (tariff/cost; partly artifact). +- `immersion_type=2` dual: +1.50, n=43 — we OVER-credit (so §12 dual→off-peak would worsen it). +- `dwelling_type=Top-floor flat`: median −1.24, n=99 — under-rate, mostly fabric scatter/artifacts. +- **Low-dir = SCATTER, do NOT single-fix:** non-PCDB main / data_source=2 (n=242, 28% within-0.5), + mains_gas=N electric (n=145), most flats. These are per-cert/data-fidelity, not one bug. + +### Resolved/closed this session (don't re-chase) - **Lead #1 `floor_codes=3` RESOLVED — the code IS authoritative.** The diagnostic that cracked it: join each **single-BP** cert's `floor_heat_loss` code to its independent `floors[].description` (the multi-BP tally was contaminated because a cert's `floors[]` summary @@ -65,6 +305,31 @@ profile-surfaced buckets below. `roof_codes=1` broad bucket is mean −0.15 (the −1.78 was top-floor-electric-flat outliers −29/−25). Remaining gains need per-cert worksheets (start code-3) or the unsupported-schema ticket. +## SESSION-4 AUDIT — description-vs-code cross-check (other element types) +After floor-3, audited every API field that carries BOTH an integer code AND an +independent `…[].description` (roofs, walls, floors, main_heating, controls), by joining +code↔description on **single-element certs** (the multi-element `[]` summary is lossy). +- **Walls / heating / controls: CLEAN** — codes map 1:1 to their description families; + residual error is per-cert scatter, not mis-mapping. +- **Roof `roof_construction=8`: investigated hard, NOT a bug — DO NOT re-chase.** It looked + like a huge lead (n=57, ~189 |err|, mean −2.43) because code-8 sloping ceilings described + "Pitched, insulated" with no numeric thickness compute U=2.30 (e.g. 7921-0052 roof_w 241 + W/K, SAP 56.75 vs lodged 80). **User worksheet (simulated case 29) settled it: at band C, + RdSAP/Elmhurst assumes UNINSULATED for an "insulated (assumed)" roof+wall with no measured + thickness → Elmhurst SAP 55, which MATCHES our 56.75.** The lodged 80 needed real insulation + the API record doesn't carry = data-fidelity artifact (meter-3 class). KEY LESSON (user): + **"insulated (assumed)" = the age-band DEFAULT insulation level, NOT "well insulated"** — at + old bands that default is ~uninsulated. Re-audit by band confirmed: old-band no-numeric = + artifacts (we ≡ Elmhurst); newer-band code-8 already gets correct insulated U (2031 band I + U≈0.18, 1436 band E 0.17, 0536 band B 0.09) — their under-rates are elsewhere; numeric- + thickness = accurate (9884 +0.06). So the force-uninsulated-at-pre-1950 rule is CORRECT. + A roof-8 "fix" would push us AWAY from Elmhurst's faithful 55 toward the unreproducible 80. +- **Roof `roof_construction=3`: latent mis-map (inert).** gov desc "(another dwelling above)" + (party roof) but we map to "Pitched, no access to loft"; masked by dwelling exposure + (mean −0.06). Correct for robustness only if touching the roof mapper; not worth chasing. +- **Worksheet-gen constraints (user, for future repros):** Elmhurst no longer lets you pick + build form for a flat; and a band-C repro defaults to uninsulated walls+roof. + ## KEY INSIGHT (load-bearing, from the user) **The gov EPC API JSON is the published OUTPUT of RdSAP software (Elmhurst), not its input.** So any API field Elmhurst doesn't expose as an *input* is register metadata the RdSAP10 method @@ -122,6 +387,113 @@ bug, not noise): on unsupported schema 19.1.0; type 4 already accurate). `shower_wwhrs` 1/2/3/4 = none / inst- WWHRS-1 / inst-WWHRS-2 / storage. Low headline value — not worth pursuing. +### SESSION-9 — profile sweep, six candidates ruled out (no shipped fix) +Re-ran `profile_api_error.py --min-n 10` at HEAD `da094feb` and chased every +biased/error-carrying bucket field-by-field. All resolved to proxy / already-deproven / +already-fixed — **no clean systematic bug found at this resolution**: +- **roof_construction code 5/8 (vaulted/sloping) under-rate** (gas: `5`−0.67, `4,5`−0.59, + `4,8`−0.85): a PROXY. `u_roof` returns the SAME value for code 4 and code 5 at a given + age/thickness (band G ND → 0.40 both; verified in `rdsap_uvalues.u_roof`). Code-5 certs + correlate with electric HW / flats / multi-part dwellings — those are the cause, not the roof. +- **age_band dropped on the roof path:** NOT a bug. Doc-level `construction_age_band` is None for + ALL 909 certs (age lives per-building-part); the mapper reads the bp-level band correctly + (`heat_transmission.py:735 part.construction_age_band`; cert 2270 → bp0 age=G mapped fine). +- **roof description "(same dwelling above)"** (a heated dwelling above → should be zero-loss + internal element): only 5 certs carry it and 4 are already within 0.5. Cert 0700 (−6.37) is a + messy 4-building-part data-fidelity cert (mixed Flat-no-insulation + Roof-room parts), not a + "same dwelling above" bug. Not systematic. +- **index-less MEV gas residual (the post-e6dda705 "next lead"):** RESOLVED by e6dda705. The 17 + index-less-MEV gas certs are now signed +0.09 / median +0.43 / 41% within-0.5 — centred scatter, + not a systematic over-rate. The ~6 certs at +1.0..+1.9 are per-cert, no common cause. +- **wit=4 (as-built cavity) gas −0.25 over 478 certs:** tail-driven, not a uniform Table-6 shift. + Splitting by age band, the worst bands (B −0.54, F −0.60, G −1.00) still hold 70–77% within-0.5 + — a few big-negative outliers drag the mean; correcting a Table-6 value would over-shift the + majority that are already accurate. +- **community-main + whc=903 electric-immersion HW under-cost (−6.3, n=3):** = the deproven + **meter_type=3** data-fidelity issue. All three (0380/2270/2673) carry meter_type=3; the HW + electricity tariff ambiguity (lodged HW ≈17.3 p/kWh vs our standard ≈22.36 p) is exactly the + Unknown-meter artifact already on this list. +- **The biggest +32 outlier 2958** (fuel=0 / sapcode=699): per-cert, NOT a class bug — cert 3420 + has an identical heating profile and sits at +0.18. 2958's error is fabric/geometry-specific. +Method note: `decompose_api_cost_error.py` cluster table = heat:high 311 (47% within) / +heat:low 206 / hw:low 173 / hw:high 125 / balanced 94 — i.e. the residual is a broad per-cert +fabric+HW tail, not one lever. The clean systematic wins (sheltered walls, MEV, fuel collisions) +are harvested; remaining headway is per-cert worksheet grind or the 100-cert schema big-ticket. + +### SESSION-9 (cont.) — silent-fallback audit + Tier-1 strict-raise SHIPPED, `7878a969` +User pivoted from accuracy-chasing to a robustness audit: "where do we map codes but NOT raise +on an unmapped code (silent fallback)?" Four parallel Explore agents swept the API mapper, +`cert_to_inputs`, the U-value/table lookups, and the worksheet dispatch. Findings triaged into +tiers (full list in the session report I wrote inline). **Tier 1 (shipped):** the fuel-type +helpers fed Table 12/32 cost/CO2/PE via a silent `API_FUEL_TO_TABLE_12.get(fuel, fuel)` +passthrough at 5 sites → an unmapped/colliding fuel hit the **mains-gas default** in +`unit_price_p_per_kwh`/`co2_factor`/`primary_energy_factor` (the cert-8536 −17-SAP class). New +`_table_12_factor_fuel_code` is byte-identical to `.get(fuel,fuel)` for every recognised input +and raises `UnmappedSapCode("table_12_factor_fuel", …)` only when the resolved code is in NO +table. **Verified 0/909 corpus raises; eval unchanged 54.9%/909/0** — pure future-proofing. +2 AAA tests, goldens + gate green, pyright net-zero (44=44). +**Tiers NOT actioned (user chose Tier-1 only — candidates for a future robustness slice):** +- Tier 2: `_api_glazing_transmission` ([mapper.py:2925]) maps only glazing codes [1,2,3,13,14]; + 4–12/15+ silently default U≈2.5 (the `reference_unmapped_api_code` "pending" gap). Window + orientation drop ([solar_gains.py:391]) silently drops a window from solar gains on an unmapped + orientation. Both would make currently-computed certs RAISE → need the codes MAPPED first, not + just a guard added (coverage tradeoff). +- Tier 3 (low impact, bands are schema-bounded A–M): `.get(band, DEFAULT)` swallows a non-None + unrecognised age band in `u_door` (→3.0), `u_basement_wall` (→0.7), `u_basement_floor` (→0.50), + `u_roof` (→0.4). The `age_band is None` branches above each are justified absent-data defaults; + only a typo/unknown band silently passing is the gap. + +### SESSION-9 (cont. 2) — glazing single/secondary/triple per RdSAP 10 Table 24, `a0432977` (BIGGEST WIN) +Chasing Tier-2 turned up the session's biggest lever. `_API_GLAZING_TYPE_TO_TRANSMISSION` mapped +only the DOUBLE-glazing codes [1,2,3,13,14]; single (5/15), secondary (4/11/12), triple +(6/8/9/10) returned None → the cascade routed them to the `u_window` all-None default **U=2.5** +instead of their Table 24 value. **Single glazing (U=4.8) modelled at half its heat loss** was +the killer — 79 corpus certs carry code 5; the 94 certs with any unmapped glazing code sat at +**32% within-0.5 vs 54.9% baseline**. Extended the transmission + gap-override tables with the +RdSAP 10 Table 24 (spec p.50) (U, g, FF) rows for every RdSAP-21 glazing code. **Eval 54.90% → +56.66% within-0.5** (net +16: 22 in, 6 offsetting-error out), within-1.0 70.2→71.9, mean|err| +1.224→1.203, 909/0. 7 AAA tests, goldens + gate green, pyright net-zero. **Method that found it: +profile by `sap_windows[].glazing_type`, split known vs unmapped codes, decode against +`epc_codes.csv` `glazed_type`.** + +### OPEN — KNOWN bugs still on the board (ranked) +1. **Glazing SOLAR/LIGHT g mis-keyed** (the natural follow-on): `_G_PERPENDICULAR_BY_GLAZING_TYPE` + (solar_gains.py) + `_G_LIGHT_BY_GLAZING_CODE` (internal_gains.py) are keyed on the SAP-10.2 + Table-6b cascade ordering (1=single…) but `_api_cascade_glazing_type` only translates {1:2} — + every OTHER API code passes through UNTRANSLATED, so API code 5 (single) reads the cascade-5 + slot (secondary/DG-low-E g) for solar+daylight gains. Smaller than the U effect (gains are + second-order) but a real systematic mis-map. Fix = complete `_api_cascade_glazing_type` → + Table-6b, OR feed the transmission-tuple g instead. Verify magnitude first. +2. **Window orientation silently dropped** (solar_gains.py:391 `if orientation is None: continue`) + — an unmapped orientation code loses the whole window's solar gain. HIGH-confidence data-loss. +3. **Tier-2 glazing strict-raise** — now that codes 1-15 are mapped, make `_api_glazing_transmission` + raise `UnmappedApiCode` on a present-but-unmapped (>15) code (zero current regression). +4. Tier-3 age-band swallows (low impact). MVHR (mech_vent=4) deferred. 21.0.0 builder missing the + 8/8b/8c ventilation + (now) glazing edits — extend if a 21.0.0 cert surfaces. +5. Per-cert: cert 0370-2933 still +15 after glazing (7 single windows but the over-rate is + non-window — separate fabric/heating cause). The 100 unsupported-schema certs (big ticket). + +### SESSION-9 (cont. 3) — glazing g remap (correctness, 0 impact) + post-glazing re-profile +- **Glazing g remap `49fb6c1b`:** completed the design's divergent-code remap — + `_API_TO_SAP10_CASCADE_GLAZING_CODE` gained {4:5, 5:1} so API single (5) / secondary (4) read + the right cascade g-slot (single 0.85/0.90, not secondary 0.76/0.80). CORRECTNESS ONLY: solar/ + daylight gains are second-order → 0 certs flip, eval unchanged 56.66%. The single-glazing + *U-value* (a0432977) was the whole accuracy effect. +- **Re-profiled at 56.7%.** Remaining biased buckets ruled out as deproven / spec-faithful / + tail-driven (verified, do NOT re-chase): + - `wall_construction=3` solid brick gas (n=197, signed −0.52, survives gas-split): **spec-faithful, + NOT a bug.** `u_wall` applies RdSAP §5.7 Table 13 thickness (≤200→2.5, 200-280→1.7, 280-420→1.4, + >420→1.1); the API plumbs `wall_thickness` and the mapper passes it; wit=4 "insulated (assumed)" + correctly takes the as-built U (no §5.8 reduction). Direction is also wrong for a thickness gap + (using thickness LOWERS U → over-rate). Residual = old solid-brick houses outperform as-built. + - `main_control=2113` (n=26, −0.76 but 77% within-0.5): tail-driven by messy multi-part certs + (0700/9092), not uniform. `wall_construction=5` (timber, n=35): tail-driven (7921 −23, 0370 +15). + - worst remaining under-rater **7921 (−23)**: roof code-8 sloping-ceiling "Pitched, insulated" + thick=None → uninsulated 2.3 (roof 241 W/K). On the DEPROVEN list (= data-fidelity, we ≡ Elmhurst + at uninsulated). Don't chase. + - NOT yet run down (genuine open candidates): `main_heat_cat=4` heat pumps (n=20, −0.78), + `water_fuel=20` community HW over-rate (n=40, +0.54, distinct direction — all sapcode 301). + ## THE 100 unsupported_schema CERTS (deferred — bigger ticket) SAP-Schema-19.1.0 (and other pre-21). The user is planning a separate big piece: map old schemas → new + **predict missing fields from similar-looking properties** (needs an EPC-prediction diff --git a/docs/HANDOVER_SUMMARY_AUDIT.md b/docs/HANDOVER_SUMMARY_AUDIT.md new file mode 100644 index 00000000..20d44be1 --- /dev/null +++ b/docs/HANDOVER_SUMMARY_AUDIT.md @@ -0,0 +1,145 @@ +# Handover — API SAP accuracy, SESSION 10: summary-report-based per-cert audit + +You're continuing API→SAP accuracy work on branch **`feature/per-cert-mapper-validation`** in +`/workspaces/model`, **HEAD `872bc585`**. This is a **long-lived working branch — NEVER PR to +main**; the user pushes/PRs when ready. 31 commits ahead of `origin`, unpushed. + +## THE GOAL (measurable, unchanged) +100% of API records with a lodged SAP compute **within 0.5 SAP** of the API's +`energy_rating_current`. Headline gauge: +``` +PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py +``` +**Current: 56.8% within-0.5** (within-1.0 72.2%, within-2.0 84.8%, mean|err| 1.197, median 0.438, +signed −0.229, **909 computed / 0 raises**, 100 unsupported_schema). Writes `_results.csv` to the +cache. Re-profile with `scripts/profile_api_error.py --min-n 12`; component decomposition with +`scripts/decompose_api_cost_error.py`. ~1009 cached API JSONs at `/tmp/epc_2026_sample` +(`EPC_SAMPLE_CACHE` overrides). + +## ⚠️ THE PIVOT — why this session is DIFFERENT +The previous session (9) ran **FIVE independent data-driven audit angles**, all of which converged +on the same conclusion — the clean systematic levers are harvested and the remaining gap is +**diffuse** (data-fidelity matching the reference software, data-composition, per-cert scatter): +1. **Error-bucket profiler** → scatter, no clean residual bias. +2. **Dropped-field scan** (raw-JSON field present but mapped-None) → every field plumbed. +3. **CO2/PE reconciliation** vs lodged → systematic +15% but it's a **factor-basis** difference + (our SAP 10.2 Table 12 vs the lodged EPC's published basis), NOT demand (cost-SAP matches), + NOT scope (our CO2/PE correctly exclude appliances/cooking per spec line 326). **Off-goal** — + CO2/PE don't feed the cost-based SAP rating. +4. **Cross-provider parity** (LIG-21.0 vs 21.0.1, same builder): LIG under-rates −0.59, cleanest in + cavity (LIG −0.45 vs standard −0.01) — but the wall U computes CORRECTLY; the cause is diffuse + (composition: more solid-brick/system-built/non-PCDB mains, all under-rating in BOTH datasets; + plus per-cert scatter). No recoverable LIG-specific mapping bug. +5. **HW-demand reconciliation** vs lodged HW cost → median residual ≈ £0, well-calibrated. The + high-HW/m² certs are small flats (SAP HW floor effect) and are ACCURATE. + +**The data-driven seam is mined out.** The user (correctly — they drove the glazing find) wants to +switch to **worksheet-level ground truth**: the **summary-report-based audit**. Do NOT re-run the +five angles above expecting a new clean bug; pursue per-cert worksheet pins instead. + +## THE METHOD — summary-report-based audit (this session's loop) +For a chosen cert, the **user generates two Elmhurst worksheets from the cert's OWN API JSON** +(`/tmp/epc_2026_sample/.json`): the **P960** (full SAP worksheet, line refs `(1a)..(486)`) +and the **Summary**. Your loop: +1. **Describe the cert field-by-field FIRST** (so the user can reproduce it in Elmhurst): dwelling + type, TFA, age band, every building part (wall/roof/floor construction + insulation + thickness), + windows, the heating system (sap_main_heating_code, category, control, emitter, fuel, PCDB index), + water heating (whc, fuel, cylinder), ventilation, PV. Use the mapper to dump the *mapped* + `EpcPropertyData` so the description matches what we actually compute on. +2. **Pin the cascade to the worksheet line refs at abs=1e-4** — `heat_transmission_section_from_cert` + for §3 (26)..(37), the water-heating/§4, §9a/§10a etc. Localise the divergence to a specific + line ref → extractor / mapper / calculator gap. +3. **VALIDATE BEHAVIOUR against the LODGED SAP, not blindly against the user's repro.** The user's + Elmhurst repros are APPROXIMATE (they often pick a slightly-wrong system / inputs). Confirm the + repro's continuous SAP ≈ the lodged `energy_rating_current` BEFORE trusting its line refs; if the + repro diverges from lodged, the repro is the problem, not our cascade. (See + `reference_elmhurst_only_test_pattern` + the `_elmhurst_worksheet_000565` prototype for the + mapper-driven cascade-fixture shape.) +4. One confirmed cause = one TDD slice = one commit (conventions below). + +### Starter candidate certs (clean gas, single-building-part, schema 21.0.1 — NOT electric-fabric +### tail, NOT LIG, NOT deproven). |err| in 0.7–6, good worksheet targets: +``` +8700-1771-0622-8501-3963 -5.77 gas cat2 whc=903 (electric immersion HW on a gas main — odd) +2135-2729-0509-0142-6226 +5.29 sapcode 119 +4700-6865-0122-1501-3963 -5.19 detached gas boiler +0700-6754-0922-3505-3963 +4.35 whc=911 (gas boiler/circulator for water only) +9093-3060-2207-6506-0204 +3.60 sapcode 502 cat9 (WARM AIR main) + whc=950 — see SESSION-9 below +0330-2817-5590-2096-7831 -2.95 gas cat2 mid-terrace +``` +The full list (81 clean candidates) regenerates from the profiler/`_results.csv`. The user may pick +their own certs from domain knowledge — let them drive selection; your job is the field-by-field +description + the line-ref pin. + +## WHAT SHIPPED IN SESSION 9 (don't redo) — 54.90% → 56.8% +- **`a0432977` glazing single/secondary/triple per RdSAP 10 Table 24 (THE BIG WIN, +16 certs).** + `_API_GLAZING_TYPE_TO_TRANSMISSION` only mapped double-glazing [1,2,3,13,14]; single (5/15, U 4.8), + secondary (4/11/12), triple (6/8/9/10) returned None → silent u_window default U=2.5. Single glazing + at half its real heat loss was the killer. Method that found it: profile `sap_windows[].glazing_type`, + decode vs `epc_codes.csv` `glazed_type`. +- **`872bc585` HW-only heat-network DLF (whc 950/951/952).** The Table 12c distribution loss fired + only for `_is_heat_network_main AND whc∈{901,902,914}`; HW-only heat networks missed it entirely. + Added a whc-gated branch `water_eff = plant_eff / DLF` (RdSAP §10, spec p.36). All 3 corpus whc=950 + certs improved in |err|; cert **9093 still +3.60** — its residual is the **warm-air main (sapcode + 502, cat 9)**, a SEPARATE cause and a good worksheet candidate. +- **`7878a969` fuel strict-raise** at the Table-12 factor boundary (the cert-8536 collision class). +- **`49fb6c1b` glazing g remap** (codes 4/5 → correct cascade g-slots) — correctness, 0 SAP impact. +- **`a7990edb` ROBUSTNESS GUARDS** (forcing functions): `_api_glazing_transmission` + + `_api_cascade_glazing_type` raise `UnmappedApiCode` on present-but-unmapped glazing; + `seasonal_efficiency` + `water_heating_efficiency` raise `UnmappedSapCode` on present-but-unmapped + codes (was the blind 0.80/0.78 default). **0 current-corpus impact (tables complete) — these are + guards.** KEY for this session: **if a worksheet-audit cert RAISES, that's the guard surfacing a + real gap — map the code.** Also re-verify: efficiency table already covers WHC 908 (multi-point + gas) / 950 (HW heat network) — those are NOT unmapped bugs. + +## RULED OUT — do NOT re-chase (verified this session + DEPROVEN list in HANDOVER_API_PROFILING.md) +- **The 100 `unsupported_schema` certs are full-SAP NEW BUILDS** (`assessment_type="SAP"`, mean + rating 86, transaction_type 6 = new dwelling). Structurally different (sap_walls/sap_roofs/ + sap_openings with measured U-values, DER, construction_year). **Out of scope for a retrofit + product — do NOT build a parallel pipeline.** They're already excluded from the 56.8%. +- **Solid brick** (gas, −0.52): spec-faithful — `u_wall` applies RdSAP §5.7 Table 13 thickness; + direction wrong for a thickness gap. Data-fidelity (old houses outperform as-built). +- **Roof code-8 sloping-ceiling "insulated"-no-thickness** (cert 7921 −23): data-fidelity, we ≡ + Elmhurst at uninsulated. **meter_type=3** (Unknown meter): data-fidelity. Orientation code-9 drop: + the East/West "fix" HURTS the gauge; conservatory-only spec rule; leave it. +- LIG-21.0 divergence, CO2/PE +15%, HW-demand over-estimate: all diffuse / off-goal (see THE PIVOT). + +## CONVENTIONS (non-negotiable) +One cause = one slice = one commit; **spec citation (page+line) in the message** (the user +explicitly asks us to confirm against the SAP 10.2 / RdSAP 10 PDFs in `domain/sap10_calculator/ +docs/specs/` before claiming a fix — see `feedback_spec_citation_in_commits`); AAA test headers +(`# Arrange / # Act / # Assert`); **`abs(x-y)<=tol` not `pytest.approx`** (strict-pyright); +private-symbol test imports single-line with `# pyright: ignore[reportPrivateUsage]`; **SAP 10.2 +only** (ignore the 10.3 PDF); no tolerance-widening / xfail; RdSAP is deterministic — every fix is a +spec rule, apply uniformly even when it unmasks offsetting errors, **but flag any within-0.5 +regression to the user**; **pyright strict net-zero** (baseline-compare via `git stash`; avoid +`**dict` unpacking into `make_minimal_sap10_epc` — explodes pyright); **stage files BY NAME** (the +tree carries unrelated `scripts/` + `sap worksheets/` changes — never `git add -A`); end commit +messages with `Co-Authored-By: Claude Opus 4.8 `. + +**Regression gate** after any calc/mapper change (goldens esp. 6035 + 000565 are the gate): +``` +PYTHONPATH=/workspaces/model python -m pytest tests/domain/sap10_calculator/ \ + domain/sap10_ml/tests/ datatypes/epc/ backend/documents_parser/tests/ -q +``` +**IGNORE these pre-existing fails** (not yours): `test_total_floor_area`, the 2 stone-wall U tests +in `test_rdsap_uvalues.py`, the flaky `test_other_client_error_propagates` (passes in isolation). + +## ARCHITECTURE (quick map) +API path = `EpcPropertyDataMapper.from_api_response(doc)` → `from_rdsap_schema_21_0_1` → +`cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)` → `calculate_sap_from_inputs(...)`. Fabric U via +`domain/sap10_ml/rdsap_uvalues.py` (`u_wall/u_roof/u_floor/u_window`) feeding +`worksheet/heat_transmission.py` (per-BP loop). HW in `cert_to_inputs` §4 + `worksheet/water_heating.py`. +Efficiency in `domain/sap10_ml/sap_efficiencies.py` (`seasonal_efficiency` / `water_heating_efficiency`, +now strict-raising). Fuel cost/CO2/PE: `tables/table_12.py` + `tables/table_32.py`. SAP equation: +`worksheet/rating.py` (ECF = 0.42·cost/(TFA+45)). The §3 breakdown helper for pins: +`cert_to_inputs.heat_transmission_section_from_cert(epc)` → `HeatTransmission` (every (26)..(37) line +ref). **KEY INSIGHT: the gov-API JSON is the published OUTPUT of RdSAP software, not its input — +route fields Elmhurst doesn't consume to the spec default.** + +## READ ALSO +- `docs/HANDOVER_API_PROFILING.md` — the full SESSION-3..9 log + the load-bearing **DEPROVEN** list. +- Auto-memories: `project_per_cert_mapper_validation_state`, `reference_unmapped_sap_code`, + `reference_unmapped_api_code`, `reference_fuel_code_collision`, `feedback_software_no_special_handling`, + `feedback_spec_citation_in_commits`, `feedback_worksheet_not_api_reference`, + `reference_elmhurst_only_test_pattern`. diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 410eb5e6..b10e1b76 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -64,6 +64,10 @@ _HHR_STORAGE_OVERLAY = HeatingOverlay( cylinder_insulation_type=1, cylinder_insulation_thickness_mm=120, cylinder_thermostat="Y", + # Single off-peak electric immersion — drives the SAP 10.2 Table 13 HW + # high-rate split (matches the relodged after-cert; without it the HW + # bills 100% at the low rate, +1.26 SAP over the reference). + immersion_heating_type=1, has_hot_water_cylinder=True, meter_type="Dual", ) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index c11285ca..d47d84a7 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -98,6 +98,7 @@ _SAP_HEATING_FIELDS: tuple[str, ...] = ( "cylinder_insulation_type", "cylinder_insulation_thickness_mm", "cylinder_thermostat", + "immersion_heating_type", ) _ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas") diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 083f8898..7d951ac5 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -133,6 +133,11 @@ class HeatingOverlay: cylinder_insulation_type: Optional[Union[int, str]] = None cylinder_insulation_thickness_mm: Optional[int] = None cylinder_thermostat: Optional[str] = None + # The cylinder's immersion-heater arrangement (e.g. single off-peak + # immersion = 1). Drives the SAP 10.2 Table 13 HW high-rate fraction on + # off-peak tariffs — without it the calculator cannot resolve + # `immersion_single` and bills HW 100% at the low rate. + immersion_heating_type: Optional[Union[int, str]] = None # EpcPropertyData (top-level) has_hot_water_cylinder: Optional[bool] = None # sap_energy_source diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 36f10c62..d60daefd 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -87,7 +87,10 @@ from domain.sap10_calculator.tables.pcdb.postcode_weather import ( from domain.sap10_calculator.tables.table_12 import ( API_FUEL_TO_TABLE_12, CO2_KG_PER_KWH, + CO2_KG_PER_KWH_MONTHLY, + PE_FACTOR_MONTHLY, PRIMARY_ENERGY_FACTOR, + UNIT_PRICE_P_PER_KWH, _DEFAULT_CO2_KG_PER_KWH, # pyright: ignore[reportPrivateUsage] _DEFAULT_PEF, # pyright: ignore[reportPrivateUsage] co2_monthly_factors_kg_per_kwh, @@ -105,8 +108,12 @@ from domain.sap10_calculator.tables.table_12a import ( tariff_from_meter_type, water_heating_high_rate_fraction, ) +from domain.sap10_calculator.tables.table_13 import ( + electric_dhw_high_rate_fraction, +) from domain.sap10_calculator.tables.table_32 import ( additional_standing_charges_gbp, + canonical_fuel_code, is_electric_fuel_code, is_liquid_fuel_code, standing_charge_gbp, @@ -227,8 +234,15 @@ _PENCE_TO_GBP: Final[float] = 0.01 _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _TMP_LOW_KJ_PER_M2_K: Final[float] = 100.0 # `wall_construction` int codes (domain/sap10_ml/rdsap_uvalues.py): -# 5 = timber frame, 7 = cob, 8 = park home — Table 22's "three types". -_TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7, 8}) +# 5 = timber frame, 7 = cob — always Table 22 low-mass. Park home is the +# third "always low-mass" type, but its wall code (8) is OVERLOADED: the +# Summary path's "PH" mapping uses 8 for park home, whereas the gov-API +# enum uses 8 for SYSTEM BUILT (a masonry type, Summary system build = code +# 6). Code 8 therefore takes the low-mass value only when the dwelling is +# actually a park home (`property_type`); otherwise it is system built and +# keeps the masonry 250 — see `_thermal_mass_parameter_kj_per_m2_k`. +_TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7}) +_TMP_PARK_HOME_OR_SYSTEM_BUILT_WALL_CODE: Final[int] = 8 # `wall_insulation_type` int codes that are INTERNAL insulation # (Table 22 "masonry … with internal insulation"): 3 = internal wall # insulation, 7 = filled cavity + internal. External (1), filled cavity @@ -385,7 +399,9 @@ def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float: 2 → 41 kWh (2013 or later) Table 4f footnote a) then multiplies the row by 1.3 when the room - thermostat is absent (control code 2101 / 2102). + thermostat is absent — the same "no room thermostat" criterion as the + interlock rule, i.e. `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES` + (2101 / 2102 / 2107 / 2111; bypass and TRVs are not a room thermostat). """ if not _is_wet_boiler_main(main): return 0.0 @@ -550,6 +566,11 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float: _MEV_KITCHEN_FAN_CONFIG_CODES: Final[frozenset[int]] = frozenset({1, 3, 5}) # PCDB Table 329 / 322 system_type=2 = decentralised MEV. _MEV_DECENTRALISED_SYSTEM_TYPE: Final[int] = 2 +# PCDB Table 329 system_type=10 = "default data" (PCDF Spec §A.20) — the +# in-use-factor row used when the SFP is taken from SAP 10.2 Table 4g +# rather than a specific PCDB product. Table 4g note 3 (PDF p.176) +# requires the default SFP to be multiplied by this IUF (2.5, duct-agnostic). +_MEV_DEFAULT_DATA_SYSTEM_TYPE: Final[int] = 10 # Elmhurst "Duct Type" cascade integer: 1=Flexible, 2=Rigid (per # `_ELMHURST_DUCT_TYPE_TO_INT` in datatypes.epc.domain.mapper). _MV_DUCT_TYPE_FLEXIBLE: Final[int] = 1 @@ -560,6 +581,26 @@ _MV_DUCT_TYPE_RIGID: Final[int] = 2 # 5, 6 = through-wall (use no-duct IUF independent of duct type) _MEV_THROUGH_WALL_CONFIG_CODES: Final[frozenset[int]] = frozenset({5, 6}) +# SAP 10.2 Table 4g (PDF p.176) / §2.6.3 note 1 — default specific fan +# power (W per litre/sec) for an MEV system whose fan(s) are not in the +# PCDB. This is the RAW SFP: Table 4g note 3 requires it to be multiplied +# by the "default data" in-use factor (Table 329 system_type 10) before +# use as the SFPav in the (230a) formula (SFPav × 1.22 × V). +_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S: Final[float] = 0.8 + + +def _mev_default_data_iuf() -> float: + """SAP 10.2 Table 4g note 3 (PDF p.176) in-use factor for default MEV + data — PCDB Table 329 system_type 10 (IUF 2.5, identical across rigid/ + flexible/no-duct columns per Table 4g note 2 "applies to both rigid and + flexible ducting"). Falls back to 1.0 if the Table 329 record is + unavailable (ETL bootstrap), preserving the pre-fix raw-SFP behaviour + rather than zeroing fan electricity.""" + record = mv_in_use_factors_record(_MEV_DEFAULT_DATA_SYSTEM_TYPE) + if record is None or record.sfp_iuf_rigid_no_scheme is None: + return 1.0 + return record.sfp_iuf_rigid_no_scheme + def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float: """Compose the SAP 10.2 §5 Table 4f line (230a) MEV decentralised @@ -595,11 +636,36 @@ def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float: decentralised fixture; the rule above fits cert 000565 alone. """ pcdf_id = epc.mechanical_ventilation_index_number - if pcdf_id is None: - return 0.0 - record = decentralised_mev_record(pcdf_id) + record = decentralised_mev_record(pcdf_id) if pcdf_id is not None else None if record is None: - return 0.0 + # No PCDB Table 322 record (index absent, or lodged index not in + # the table). For a mechanical-EXTRACT system, SAP 10.2 §2.6.3 / + # Table 4g note 1 prescribes the default SFP (0.8 W/(l/s)) used + # directly as SFPav. Natural / balanced (MVHR / MV) systems + # contribute no (230a) decentralised-MEV fan electricity here — + # the gate mirrors `_has_balanced_mechanical_ventilation`'s + # MEV / PIV-from-outside vs balanced split. Closes the +2.2 SAP + # over-rate on the index-less MEV cohort (mostly gas houses), which + # previously billed zero fan electricity. + sv = epc.sap_ventilation + if ( + sv is None + or sv.mechanical_ventilation_kind + != MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE.name + ): + return 0.0 + # SAP 10.2 Table 4g note 3 (PDF p.176): the default SFP "[is] to be + # multiplied by the appropriate in-use factor for default data from + # the PCDB" (Table 329 system_type 10, IUF 2.5). Omitting it + # under-billed the index-less MEV fan electricity by 2.5x → +1.3 SAP + # over-rate on the no-PCDB MEV cohort (mostly gas houses). Distinct + # from the with-index path below, which applies the tested-product + # system_type-2 "no scheme" IUF (~1.45) per fan. + sfp_av = _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * _mev_default_data_iuf() + return mev_decentralised_kwh_per_yr( + sfp_av_w_per_l_per_s=sfp_av, + dwelling_volume_m3=dimensions_from_cert(epc).volume_m3, + ) iuf_record = mv_in_use_factors_record(_MEV_DECENTRALISED_SYSTEM_TYPE) if iuf_record is None: return 0.0 @@ -807,6 +873,14 @@ _SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = { 5: 0.10, 6: 0.10, 7: 0.15, + 8: 0.10, # Electric underfloor heating (direct-acting electric, e.g. + # SAP code 424): SAP 10.2 Table 11 (PDF p.188) row + # "Integrated storage/direct-acting electric systems" / + # "Other electric systems" = 0.10. First exercised when the + # description-lodged-secondary fix routed cat-8 mains (which + # previously short-circuited to 0) through the Table 11 + # lookup (cert 2051-9502, electric underfloor + assumed + # portable-electric secondary). 9: 0.10, # Warm-air systems (NOT heat pump): a gas/oil warm-air unit # is an "All gas, liquid and solid fuel systems" row (0.10), # and electric warm air is "Other electric systems" (also @@ -996,6 +1070,77 @@ def _heat_network_dlf(age_band: Optional[str]) -> float: raise UnmappedSapCode("heat_network_age_band", age_band) +# SAP 10.2 Table 4c(3) (PDF p.169) — "Factor for controls and charging +# method" for HEAT NETWORKS, by Table 4e control code. The factor multiplies +# the heat-network heat requirement on top of the Table 12c distribution loss +# factor: worksheet (307) space = (98c) × (302) × (305) × (306), and +# (310) DHW = (64) × (305a) × (306). "Flat rate charging" (note d: the +# household pays a fixed amount regardless of heat used) carries a demand +# penalty — there is no incentive to economise; "charging linked to use of +# heat" does not. The SPACE column adds a further +0.05 when there is also +# no thermostatic room-temperature control (2301/2302). +_HEAT_NETWORK_SPACE_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = { + # Flat rate charging, no thermostatic room control → 1.10 + 2301: 1.10, 2302: 1.10, + # Flat rate charging, with some thermostatic control → 1.05 + 2303: 1.05, 2304: 1.05, 2305: 1.05, 2307: 1.05, 2311: 1.05, 2313: 1.05, + # Charging linked to use of heat, thermostat but no TRVs → 1.05 + 2308: 1.05, 2309: 1.05, + # Charging linked to use of heat, with TRVs → 1.00 + 2306: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00, +} +_HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = { + # Flat rate charging → 1.05 (all controls) + 2301: 1.05, 2302: 1.05, 2303: 1.05, 2304: 1.05, + 2305: 1.05, 2307: 1.05, 2311: 1.05, 2313: 1.05, + # Charging linked to use of heat → 1.00 + 2306: 1.00, 2308: 1.00, 2309: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00, +} + + +def _heat_network_space_charging_factor(main: Optional[MainHeatingDetail]) -> float: + """SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305) — heat-network + space-heating factor for controls and charging method. Returns 1.0 when + the control is absent (no penalty); every Table 4e Group-3 code + (2301-2314) is covered, so an unmapped key only arises off the + heat-network path and is harmless at 1.0.""" + code = main.main_heating_control if main is not None else None + if not isinstance(code, int): + return 1.0 + return _HEAT_NETWORK_SPACE_CHARGING_FACTOR_BY_CODE.get(code, 1.0) + + +def _heat_network_dhw_charging_factor(main: Optional[MainHeatingDetail]) -> float: + """SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305a) — heat-network + water-heating factor for charging method (flat rate → 1.05, linked to + use → 1.0). Returns 1.0 when the control is absent.""" + code = main.main_heating_control if main is not None else None + if not isinstance(code, int): + return 1.0 + return _HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE.get(code, 1.0) + + +def _dwelling_age_band(epc: EpcPropertyData) -> Optional[str]: + """The dwelling's construction age band, read from the first building + part that lodges one. + + The GOV.UK API can lodge a junk empty leading building part (all + fields absent) ahead of the real Main Dwelling — reading + `sap_building_parts[0]` then yields None and silently drops the age + band (e.g. defaulting the heat-network DLF to the K-or-newer 1.50 + instead of the dwelling's true band). A no-op for normal certs, where + `[0]` is the Main part and already carries a valid band. + """ + return next( + ( + bp.construction_age_band + for bp in epc.sap_building_parts + if bp.construction_age_band + ), + None, + ) + + # SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in # distribution network". Its CO2 / PE factors vary by month per Table # 12d / 12e (= standard-electricity profile); worksheet (372)/(472) @@ -1282,22 +1427,32 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = { } -# SAP 10.2 Table 4e Group 1 (PDF p.171) — boiler control codes providing -# NO thermostatic control of room temperature, i.e. no room thermostat -# ("No time or thermostatic control of room temperature" 2101 / -# "Programmer, no room thermostat" 2102 — the two Group-1 rows carrying -# the "+0.6 °C / Table 4c(2)" annotation). Per RdSAP 10 §3 (PDF p.57) -# boiler interlock is "assumed present if there is a room thermostat and -# (for stored hot water systems heated by the boiler) a cylinder -# thermostat. Otherwise not interlocked." A gas/liquid-fuel boiler under -# one of these controls therefore has NO boiler interlock regardless of -# the cylinder thermostat, triggering the Table 4c(2) (PDF p.169) "No -# thermostatic control of room temperature – regular boiler" -5pp Space -# + DHW seasonal-efficiency adjustment. The combi rows of Table 4c(2) -# take Space -5 / DHW 0; the DHW leg is gated separately on a cylinder -# being present (regular boiler) at the call site. +# SAP 10.2 Table 4e Group 1 (PDF p.171) — boiler control codes with NO +# boiler interlock because they lack a room thermostat (or an equivalent +# device). SAP 10.2 §9.4.11 (PDF p.66) is explicit: "A boiler system with +# no room thermostat (or a device equivalent in this context, such as a +# flow switch or boiler energy manager), even if there is a cylinder +# thermostat, must be considered as having no interlock", and "TRVs alone +# (other than some communicating TRVs) do not perform the boiler interlock +# function". A *fixed bypass* likewise provides no interlock — it exists to +# keep water circulating when the TRVs close. The Group-1 rows without a +# room thermostat / flow switch / boiler energy manager are therefore: +# 2101 "No time or thermostatic control of room temperature" +# 2102 "Programmer, no room thermostat" +# 2107 "Programmer, TRVs and bypass" ← bypass ≠ interlock +# 2111 "TRVs and bypass" ← bypass ≠ interlock +# (2108 "Programmer, TRVs and flow switch" and 2109 "… boiler energy +# manager" carry an interlock-equivalent device, so they are INTERLOCKED +# and excluded; 2103-2106/2113 all carry a room thermostat.) Each of these +# triggers the Table 4c(2) (PDF p.169) "No thermostatic control of room +# temperature – regular boiler" -5pp Space + DHW seasonal-efficiency +# adjustment. The combi rows of Table 4c(2) take Space -5 / DHW 0; the DHW +# leg is gated separately on a cylinder being present at the call site. +# NB this is the interlock criterion only — the separate "+0.6 °C" Table 4e +# temperature adjustment applies to 2101/2102 alone (it lives in +# `_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE`, where 2107/2111 stay at 0.0). _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: Final[frozenset[int]] = frozenset( - {2101, 2102} + {2101, 2102, 2107, 2111} ) @@ -1608,7 +1763,17 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]: PE/cost lookups returned defaults instead of the gas-combi values. """ if epc.sap_heating.water_heating_fuel: - return epc.sap_heating.water_heating_fuel + fuel = epc.sap_heating.water_heating_fuel + # When DHW is on a heat network, the colliding community fuels + # 30/31/32 take their Table 12 community row rather than the + # same-numbered electricity code — see + # `_heat_network_community_fuel_code`. + community = _heat_network_community_fuel_code(fuel, _water_heating_main(epc)) + if community is not None: + return community + # Normalise colliding gov-API solid-fuel enum codes (see + # `_main_fuel_code`) before the shared price/CO2/PE lookups. + return canonical_fuel_code(fuel) return _main_fuel_code(_water_heating_main(epc)) @@ -1706,11 +1871,14 @@ def _main_heating_detail_efficiency( else: eff = seasonal_efficiency(main_code, main_category, main_fuel) if _is_heat_network_main(main): - primary_age = ( - epc.sap_building_parts[0].construction_age_band - if epc.sap_building_parts else None + primary_age = _dwelling_age_band(epc) + # Worksheet (307): heat required = demand × (305) × (306 DLF), so the + # delivered-per-fuel efficiency carries 1 / ((305) charging factor × + # DLF). The Table 4c(3) flat-rate charging penalty raises demand. + eff = 1.0 / ( + _heat_network_dlf(primary_age) + * _heat_network_space_charging_factor(main) ) - eff = 1.0 / _heat_network_dlf(primary_age) return eff @@ -1918,6 +2086,53 @@ _RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = { } +# Gov-API community fuel enum codes (waste 30 / biomass 31 / biogas 32) +# whose VALUE collides with a Table-32 electricity tariff code of the same +# number (30 standard / 31 7-hour-low / 32 7-hour-high). Per +# `epc_codes.csv` these are unambiguously "(community)" fuels, but the +# bare Table-32 codes 30/31/32 are ALSO used internally as grid +# electricity (e.g. `_STANDARD_ELECTRICITY_FUEL_CODE = 30` written by the +# no-water-heating immersion default), so the community meaning is only +# authoritative when the main is a heat network — see +# `_heat_network_community_fuel_code`. +_API_COMMUNITY_COLLISION_FUELS: Final[frozenset[int]] = frozenset({30, 31, 32}) + + +def _heat_network_community_fuel_code( + fuel: int, main: Optional[MainHeatingDetail] +) -> Optional[int]: + """Translate a gov-API community fuel enum to its SAP Table 12 + community fuel code WHEN the main is a heat network; else return None + so the caller keeps `fuel` unchanged. + + Community fuels 30 (waste) / 31 (biomass) / 32 (biogas) collide in + value with the Table-32 electricity codes 30/31/32. Without this + translation `is_electric_fuel_code` flags a community-scheme main as + electric and `_is_electric_main` routes its cost through the off-peak + electricity branch — bypassing the heat-network rate + (`_heat_network_factor_fuel_code`) entirely. Per RdSAP 10 §C / SAP + 10.2 Table 12 the community waste/biomass/biogas rows are codes + 42/43/44 (the same rows the backwards-compat enum codes 11/12/13 map + to). Cert 8536 (biomass community, SAP code 301) closed -17.2 → -6.5. + + Gating on `_is_heat_network_main` keeps the bare Table-32 code 30 the + cascade uses internally as grid electricity untouched on + non-community certs (e.g. cert 2211 whose whc=999 default writes + `water_heating_fuel=30`). + + Raises `UnmappedSapCode` when a heat-network main lodges a colliding + community fuel the translation table doesn't cover — surfacing the + gap loudly instead of silently mis-pricing it as grid electricity, + per the strict-raise principle ([[reference-unmapped-sap-code]]). + """ + if fuel not in _API_COMMUNITY_COLLISION_FUELS or not _is_heat_network_main(main): + return None + translated = API_FUEL_TO_TABLE_12.get(fuel) + if translated is None: + raise UnmappedSapCode("heat_network_community_fuel", fuel) + return translated + + def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: """Resolve `MainHeatingDetail.main_fuel_type` to a SAP fuel code. @@ -1935,10 +2150,58 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: return None fuel = main.main_fuel_type if isinstance(fuel, int): - return fuel + # Heat-network community fuels 30/31/32 collide with electricity + # Table-32 codes — translate to the community row before anything + # else so the main isn't mis-classified as electric. + community = _heat_network_community_fuel_code(fuel, main) + if community is not None: + return community + # Normalise the colliding gov-API solid-fuel enum codes (5 + # anthracite / 9 dual fuel / 33 coal) to their canonical Table + # 32/12 codes here — at the fuel-TYPE boundary — so the shared + # price/CO2/PE table lookups (which also receive electricity + # TARIFF codes 31/33 for the dual-rate split) never confuse a + # coal fuel-type 33 with the electricity-10h tariff code 33. + return canonical_fuel_code(fuel) raise MissingMainFuelType(fuel, main.sap_main_heating_code) +# Fuel codes the Table 12 / Table 32 factor & price lookups recognise as a +# DIRECT key (vs falling through to their mains-gas default). The union of +# every per-fuel column the cost/CO2/PE cascade consumes, plus the values +# `API_FUEL_TO_TABLE_12` translates to (all valid Table-12 codes). A code in +# this set — or translatable into it via the API enum map — is priced/ +# factored correctly; a code in NEITHER would silently default to mains gas. +_RECOGNISED_TABLE_12_FUEL_CODES: Final[frozenset[int]] = frozenset( + set(CO2_KG_PER_KWH) + | set(PRIMARY_ENERGY_FACTOR) + | set(UNIT_PRICE_P_PER_KWH) + | set(CO2_KG_PER_KWH_MONTHLY) + | set(PE_FACTOR_MONTHLY) + | set(API_FUEL_TO_TABLE_12.values()) +) + + +def _table_12_factor_fuel_code(fuel: int) -> int: + """`API_FUEL_TO_TABLE_12.get(fuel, fuel)` with a STRICT tail. + + Returns the Table-12 factor code for the cost / CO2 / PE lookups, with + behaviour identical to the prior silent passthrough for every recognised + input. The one difference: when the resolved code is neither translatable + via the API enum map NOR already a recognised Table-12/32 fuel code, it + raises `UnmappedSapCode` instead of passing the unknown code through to + the mains-gas default baked into `unit_price_p_per_kwh` / + `co2_factor_kg_per_kwh` / `primary_energy_factor` (the silent fuel- + collision class — cert 8536's community biomass mis-priced as grid + electricity was this pattern). Mirror of the strict-raise principle + ([[reference-unmapped-sap-code]]). + """ + code = API_FUEL_TO_TABLE_12.get(fuel, fuel) + if code in _RECOGNISED_TABLE_12_FUEL_CODES: + return code + raise UnmappedSapCode("table_12_factor_fuel", fuel) + + def _heat_network_factor_fuel_code( main: Optional[MainHeatingDetail], ) -> Optional[int]: @@ -1967,7 +2230,7 @@ def _heat_network_factor_fuel_code( fuel = _main_fuel_code(main) if fuel is None or not _is_heat_network_main(main): return fuel - return API_FUEL_TO_TABLE_12.get(fuel, fuel) + return _table_12_factor_fuel_code(fuel) def _fuel_cost_gbp_per_kwh( @@ -2130,6 +2393,15 @@ _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12: Final[dict[Tariff, tuple[int, int]]] = { } +# SAP Table 4a electric room-heater codes (panel/convector/radiant 691, +# fan 692, portable 693, water-/oil-filled 694, "electric heaters assumed" +# 699) — the same set RdSAP 10 §12 Rule 3 (PDF p.62) routes to the 10-hour +# tariff. They are direct-acting electric for the Table 12a Grid 1 SH split. +_ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( + {691, 692, 693, 694, 699} +) + + def _table_12a_system_for_main( main: Optional[MainHeatingDetail], ) -> Optional[Table12aSystem]: @@ -2145,7 +2417,8 @@ def _table_12a_system_for_main( Coverage as fixtures land: - ASHP / GSHP (codes 211-224, 521-524, PCDB index) — wired - - Storage heaters (401-409) — TODO + - Storage heaters (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20), + all others → OTHER_STORAGE_HEATERS (0.00) — wired - Underfloor heating (421-422) — TODO - Direct-acting electric (191) / CPSU (192) / electric storage boiler (193, 195) — TODO @@ -2157,15 +2430,26 @@ def _table_12a_system_for_main( main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ) - # Electric room heaters (RdSAP main_heating_category 10) are direct- - # acting electric → SAP 10.2 Table 12a Grid 1 (PDF p.191) "Other - # systems including direct-acting electric" row (7-hour high-rate - # fraction 1.00, 10-hour 0.50). Distinct from electric STORAGE - # heaters (category 7), which charge off-peak and correctly fall - # through to None here (→ 100% low rate). Gated on `_is_electric_main` - # so a non-electric room heater (gas / solid-fuel cat 10) is excluded; - # all callers already pre-gate on electric, this is belt-and-braces. - if main.main_heating_category == 10 and _is_electric_main(main): + # Electric room heaters are direct-acting electric → SAP 10.2 Table + # 12a Grid 1 (PDF p.191) "Other systems including direct-acting + # electric" row (7-hour high-rate fraction 1.00, 10-hour 0.50). + # Identified EITHER by RdSAP main_heating_category 10 OR by a Table 4a + # electric room-heater SAP code (691-694 panel/fan/portable/water- + # filled, 699 "electric heaters assumed" — the SAME set RdSAP 10 §12 + # Rule 3 (PDF p.62) routes to the 10-hour tariff). The "No system + # present: electric heaters assumed" lodging (code 699) carries + # main_heating_category 1, NOT 10, so the category-only gate missed it + # and it fell through to None → 100% off-peak LOW rate, billing + # direct-acting heaters as if they charged overnight like storage + # (cert 2958 +32.2 SAP, the worst over-rate in the sample). Distinct + # from electric STORAGE heaters (category 7), which DO charge off-peak + # and correctly fall through to None here (→ 100% low rate). Gated on + # `_is_electric_main` so a non-electric room heater (gas / solid-fuel + # cat 10) is excluded; all callers already pre-gate on electric. + if _is_electric_main(main) and ( + main.main_heating_category == 10 + or (code is not None and code in _ELECTRIC_ROOM_HEATER_SAP_CODES) + ): return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC # A PCDB Table 362 record IS a heat pump by definition (the Appendix-N # efficiency cascade keys off it), whether or not a Table-4a SAP code @@ -2188,6 +2472,16 @@ def _table_12a_system_for_main( 211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524 ): return Table12aSystem.ASHP_OTHER + # Electric STORAGE heaters (RdSAP main_heating_category 7) — SAP 10.2 + # Table 12a Grid 1 (PDF p.191). Code 408 is an "Integrated (storage + + # direct-acting) system" → 0.20 SH high-rate fraction at 7-hour; every + # other storage code is "Other storage heaters" → 0.00 (charged wholly + # off-peak, the same 100%-low-rate the None fallback already gave). + # Gated on `_is_electric_main` belt-and-braces (all callers pre-gate). + if main.main_heating_category == 7 and _is_electric_main(main): + if code == 408: + return Table12aSystem.INTEGRATED_STORAGE_DIRECT + return Table12aSystem.OTHER_STORAGE_HEATERS return None @@ -2260,6 +2554,9 @@ def _hot_water_fuel_cost_gbp_per_kwh( *, water_heating_code: Optional[int] = None, inherit_main_for_community_heating: bool = False, + cylinder_volume_l: Optional[float] = None, + occupancy_n: Optional[float] = None, + immersion_single: Optional[bool] = None, ) -> float: """Hot water bills at the *water-heating* fuel's rate. When the water-heating fuel is electric AND tariff is off-peak, bill at the @@ -2273,10 +2570,18 @@ def _hot_water_fuel_cost_gbp_per_kwh( ∈ {901, 902, 914}) and that main is a PCDB Table 362 heat pump, the HW bills per SAP 10.2 Table 12a Grid 1 WH column (PDF p.191) — the ASHP/GSHP-from-database row carries a 0.70 high-rate fraction at - 7-hour and 10-hour, NOT 100% off-peak low rate. Electric IMMERSION - (WHC 903) is a different Table 12a row (off-peak immersion 0.17 / - Table 13) and stays on the 100%-low-rate fallback until that slice - lands. + 7-hour and 10-hour, NOT 100% off-peak low rate. + + Electric IMMERSION exception (WHC 903): Table 12a's "Immersion water + heater" row (PDF p.191) routes the WH column to Table 13 (PDF p.197). + The Table 13 high-rate fraction — a function of cylinder volume, + assumed occupancy and single-/dual-immersion — gives the proportion + billed at the high rate, the remainder at the low rate. Without it + the immersion HW billed 100% at the off-peak low rate, under-costing + the dwelling and over-rating it (median +0.98 SAP across the off-peak + WHC-903 API cohort). Needs `cylinder_volume_l` + `occupancy_n` + + `immersion_single`; absent any of them (no cylinder / volume not + resolvable) it falls back to the 100%-low-rate scalar. `inherit_main_for_community_heating`: per S0380.173, when WHC ∈ {901, 902, 914} AND main is a heat network, ignore the cert- @@ -2301,6 +2606,21 @@ def _hot_water_fuel_cost_gbp_per_kwh( ) blended = high_frac * high_rate + (1.0 - high_frac) * low_rate return blended * _PENCE_TO_GBP + if ( + water_heating_code == _WHC_ELECTRIC_IMMERSION + and cylinder_volume_l is not None + and occupancy_n is not None + and immersion_single is not None + ): + high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) + high_frac = electric_dhw_high_rate_fraction( + cylinder_volume_l=cylinder_volume_l, + occupancy_n=occupancy_n, + single_immersion=immersion_single, + tariff=tariff, + ) + blended = high_frac * high_rate + (1.0 - high_frac) * low_rate + return blended * _PENCE_TO_GBP return _off_peak_low_rate_gbp_per_kwh(tariff) if water_heating_fuel is not None: return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP @@ -2308,7 +2628,9 @@ def _hot_water_fuel_cost_gbp_per_kwh( def _secondary_fraction( - main: Optional[MainHeatingDetail], secondary_heating_type: object + main: Optional[MainHeatingDetail], + secondary_heating_type: object, + secondary_lodged: bool = False, ) -> float: """SAP 10.2 Table 11 lookup by main heating category, applied only when (a) the cert has a secondary system lodged OR (b) the main @@ -2316,6 +2638,17 @@ def _secondary_fraction( heaters). Returns 0.0 when neither applies — the most common case for gas/oil main systems whose cert doesn't lodge a secondary. + `secondary_lodged` covers the gov-API path: the register publishes + the secondary as a DESCRIPTION (`secondary_heating.description`, e.g. + "Portable electric heaters (assumed)") even when the integer + `secondary_heating_type` code is absent. The description is + authoritative — a lodged secondary description means RdSAP assessed a + secondary (per §A.2.2 the assumed system is portable electric heaters) + and its Table 11 fraction must be costed. Without this a gas/oil + boiler main with an assumed portable-electric secondary dropped the + secondary entirely (sec_kWh=0), under-costing the dwelling and + over-rating its SAP by a clean systematic +2.7 (median). + `main_heating_fraction` on the cert is NOT consulted here: empirical probe shows it tracks main-system-1 vs main-system-2 allocation in multi-main configurations (99% of corpus has =1, meaning "single @@ -2337,7 +2670,7 @@ def _secondary_fraction( if main is None: return 0.0 code = main.sap_main_heating_code - has_lodged_secondary = secondary_heating_type is not None + has_lodged_secondary = secondary_heating_type is not None or secondary_lodged force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES if not has_lodged_secondary and not force: return 0.0 @@ -2349,6 +2682,19 @@ def _secondary_fraction( return _secondary_heating_fraction_for_category(main.main_heating_category) +def _has_lodged_secondary_description(epc: EpcPropertyData) -> bool: + """True when the cert lodges a secondary-heating DESCRIPTION (the + gov-API path surfaces the secondary as `secondary_heating.description`, + e.g. "Portable electric heaters (assumed)", even when the integer + `secondary_heating_type` code is None). RdSAP treats a lodged + secondary as costed (§A.2.2), so this gates the Table 11 fraction.""" + sec = epc.secondary_heating + if sec is None: + return False + desc = getattr(sec, "description", None) + return desc is not None and desc not in ("None", "") + + def _secondary_heating_fraction_for_category( main_heating_category: Optional[int], ) -> float: @@ -2902,6 +3248,15 @@ def _pumps_fans_fuel_cost_gbp_per_kwh( # the SAME cascade the main heating uses, including the main_heating_ # category fallback (e.g. heat pumps return 2.30 via category 4). _WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914}) +# Hot-water-only heat-network codes — SAP 10.2 Table 4a HW section (PDF +# p.167): 950 boilers / 951 CHP / 952 heat pump. The DHW is supplied by a +# heat network independent of the space-heating system, so RdSAP 10 §10 +# (spec p.36 "water heating only ... for plant efficiency, distribution loss +# and pumping energy — see Table 12c") requires the Table 12c distribution +# loss factor applied on top of the Table 4a plant efficiency. Distinct from +# the inherit-from-main codes: the DLF fires on the WHC regardless of whether +# the *main* is a heat network (e.g. cert 9093, whc 950 + warm-air main 502). +_WATER_HEAT_NETWORK_ONLY_CODES: Final[frozenset[int]] = frozenset({950, 951, 952}) # Water-heating code 901 = "From main heating system" — used by the # SAP 10.2 Appendix D §D2.1 (2) Equation D1 gate, which only applies # when "the boiler provides both space and water heating". @@ -3476,7 +3831,7 @@ def _hot_water_co2_factor_kg_per_kwh( return _DEFAULT_CO2_KG_PER_KWH table_12_code = ( fuel if fuel in CO2_KG_PER_KWH - else API_FUEL_TO_TABLE_12.get(fuel, fuel) + else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: return co2_factor_kg_per_kwh(table_12_code) @@ -3540,7 +3895,7 @@ def _hot_water_primary_factor( return _DEFAULT_PEF table_12_code = ( fuel if fuel in PRIMARY_ENERGY_FACTOR - else API_FUEL_TO_TABLE_12.get(fuel, fuel) + else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: return primary_energy_factor(table_12_code) @@ -3574,7 +3929,7 @@ def _secondary_fuel_code(epc: EpcPropertyData) -> int: return _STANDARD_ELECTRICITY_FUEL_CODE if code in CO2_KG_PER_KWH: return code - return API_FUEL_TO_TABLE_12.get(code, code) + return _table_12_factor_fuel_code(code) def _secondary_heating_co2_factor_kg_per_kwh( @@ -3654,7 +4009,14 @@ def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: if not parts: return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K main: SapBuildingPart = parts[0] - if _int_or_none(main.wall_construction) in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: + wall_code: Optional[int] = _int_or_none(main.wall_construction) + if wall_code in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: + return _TMP_LOW_KJ_PER_M2_K + # Wall code 8 is a park home (low-mass) ONLY when the dwelling really is + # one; on the gov-API path code 8 is system built (masonry). Disambiguate + # by property_type so an API system build is not mis-rated as low-mass. + is_park_home: bool = (epc.property_type or "").strip().lower() == "park home" + if wall_code == _TMP_PARK_HOME_OR_SYSTEM_BUILT_WALL_CODE and is_park_home: return _TMP_LOW_KJ_PER_M2_K if _int_or_none(main.wall_insulation_type) in _TMP_INTERNAL_WALL_INSULATION_CODES: return _TMP_LOW_KJ_PER_M2_K @@ -3794,9 +4156,60 @@ def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmissio insulated_door_count=epc.insulated_door_count, insulated_door_u_value=epc.insulated_door_u_value, exposure=exposure, + corridor_door_count=_corridor_door_count(epc), ) +def _has_sheltered_corridor_wall(epc: EpcPropertyData) -> bool: + """Whether the dwelling is accessed via an unheated corridor/stairwell. + + A SHELTERED alternative wall (`is_sheltered`, the RdSAP §5.9 + wall-to-unheated-corridor surface) is the evidence that the dwelling's + entrance faces an unheated corridor or stairwell. False for houses and + exposed-gable flats (no sheltered alt wall lodged). + """ + return any( + (bp.sap_alternative_wall_1 is not None and bp.sap_alternative_wall_1.is_sheltered) + or (bp.sap_alternative_wall_2 is not None and bp.sap_alternative_wall_2.is_sheltered) + for bp in (epc.sap_building_parts or []) + ) + + +def _corridor_door_count(epc: EpcPropertyData) -> int: + """RdSAP §3.7 + Table 26 — number of doors opening to an unheated + corridor/stairwell (each billed at U=1.4 on the sheltered wall). + + A sheltered alternative wall (`_has_sheltered_corridor_wall`) is the + evidence that the dwelling is accessed via an unheated corridor, so its + entrance door opens to that corridor. RdSAP convention assumes one such + access door when the sheltered wall is present and the cert lodges at + least one door; the remainder are external. Returns 0 when no sheltered + alt wall is lodged (houses, exposed-gable flats) so the door channel is + unchanged for every non-corridor dwelling. + """ + return 1 if _has_sheltered_corridor_wall(epc) and epc.door_count > 0 else 0 + + +def _has_draught_lobby(epc: EpcPropertyData, sv: Optional[SapVentilation]) -> bool: + """RdSAP 10 §2 (13) — presence of a draught lobby. + + Spec (RdSAP 10 Specification 10-06-2025, p.30, "Draught lobby"): + "add infiltration 0.05 if draught lobby is not present, or use 0.0 if + present. ... Flat or maisonette: Assume draught lobby if entrance door + is facing corridor (heated or unheated) or stairwell." + + A sheltered corridor wall (`_has_sheltered_corridor_wall`) is exactly + that evidence: the flat's entrance faces an unheated corridor/stairwell, + so a draught lobby is assumed present regardless of the lodged value. + Otherwise fall back to the lodged value — which, when undetermined, is + the RdSAP "assume no draught lobby if cannot be determined" default for + houses. + """ + if _has_sheltered_corridor_wall(epc): + return True + return bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False + + def _rooflight_total_area_m2_from_cert(epc: EpcPropertyData) -> float: """Σ area of `epc.sap_roof_windows` for §5 daylight-factor L2a + §6 horizontal solar gain. Returns 0.0 when none are lodged. @@ -4295,7 +4708,9 @@ def energy_requirements_section_from_cert( main_category = main.main_heating_category if main is not None else None main_fuel = _main_fuel_code(main) secondary_fraction_value = _secondary_fraction( - main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None + main, + epc.sap_heating.secondary_heating_type if epc.sap_heating else None, + secondary_lodged=_has_lodged_secondary_description(epc), ) # When no secondary system is lodged the worksheet displays (208) = 0; # the per-system fuel formula already collapses to 0 via fraction_201 = 0 @@ -4525,10 +4940,7 @@ def ventilation_from_cert( # lodged count is below the age-band minimum. The Elmhurst Summary # renders "0" as the form for unknown; the worksheet applies the # default via `max(lodged, table_5_default)`. - age_band = ( - epc.sap_building_parts[0].construction_age_band - if epc.sap_building_parts else "" - ) + age_band = _dwelling_age_band(epc) or "" is_park_home = (epc.property_type or "").strip().lower() == "park home" table_5_fan_default = _rdsap_extract_fans_default( age_band, epc.habitable_rooms_count, is_park_home=is_park_home, @@ -4591,7 +5003,7 @@ def ventilation_from_cert( flueless_gas_fires=vc.flueless_gas_fires, has_suspended_timber_floor=eff_has_susp, suspended_timber_floor_sealed=eff_sealed, - has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False, + has_draught_lobby=_has_draught_lobby(epc, sv), window_pct_draught_proofed=float(epc.percent_draughtproofed or 0), sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2, air_permeability_ap4=ap4, @@ -4815,11 +5227,75 @@ _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset( # code 3 → Medium (160 litres) (certs 0350, 0380, 2225, 2636, # 3800, 9285) # code 4 → Large (210 litres) (cert 9418) -# Codes 5 / 6 (Inaccessible / Exact) not yet observed. +# code 5 → Inaccessible (context-dependent — see Table 28 below, +# resolved by `_cylinder_inaccessible_volume_l`) +# code 6 → Exact (the lodged measured volume in litres, +# `cylinder_volume_measured_l`; 20 API certs) _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { 2: 110.0, 3: 160.0, 4: 210.0 } +# RdSAP 10 §10.5 Table 28 (PDF p.55) — the "Inaccessible" descriptor's +# size-to-use depends on the heating context, and the "Exact" descriptor +# lodges its measured volume separately. +_CYLINDER_SIZE_INACCESSIBLE: Final[int] = 5 +_CYLINDER_SIZE_EXACT: Final[int] = 6 +_CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L: Final[float] = 210.0 +_CYLINDER_INACCESSIBLE_SOLID_FUEL_L: Final[float] = 160.0 +_CYLINDER_INACCESSIBLE_DEFAULT_L: Final[float] = 110.0 +# RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the size +# of a hot-water cylinder is taken as according to Table 28." For a cylinder +# present but with no size descriptor lodged (size code 0 / absent), the +# baseline Table 28 default is the "Normal" row (110 L) — the same value +# §10.7 instantiates as the first-row default. The context-dependent +# Inaccessible 210/160 values are NOT applied here: they are tied to the +# explicit "Inaccessible" descriptor (code 5) the assessor lodges +# deliberately, not to a merely-unpopulated size field. +_CYLINDER_SIZE_NOT_DETERMINED_L: Final[float] = 110.0 + + +def _cylinder_inaccessible_volume_l(epc: EpcPropertyData) -> float: + """RdSAP 10 §10.5 Table 28 (PDF p.55) — size to use for an + "Inaccessible" cylinder (code 5): 210 L for off-peak electric DUAL + immersion, 160 L from a solid-fuel boiler, otherwise 110 L.""" + if _immersion_is_single(epc) is False and _is_off_peak_meter( + epc.sap_energy_source.meter_type, fuel_is_electric=True + ): + return _CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L + main = _first_main_heating(epc) + if main is not None and main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES: + return _CYLINDER_INACCESSIBLE_SOLID_FUEL_L + return _CYLINDER_INACCESSIBLE_DEFAULT_L + + +def _cylinder_volume_l_from_code(epc: EpcPropertyData) -> Optional[float]: + """RdSAP 10 §10.5 Table 28 — resolve the HW cylinder volume (litres) + from the lodged `cylinder_size` descriptor code. Codes 2/3/4 → + 110/160/210; code 5 (Inaccessible) → context-dependent; code 6 (Exact) + → the lodged measured volume. Returns None when no size code is lodged + (or code 6 lodges no measured volume). Does NOT gate on + `has_hot_water_cylinder` — callers apply that guard.""" + size_code = _int_or_none(epc.sap_heating.cylinder_size) + if size_code is None: + return None + if size_code == _CYLINDER_SIZE_EXACT: + measured = _int_or_none(epc.sap_heating.cylinder_volume_measured_l) + return float(measured) if measured is not None else None + if size_code == _CYLINDER_SIZE_INACCESSIBLE: + return _cylinder_inaccessible_volume_l(epc) + return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) + +# RdSAP `immersion_heating_type` lodgement codes. Code 1 = DUAL immersion, +# code 2 = SINGLE. Confirmed against RdSAP 10 §10.5 (PDF p.54 — an +# immersion is "assumed dual" on a dual/off-peak meter) cross-checked +# with the API cohort: code 1 sits 3.6:1 on dual meters (40 vs 11 single) +# while code 2 sits on single meters (22 single vs 16 dual). This INVERTS +# the unverified "1=single, 2=dual" annotation in an earlier handover — +# the dual code (1) carries Table 13's small high-rate fraction, matching +# the cohort's over-rating direction; treating code 1 as single overshot. +_IMMERSION_TYPE_DUAL: Final[int] = 1 +_IMMERSION_TYPE_SINGLE: Final[int] = 2 + # RdSAP 10 §10.5 code 7-11: cylinder insulation type. Empirical mapping # from the ASHP cohort (all 7 certs lodge code 1, worksheet shows # "Foam" → factory-applied per SAP 10.2 Table 2 Note 2). @@ -4904,10 +5380,7 @@ def _apply_rdsap_no_water_heating_system_default( """ if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM: return epc - age_band = ( - epc.sap_building_parts[0].construction_age_band - if epc.sap_building_parts else None - ) + age_band = _dwelling_age_band(epc) band = (age_band or "")[:1].upper() default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) if default is None: @@ -4927,6 +5400,56 @@ def _apply_rdsap_no_water_heating_system_default( return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating) +# SAP 10.2 p.24 "Heat networks" (c): the default storage loss for heat- +# network DHW with no PCDB HIU and no lodged cylinder is "equivalent to a +# cylinder of 110 litres and a factory insulation thickness of 50 mm". +_HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM: Final[int] = 50 + + +def _apply_heat_network_hiu_default_store( + epc: EpcPropertyData, +) -> EpcPropertyData: + """SAP 10.2 p.24 "Heat networks" (c) — when domestic hot water is + provided by a heat network and neither a PCDB Heat Interface Unit nor + a lodged hot-water cylinder applies: + + "a measured loss of 1.72 kWh/day should be used, corrected using + Table 2b. This is equivalent to a cylinder of 110 litres and a + factory insulation thickness of 50 mm." + + RdSAP 10 Table 29 (PDF p.56) assumes a cylinder thermostat is present + when DHW is from a heat network → Table 2b base temperature factor + 0.60 (no ×1.3 absent-thermostat penalty). + + Mirrors `_apply_rdsap_no_water_heating_system_default`: rebinds the + 110 L / 50 mm-factory store onto `epc` (keeping the community water- + heating code + fuel) so every downstream §4 gate — storage loss (56), + primary loss (59) and combi-loss suppression — sees the spec default. + A heat network is NOT a combi boiler, so the injected cylinder zeroes + the Table 3a keep-hot (61) loss via the `has_hot_water_cylinder` gate + in `_water_heating_worksheet_and_gains`; without this the cascade + wrongly billed a 600 kWh/yr keep-hot loss on heat-network DHW. + + No-op (returns `epc` unchanged) unless the DHW main is a heat network, + no cylinder is lodged, and the network is not in the PCDB (an indexed + network uses the PCDB HIU loss per branch (a)).""" + if epc.has_hot_water_cylinder: + return epc + dhw_main = _water_heating_main(epc) + if not _is_heat_network_main(dhw_main): + return epc + if dhw_main is not None and dhw_main.main_heating_index_number is not None: + return epc + sap_heating = replace( + epc.sap_heating, + cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L, + cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY, + cylinder_insulation_thickness_mm=_HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM, + cylinder_thermostat="Y", + ) + return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating) + + # SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent # boilers (151, 153, 155, 159), open-fire + back boiler (156), closed # room heater + back boiler (158), range cooker boiler (160, 161). @@ -4938,6 +5461,28 @@ _TABLE_4A_SOLID_FUEL_BOILER_CODES: Final[frozenset[int]] = frozenset( ) +# SAP 10.2 Table 4c(2) boiler controls (21xx) that carry NO programmer / +# time switch: 2101 "No time or thermostatic control", 2103 "Room +# thermostat only", 2111 "TRVs and bypass", 2113 "Room thermostat and +# TRVs". Every other 21xx control includes a programmer (2102/2104/2105/ +# 2106 …) or time-and-temperature zone control (2110/2112). Used by the +# RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed" rule below. +_BOILER_CONTROLS_WITHOUT_PROGRAMMER: Final[frozenset[int]] = frozenset( + {2101, 2103, 2111, 2113} +) + +# SAP 10.2 Table 4b (PDF p.168) gas/LPG/biogas boilers lodged pre-1998 +# (fan-assisted flue 110-114 + balanced/open flue 115-119) plus the +# pre-1998 liquid-fuel boilers (124 pre-1985, 125 1985-1997, 128 combi +# pre-1998). Gas/LPG 101-109 and oil 126/127/129/130 are 1998-or-later. +# Used by the RdSAP 10 §10.5 separate-timing rule: a 1998-or-later boiler +# is always separately timed; a pre-1998 boiler only when a programmer +# is present. +_PRE_1998_BOILER_SAP_CODES: Final[frozenset[int]] = frozenset( + set(range(110, 120)) | {124, 125, 128} +) + + def _separately_timed_dhw( epc: EpcPropertyData, main: Optional[MainHeatingDetail], ) -> bool: @@ -5020,7 +5565,24 @@ def _separately_timed_dhw( # DHW is not separately timed. if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False - return bool(epc.has_hot_water_cylinder) + if not epc.has_hot_water_cylinder: + return False + # RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed": + # No programmer, pre-1998 boiler → No + # Programmer, pre-1998 boiler → Yes + # Post-1998 boiler → Yes + # i.e. DHW is NOT separately timed only when a pre-1998 boiler is + # paired with a no-programmer control (Table 4c(2): room-thermostat- + # only / TRV-only). Every other boiler+cylinder cert keeps the + # separately-timed default — so the change is confined to old, low- + # control stock (this lpg-boiler "before" worksheet: code 115 + 2113 + # → (53) temperature factor 0.78, not 0.702). + if ( + main.main_heating_control in _BOILER_CONTROLS_WITHOUT_PROGRAMMER + and main.sap_main_heating_code in _PRE_1998_BOILER_SAP_CODES + ): + return False + return True def _table_2b_note_b_multiplier_applies( @@ -5120,10 +5682,7 @@ def _heat_pump_cylinder_meets_pcdb_criteria( for the cohort this criterion is always "unknown" → returns False. """ sh = epc.sap_heating - size_code = _int_or_none(sh.cylinder_size) - if size_code is None: - return False - cert_volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) + cert_volume_l = _cylinder_volume_l_from_code(epc) if cert_volume_l is None: return False # Volume criterion. @@ -5631,15 +6190,38 @@ def _orientation_from_summary_string(raw: Optional[str]) -> Optional[Orientation def _hot_water_cylinder_volume_l(epc: EpcPropertyData) -> Optional[float]: """Resolve the HW cylinder volume (litres) from the cert's - `cylinder_size` code via RdSAP 10 §10.5 Table 28. Returns None - when no cylinder is lodged or the size code falls outside the - cohort-observed range (codes 2-4 → Normal / Medium / Large).""" + `cylinder_size` code via RdSAP 10 §10.5 Table 28 — Normal/Medium/Large + (codes 2/3/4), Inaccessible (5, context-dependent) and Exact (6, lodged + measured volume). Returns None only when no cylinder is lodged. + + RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the + size of a hot-water cylinder is taken as according to Table 28." When a + cylinder IS present but no size descriptor resolves (size code 0 / + absent, or Exact with no measured volume), fall back to the Table 28 + baseline "Normal" default (110 L). Without this the cylinder resolved + to None, silently dropping BOTH its storage loss and the Table 13 + high-rate fraction, over-rating unsized-cylinder electric dwellings.""" if not epc.has_hot_water_cylinder: return None - size_code = _int_or_none(epc.sap_heating.cylinder_size) - if size_code is None: - return None - return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) + volume_l = _cylinder_volume_l_from_code(epc) + if volume_l is not None: + return volume_l + return _CYLINDER_SIZE_NOT_DETERMINED_L + + +def _immersion_is_single(epc: EpcPropertyData) -> Optional[bool]: + """True for a single immersion, False for a dual immersion, None when + the cert lodges no recognised `immersion_heating_type`. Maps the + RdSAP code (1 = dual, 2 = single — see `_IMMERSION_TYPE_DUAL`). + None makes the Table 13 high-rate-fraction caller fall back to the + 100%-low-rate scalar rather than guess the immersion configuration. + """ + code = _int_or_none(epc.sap_heating.immersion_heating_type) + if code == _IMMERSION_TYPE_DUAL: + return False + if code == _IMMERSION_TYPE_SINGLE: + return True + return None def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool: @@ -5826,6 +6408,12 @@ def _primary_loss_override( pipework_insulation_fraction=pipework_p, has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y", separately_timed_dhw=_separately_timed_dhw(epc, main), + # SAP 10.2 Table 3 (PDF p.160): "For heat networks apply the + # formula above with p = 1.0 and h = 3 for all months." The h=3 + # row applies regardless of the thermostat / separate-timing + # lodgement (and so is robust to the community-fuel-as-electric + # collision that would otherwise route DHW to the h=5 row). + heat_network=_is_heat_network_main(main), ) # SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW # comes from an electric immersion, not from the boiler — the boiler @@ -5840,6 +6428,42 @@ def _primary_loss_override( return base +def _cylinder_thermostat_present( + epc: EpcPropertyData, main: Optional[MainHeatingDetail], +) -> bool: + """Whether a cylinder thermostat is present for the Table 2b temperature + factor (absent → ×1.3 penalty on the storage loss). + + A lodged "Y" wins. Otherwise SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A + cylinder thermostat should be assumed to be present when the domestic + hot water is obtained from a heat network, an immersion heater, a + thermal store, a combi boiler or a CPSU." RdSAP 10 Table 29 (PDF p.56) + points the no-access default at this rule. So a cylinder heated by an + immersion (WHC 903), a direct-acting electric boiler (SAP code 191 — + electric-resistance, immersion-equivalent), or a heat network gets the + base Table 2b factor (no absent-thermostat ×1.3). + + Cert 2474 (Summary path: WHC 901, main SAP 191, electric, no lodged + cylinder thermostat) is the case: the dr87 worksheet lodges (53) + temperature factor 0.6000 (thermostat present), and the "add cylinder + thermostat" recommendation reads "SAP increase too small" because it + is already assumed present. Without this the cascade applied ×1.3 and + over-stated storage loss by ~378 kWh/yr (SAP −1.86).""" + if epc.sap_heating.cylinder_thermostat == "Y": + return True + dhw_main = _water_heating_main(epc) + if _is_heat_network_main(dhw_main): + return True + if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: + return True + if ( + dhw_main is not None + and dhw_main.sap_main_heating_code == _DIRECT_ACTING_ELECTRIC_BOILER_CODE + ): + return True + return False + + def _cylinder_storage_loss_override( epc: EpcPropertyData, main: Optional[MainHeatingDetail], @@ -5865,10 +6489,7 @@ def _cylinder_storage_loss_override( if not epc.has_hot_water_cylinder: return None sh = epc.sap_heating - size_code = _int_or_none(sh.cylinder_size) - if size_code is None: - return None - volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) + volume_l = _cylinder_volume_l_from_code(epc) if volume_l is None: return None insulation_label = _cylinder_storage_loss_insulation_label( @@ -5883,7 +6504,7 @@ def _cylinder_storage_loss_override( volume_l=volume_l, insulation_type=insulation_label, thickness_mm=float(thickness_mm), - has_cylinder_thermostat=sh.cylinder_thermostat == "Y", + has_cylinder_thermostat=_cylinder_thermostat_present(epc, main), # SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the # ×0.9 multiplier to boiler / warm-air / heat-pump systems — # community heating excluded. Gate via the dedicated helper so @@ -6323,6 +6944,11 @@ def cert_to_inputs( # means every downstream helper sees the spec default; the demand # cascade reuses this entry point so it is covered too. epc = _apply_rdsap_no_water_heating_system_default(epc) + # SAP 10.2 p.24 "Heat networks" (c) — heat-network DHW with no PCDB + # HIU and no lodged cylinder uses a default 110 L / 50 mm-factory + # store (storage + primary loss, no combi keep-hot). Rebinds before + # the §4 cascade so every loss gate sees the spec default. + epc = _apply_heat_network_hiu_default_store(epc) dim = dimensions_from_cert(epc) # SAP §3 heat transmission + §2 ventilation cascades — see the # respective `_from_cert` helpers for cert→inputs mapping rules. @@ -6376,9 +7002,7 @@ def cert_to_inputs( # 10-hour) instead of `ALL_OTHER_USES` (0.80) — see # `_pumps_fans_fuel_cost_gbp_per_kwh`. Zero when no MEV is lodged. mev_kwh_for_cost_split = _mev_decentralised_kwh_per_yr_from_cert(epc) - primary_age = ( - epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None - ) + primary_age = _dwelling_age_band(epc) # SAP 10.2 Appendix D2.1: if the cert lodges a PCDB index number that # resolves to a Table 105 (gas/oil boilers) record, the PCDB winter @@ -6556,15 +7180,30 @@ def cert_to_inputs( # HW from main on a heat-network cert: the DHW also incurs the # network's distribution losses. Same 1/DLF override as for # space heating so the delivered HW kWh reflects q_useful × DLF - # = q_generated, matching the per-kWh-generated unit price. - water_eff = 1.0 / _heat_network_dlf(primary_age) + # = q_generated, matching the per-kWh-generated unit price. Worksheet + # (310): heat required = (64) × (305a) × (306 DLF), so the DHW + # efficiency also carries 1 / Table 4c(3) (305a) charging factor. + water_eff = 1.0 / ( + _heat_network_dlf(primary_age) + * _heat_network_dhw_charging_factor(main) + ) + elif epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES: + # HW-only heat network (whc 950/951/952): the Table 4a plant + # efficiency is already in `water_eff`; apply the Table 12c + # distribution loss on top per RdSAP 10 §10 (spec p.36 "water + # heating only ... distribution loss"). q_generated = q_useful × + # DLF, so delivered-per-fuel efficiency = plant_eff / DLF. Fires + # on the WHC alone — the HW network is independent of the main. + water_eff = water_eff / _heat_network_dlf(primary_age) is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES # §9a Table 11 secondary fraction — pulled forward of §4 so the # post-§8 Equation D1 cascade can derive Q_space = (98c)m × (204) # without recomputing it. Pure function over the cert; same value # later when §9a `space_heating_fuel_monthly_kwh` runs. secondary_fraction_value = _secondary_fraction( - main, epc.sap_heating.secondary_heating_type + main, + epc.sap_heating.secondary_heating_type, + secondary_lodged=_has_lodged_secondary_description(epc), ) # SAP10.2 §4 — compute the worksheet (45..65) values now (they only # depend on the cert dwelling shape, not on water_efficiency). The @@ -6917,15 +7556,12 @@ def cert_to_inputs( secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh, hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv, main_fuel_code_table_12=( - API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel) + _table_12_factor_fuel_code(main_fuel) if main_fuel is not None else None ), secondary_fuel_code_table_12=_secondary_fuel_code(epc), water_heating_fuel_code_table_12=( - API_FUEL_TO_TABLE_12.get( - epc.sap_heating.water_heating_fuel, - epc.sap_heating.water_heating_fuel, - ) + _table_12_factor_fuel_code(epc.sap_heating.water_heating_fuel) if epc.sap_heating.water_heating_fuel is not None else None ), # SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an @@ -6996,6 +7632,22 @@ def cert_to_inputs( epv_exported_monthly_kwh=adjusted_export_monthly_kwh, ) + # SAP 10.2 Appendix M1 (PDF p.94): "EPV,ex,m = 0 if the PV system is not + # connected to an export-capable meter." A non-export-capable dwelling + # earns no export payment — only the onsite β consumption (EPV,dw) + # offsets demand. Zero the exported stream so the §10a cost, CO2 and PE + # export credits all drop out; the dwelling (onsite) portion and any + # diverter HW reduction above are unchanged. (Without this the cascade + # credited the full export — e.g. cert at 7 Wybourn Terrace S2 5BJ + # over-rated +19 SAP: PE/CO2 matched the lodged figures but the export + # cost credit alone pulled the rating from ~73 to 92.) + if not epc.sap_energy_source.is_dwelling_export_capable: + pv_split = PhotovoltaicSplit( + beta_monthly=pv_split.beta_monthly, + epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh, + epv_exported_monthly_kwh=(0.0,) * 12, + ) + # SAP 10.2 §12.4.4 overrides — when summer immersion applies (back- # boiler combo + cylinder + WHC from main heating), the HW cost / # CO2 / PE factors are kWh-weighted blends of the winter boiler fuel @@ -7025,6 +7677,9 @@ def cert_to_inputs( prices, water_heating_code=epc.sap_heating.water_heating_code, inherit_main_for_community_heating=_community_hw_inherit, + cylinder_volume_l=_hot_water_cylinder_volume_l(epc), + occupancy_n=wh_result.occupancy if wh_result is not None else None, + immersion_single=_immersion_is_single(epc), ) hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), diff --git a/domain/sap10_calculator/tables/table_12.py b/domain/sap10_calculator/tables/table_12.py index 2c884128..7ea27585 100644 --- a/domain/sap10_calculator/tables/table_12.py +++ b/domain/sap10_calculator/tables/table_12.py @@ -214,7 +214,7 @@ API_FUEL_TO_TABLE_12: Final[dict[int, int]] = { 0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10, 10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9, 18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41, - 26: 1, 27: 2, 28: 4, 29: 30, + 26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11, } diff --git a/domain/sap10_calculator/tables/table_13.py b/domain/sap10_calculator/tables/table_13.py new file mode 100644 index 00000000..c1dabe25 --- /dev/null +++ b/domain/sap10_calculator/tables/table_13.py @@ -0,0 +1,88 @@ +"""SAP 10.2 Table 13 — high-rate fraction for electric DHW heating. + +Sourced verbatim from `domain/sap10_calculator/docs/specs/sap-10-2-full- +specification-2025-03-14.pdf`, page 197 (Table 13). RdSAP10 §10.5 (PDF +p.54) routes electric immersion water heating here via Table 12a's +"Immersion water heater" row, whose Water-heating column reads "Fraction +from Table 13". + +The table gives the fraction of DHW electricity consumed at the HIGH +rate for a cylinder with a single or dual immersion heater on an +off-peak tariff; the remainder is at the low rate. Note 2 of the table +supplies exact equations equivalent to the tabulated (rounded) grid — +this module evaluates those equations, so no floor-area interpolation +is needed: + + 7-hour tariff (>= 7 hours/day at the low rate) + Dual: [(6.8 - 0.024 V) N + 14 - 0.07 V] / 100 + Single: [(14530 - 762 N) / V - 80 + 10 N] / 100 + + 10-hour tariff (>= 10 hours/day at the low rate) + Dual: [(6.8 - 0.036 V) N + 14 - 0.105 V] / 100 + Single: [(14530 - 762 N) / (1.5 V) - 80 + 10 N] / 100 + +where V is the cylinder volume (litres) and N is the assumed occupancy +(Appendix J Table 1b). Per Note 2 the result is clamped to [0, 1]. + +Table 12a (PDF p.191) — whose title reads "High-rate fractions for +systems using 7-hour and 10-hour tariffs" — routes the "Immersion water +heater" row to Table 13 for the tariff "7-hour or 10-hour" ONLY. An +18-hour or 24-hour tariff is outside Table 12a/13's scope: it provides +at least 18 hours/day at the low rate, more than enough to heat any +immersion cylinder off-peak, so the high-rate fraction is 0 (all DHW +billed at the low rate). The Elmhurst dr87 worksheet for solid fuel 5 +(cert 001431: 18-hour meter, 110 L dual immersion, WHC 903) confirms +this — HW (245) high-rate = 0.0 kWh, (246) low-rate = 100%. (An earlier +reading mapped 18-/24-hour to the 10-hour column via Note 1's "at least +10 hours"; the worksheet refutes it — Table 12a's title bounds the table +to the two named tariffs.) + +Heat pumps providing water heating only are treated as dual immersion +(Note 1) — out of scope of this helper (callers route those via Table +12a). +""" + +from __future__ import annotations + +from domain.sap10_calculator.tables.table_12a import Tariff + + +def electric_dhw_high_rate_fraction( + *, + cylinder_volume_l: float, + occupancy_n: float, + single_immersion: bool, + tariff: Tariff, +) -> float: + """SAP 10.2 Table 13 (PDF p.197) high-rate fraction for an electric + immersion DHW cylinder on an off-peak tariff. + + `single_immersion` selects the single- vs dual-immersion equation + (RdSAP10 §10.5 p.54: an immersion is assumed dual on a dual meter). + The 7-hour tariff uses the 7-hour equations; the 10-hour tariff uses + the 10-hour equations. The 18-hour and 24-hour tariffs are outside + Table 12a/13's "7-hour and 10-hour" scope (PDF p.191 title) — they + provide >= 18 hours/day at the low rate, so the high-rate fraction is + 0. STANDARD has no off-peak split and is rejected — callers must + early-return before this fires. + """ + if tariff is Tariff.STANDARD: + raise ValueError("Table 13 high-rate fraction is undefined for STANDARD") + if tariff in (Tariff.EIGHTEEN_HOUR, Tariff.TWENTY_FOUR_HOUR): + # Outside Table 12a's 7-hour/10-hour scope — >= 18 h/day low rate + # heats the cylinder entirely off-peak (high-rate fraction 0). + return 0.0 + v = cylinder_volume_l + n = occupancy_n + if tariff is Tariff.SEVEN_HOUR: + if single_immersion: + fraction = ((14530 - 762 * n) / v - 80 + 10 * n) / 100 + else: + fraction = ((6.8 - 0.024 * v) * n + 14 - 0.07 * v) / 100 + else: + # 10-hour tariff (18-/24-hour handled above as out-of-scope). + if single_immersion: + fraction = ((14530 - 762 * n) / (1.5 * v) - 80 + 10 * n) / 100 + else: + fraction = ((6.8 - 0.036 * v) * n + 14 - 0.105 * v) / 100 + return max(0.0, min(1.0, fraction)) diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 955bad9c..8377fe86 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -105,9 +105,53 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = { 0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10, 10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9, 18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41, - 26: 1, 27: 2, 28: 4, 29: 30, + 26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11, } +# Gov-API `main_fuel_type` enum codes whose value COLLIDES with a +# same-valued Table 32 code of a DIFFERENT fuel. The gov EPC register +# always lodges the API enum, so for these the API translation is +# authoritative and must win over the direct same-value Table-code +# lookup (which otherwise mis-prices solid fuel at the colliding code's +# rate). Confirmed by the description-vs-code audit on +# `main_heating[].description`: +# 5 = anthracite — Table-32 code 5 is bulk LPG (secondary), 12.19 p +# vs anthracite 3.64 p. Drove the cohort's worst cert (2100, +# -61 SAP at the LPG rate). +# 33 = coal — Table-32 code 33 is the electricity 10-hour low rate +# 7.5 p vs house coal 3.67 p (and `is_electric_fuel_code(33)` +# wrongly classified the coal main as electric). +# DEFERRED (not included): API 9 = dual fuel (mineral + wood) is also a +# collision (Table-32 9 = LPG SC11F 3.48 p vs dual fuel 3.99 p) but the +# 0.45 p delta nets neutral-to-negative on the (outlier-dominated) +# dual-fuel certs and shifts them in a direction not yet understood — +# investigate separately. +# +# COMMUNITY FUELS (handled elsewhere, NOT here): API 30 (waste +# combustion), 31 (biomass) and 32 (biogas) — all "(community)" in the +# enum — collide in VALUE with the Table-32 electricity codes 30 (standard +# rate), 31 (7-hour low) and 32 (7-hour high). They must NOT be +# canonicalised globally: the cascade uses the bare Table-32 code 30 +# internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (e.g. the RdSAP +# no-water-heating immersion default writes `water_heating_fuel=30`), so a +# blanket remap would mis-price genuine grid electricity as community +# waste. The translation is therefore done at the fuel-TYPE boundary +# GATED on heat-network context (`_heat_network_community_fuel_code` in +# cert_to_inputs), where the community meaning is unambiguous. Community +# fuels 20/25 do not collide with an electricity code, so they resolve +# correctly through the heat-network path without any special handling. +_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 33}) + + +def canonical_fuel_code(fuel_code: Optional[int]) -> Optional[int]: + """Normalise a colliding gov-API fuel enum (see + `_GOV_API_COLLISION_FUELS`) to its canonical Table 32 code so the + same-value collision can't mis-resolve it. Non-colliding codes and + already-canonical Table codes pass through unchanged.""" + if fuel_code in _GOV_API_COLLISION_FUELS: + return API_FUEL_TO_TABLE_32.get(fuel_code, fuel_code) + return fuel_code + # RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel # code. Only fuels with a published standing charge appear here; diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 3e4c9a4e..ebdb2b52 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -117,6 +117,11 @@ def _decimal_round_half_up_product(a: float, b: float, dp: int) -> float: _WALL_INSULATION_NONE: Final[int] = 4 _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85 +# RdSAP 10 Table 26 (PDF p.51): a door opening to an unheated corridor or +# stairwell takes U=1.4 W/m²K for any age band (vs 3.0 for an external door +# A-J). The door sits on the sheltered wall (RdSAP §3.7 p.18) and its area +# deducts from that wall, not the main wall. +_CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 # SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and # roof windows) — turns raw window U into the worksheet's (27) effective U. @@ -569,6 +574,7 @@ def heat_transmission_from_cert( insulated_door_count: int = 0, insulated_door_u_value: Optional[float] = None, exposure: Optional[DwellingExposure] = None, + corridor_door_count: int = 0, ) -> HeatTransmission: """Conduction HLC + thermal-bridging contribution, summed across every sap_building_part in the cert. Windows and doors are apportioned to the @@ -590,7 +596,18 @@ def heat_transmission_from_cert( floor_description = _joined_descriptions(epc.floors) # RdSAP10 §15 — door area rounds to 2 d.p. before entering the calc. - door_area = _round_half_up(max(0, door_count) * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP) + # A door to an unheated corridor (RdSAP Table 26 / §3.7) is billed at + # U=1.4 and its area deducts from the sheltered wall, not the main wall. + # Only the remaining EXTERNAL doors stay on the main wall at the + # age-default U; the corridor door area is tracked separately and + # assigned to the first sheltered alt wall in the BP loop below. + corridor_door_count = max(0, min(corridor_door_count, door_count)) + external_door_count = max(0, door_count - corridor_door_count) + door_area = _round_half_up(external_door_count * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP) + corridor_door_area = _round_half_up( + corridor_door_count * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP, + ) + corridor_door_area_remaining = corridor_door_area # SAP10.2 §3.2: effective window U includes the 0.04 m²K/W curtain # resistance — `(27)` worksheet column applies it per-window. When # sap_windows have per-window U lodgements (mixed glazing types in @@ -795,7 +812,21 @@ def heat_transmission_from_cert( # code feeds the documentary-evidence R-value calc when a # measured wall thickness is also present (else ignored). wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity, + # RdSAP 10 §5.7/§5.8 (PDF p.40-41), Table 14 — a dry-lined + # (incl. lath-and-plaster) uninsulated wall adds R=0.17. + # The alt-wall path already passes this; the main wall must + # too, else every lodged `wall_dry_lined=Y` main wall is + # billed at the un-adjusted U. + dry_lined=bool(part.wall_dry_lined), ) + # RdSAP 10 §5.1 — a lodged/known main-wall U-value (the assessor's + # RdSAP output, surfaced by the gov-EPC API as `wall_u_value`) + # overrides the §5.6/§5.7 construction-default cascade for the MAIN + # wall. Alt-wall sub-areas keep their own per-area U (handled below), + # so this only replaces the primary `uw`. + lodged_wall_u = getattr(part, "wall_u_value", None) + if lodged_wall_u is not None: + uw = lodged_wall_u # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling # age C from Slice 91's `_api_resolve_sloping_ceiling_thickness`, @@ -843,6 +874,15 @@ def heat_transmission_from_cert( # 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.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 + # open data can redact the backing insulation thickness, so the + # cascade otherwise mis-derives an uninsulated U for an insulated roof + # (cert 7921-0052-0940-5007-0663: lodged 0.2 vs cascade 2.30 → -23 SAP). + lodged_roof_u = getattr(part, "roof_u_value", None) + if lodged_roof_u is not None: + ur = lodged_roof_u # 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 @@ -887,6 +927,12 @@ def heat_transmission_from_cert( wall_thickness_mm=part.wall_thickness_mm, description=effective_floor_description, ) + # RdSAP 10 §5.1 — a lodged/known ground-floor U-value (surfaced by the + # gov-EPC API as `floor_u_value`) overrides the BS EN ISO 13370 / + # Table 19 cascade for this part's floor=0. + lodged_floor_u = getattr(part, "floor_u_value", None) + if lodged_floor_u is not None: + uf = lodged_floor_u # RdSAP 10 Table 15 footnote * — flats/maisonettes with unknown # party-wall construction default to U=0.0 (both sides heated), # not the U=0.25 house default. Cert 0036-6325-1100-0063-1226 @@ -1009,12 +1055,20 @@ def heat_transmission_from_cert( continue # RdSAP10 §15 — alt wall area rounded to 2 d.p. alt_walls_total_area += _round_half_up(alt_wall.wall_area, _AREA_ROUND_DP) + alt_opening = alt_window_area if idx == 0 else 0.0 + # RdSAP §3.7 (p.18): the door to an unheated corridor deducts + # from the SHELTERED wall area. Attach the corridor door to the + # first sheltered alt wall (its U=1.4 contribution is billed in + # the door channel below, not here). + if alt_wall.is_sheltered and corridor_door_area_remaining > 0.0: + alt_opening += corridor_door_area_remaining + corridor_door_area_remaining = 0.0 alt_walls_contribution += _alt_wall_w_per_k( alt_wall=alt_wall, country=country, age_band=age_band, wall_description=wall_description, - opening_area_m2=alt_window_area if idx == 0 else 0.0, + opening_area_m2=alt_opening, ) # Main wall net adds back the alt-wall windows that were initially # deducted from the BP's total gross — those openings should have @@ -1263,6 +1317,13 @@ def heat_transmission_from_cert( total_external_area += part_external_area bridging += y * part_external_area + # RdSAP Table 26 — the unheated-corridor door's heat loss (U=1.4). Its + # area was deducted from the sheltered alt wall above, so the alt wall + # net (and hence its W/K) already excludes it; bill it here at the + # corridor U. (31) is unaffected: the door area stays counted in the + # alt-wall gross contribution, equivalent to the worksheet's separate + # door line. + doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area roof_windows_w_per_k = roof_windows_w_per_k_total fabric_heat_loss = ( walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33) @@ -1339,4 +1400,16 @@ def _alt_wall_w_per_k( # dry-lined) → U=2.34 via §5.6 + §5.8 chain. wall_thickness_mm=alt_wall.wall_thickness_mm, ) + if alt_wall.is_sheltered: + # RdSAP 10 Table 4 (p.22) "Sheltered" wall: an alt sub-area + # adjacent to an unheated buffer (stair core, adjoining + # structure) carries an added external surface resistance of + # R=0.5 m²K/W → U_sheltered = 1/(1/U + 0.5). Mirrors the main + # wall's `gable_wall_sheltered` branch + # (`_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). The gov-EPC API + # lodges this per alt-wall as `sheltered_wall="Y"`; without it a + # sheltered timber/cavity alt sub-area billed its full exposed U + # (cert 0340-2976: a 42 m² sheltered timber-frame alt at U=2.5 + # over-stated the wall channel by ~58 W/K → -5 SAP under-rate). + alt_u = 1.0 / (1.0 / alt_u + _SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W) return alt_u * net_alt_area diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 52272d84..56eb0cd9 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -132,6 +132,7 @@ def hot_water_mixer_showers_monthly_l_per_day( has_bath: bool, mixer_shower_flow_rates_l_per_min: tuple[float, ...], cold_water_temps_c: tuple[float, ...], + n_electric_showers: int = 0, ) -> tuple[float, ...]: """SAP 10.2 §4 line (42a)m via Appendix J equations J1, J2, J3. @@ -145,14 +146,23 @@ def hot_water_mixer_showers_monthly_l_per_day( Each outlet's warm-water draw for a month is the flow rate × 6 min × Table J5 fbeh. The hot fraction is (41 − Tcold[m])/(52 − Tcold[m]). Per-outlet daily warm water is scaled by N_shower / N_outlets, then - summed across outlets to give (42a)m. + summed across the MIXER outlets to give (42a)m. - Instantaneous electric showers belong to worksheet (64a)m, not (42a)m - — those should be excluded from `mixer_shower_flow_rates_l_per_min`. + N_outlets is the dwelling's TOTAL shower-outlet count: SAP 10.2 + Appendix J step 1a is explicit that the count INCLUDES "any + instantaneous electric showers" (which then bill their own energy via + (64a)m). So `mixer_shower_flow_rates_l_per_min` lists only the mixer + outlets (iterated for the warm-water draw), but `n_electric_showers` + must be added to the denominator — otherwise a dwelling with both a + mixer and an electric shower assigns the FULL N_shower to the mixer + system AND bills the electric shower on top, double-counting shower + demand (over-counts main HW → under-rates the dwelling). """ - n_outlets = len(mixer_shower_flow_rates_l_per_min) - if n_outlets == 0: + n_mixer_outlets = len(mixer_shower_flow_rates_l_per_min) + if n_mixer_outlets == 0: return tuple(0.0 for _ in range(12)) + # Appendix J step 1a: Noutlets includes instantaneous electric showers. + n_outlets = n_mixer_outlets + n_electric_showers if has_bath: n_shower = 0.45 * n_occupants + 0.65 else: @@ -550,13 +560,21 @@ def primary_circuit_hours_per_day_table_3( *, has_cylinder_thermostat: bool, separately_timed_dhw: bool, + heat_network: bool = False, ) -> tuple[float, float]: - """SAP 10.2 Table 3 (PDF p.159) — hours of primary circulation per - day, returned as `(winter_hours, summer_hours)`: + """SAP 10.2 Table 3 (PDF p.159-160) — hours of primary circulation + per day, returned as `(winter_hours, summer_hours)`: no thermostat → (11, 3) thermostat, not separately timed → ( 5, 3) thermostat, separately timed → ( 3, 3) + + SAP 10.2 Table 3 (PDF p.160) verbatim: "For heat networks apply the + formula above with p = 1.0 and h = 3 for all months." So a heat- + network main uses h=3 winter and summer regardless of the cylinder- + thermostat / separate-timing lodgement. """ + if heat_network: + return (3.0, 3.0) if not has_cylinder_thermostat: return (11.0, 3.0) if separately_timed_dhw: @@ -569,6 +587,7 @@ def primary_loss_monthly_kwh( pipework_insulation_fraction: float, has_cylinder_thermostat: bool, separately_timed_dhw: bool, + heat_network: bool = False, ) -> tuple[float, ...]: """SAP 10.2 §4 line (59)m via Table 3 (PDF p.159): (59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263] @@ -576,6 +595,10 @@ def primary_loss_monthly_kwh( hours of primary circulation per day (winter / summer split per `primary_circuit_hours_per_day_table_3`). + `heat_network=True` selects the SAP 10.2 Table 3 (PDF p.160) heat- + network row — h=3 for all months — regardless of the cylinder- + thermostat / separate-timing lodgement. + Returns 12 monthly values in calendar order Jan..Dec. Callers must gate this helper on the spec's zero-loss configurations (combi boilers, integral-vessel HPs, CPSUs, thermal stores ≤ 1.5 m @@ -587,6 +610,7 @@ def primary_loss_monthly_kwh( winter_h, summer_h = primary_circuit_hours_per_day_table_3( has_cylinder_thermostat=has_cylinder_thermostat, separately_timed_dhw=separately_timed_dhw, + heat_network=heat_network, ) return tuple( n * 14.0 * ( @@ -880,6 +904,8 @@ def water_heating_from_cert( has_bath=has_bath, mixer_shower_flow_rates_l_per_min=mixer_shower_flow_rates_l_per_min, cold_water_temps_c=cold_water_temps_c, + # SAP 10.2 Appendix J step 1a — Noutlets includes electric showers. + n_electric_showers=electric_shower_count if has_electric_shower else 0, ) has_shower = ( len(mixer_shower_flow_rates_l_per_min) > 0 diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 2ef935ce..ea1188db 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -66,39 +66,26 @@ def _described_as_insulated(description: Optional[str]) -> bool: return "insulated" in desc or "partial insulation" in desc -def _cavity_described_as_filled(description: Optional[str]) -> bool: - """True when an as-built cavity wall's description asserts the cavity is - insulated/filled, routing it to the Table 6 "Filled cavity" row. - - Distinguishes the three as-built cavity states the EPC renders by age - band when wall_insulation_type=4 ("as-built / assumed"): - - - "...insulated (assumed)" → Filled cavity (assessor judges - the cavity filled but lodges no - thickness) - - "...partial insulation (assumed)" → "Cavity as built" row (the - as-built partial fill of the age - band, NOT a retrofit cavity fill) - - "...no insulation (assumed)" → "Cavity as built" row - - Narrower than `_described_as_insulated`: it excludes the "partial - insulation" substring so a "partial insulation (assumed)" cavity stays on - the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F = - 1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the - filled row understates heat loss by 2.5x. A genuine retrofit fill is - lodged distinctly as "Cavity wall, filled cavity" - (wall_insulation_type=2), handled by the explicit-code branch. - - Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity - type 4, "partial insulation (assumed)") closes all four SAP metrics on - the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m². - """ - if description is None: - return False - desc = description.lower() - if "no insulation" in desc: - return False - return "insulated" in desc +# An AS-BUILT cavity wall (wall_insulation_type=4 / "as-built / assumed", +# however the EPC renders the insulation adjective — "insulated", "partial +# insulation" or "no insulation" "(assumed)") routes to Table 6's "Cavity +# as built" row via the bucketed cascade, NOT the "Filled cavity" row. Per +# RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote ("assumed +# as built") applies only at age bands I-M, where the two rows are +# numerically identical — so at bands A-H the Filled cavity row represents a +# GENUINE fill, not the as-built assumption. A genuine retrofit fill is +# lodged distinctly as "Cavity wall, filled cavity" (wall_insulation_type=2), +# caught by the explicit-code branch in `u_wall`. +# +# Slice S0380.210 first corrected this for "partial insulation (assumed)" +# (golden 0390-2954-3640, band F → as-built 1.0); the "insulated (assumed)" +# variant was left on the filled row by a legacy production convention. That +# was the SAME latent A-H bug: the API SAP-accuracy cohort over-rated +# "Cavity wall, as built, insulated (assumed)" certs at bands G/H by a clean +# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60), while bands I-M +# were unaffected (rows coincide). The `_cavity_described_as_filled` +# description sniffer is therefore retired — as-built cavities always use the +# as-built row regardless of the rendered insulation adjective. # --------------------------------------------------------------------------- @@ -472,6 +459,29 @@ _DEFAULT_WALL_BY_AGE: Final[dict[str, int]] = { "L": WALL_CAVITY, "M": WALL_CAVITY, } +# Gov-EPC API `wall_construction` codes that DIVERGE from the calculator's +# internal WALL_* code-space. The gov enum (confirmed by the description-vs- +# code audit across the corpus) is 1=granite, 2=sandstone, 3=solid brick, +# 4=cavity, 5=timber frame (all ALIGN with the WALL_* constants), but +# 6=basement, 8=system built, 9=cob — whereas the calc constants are +# WALL_SYSTEM_BUILT=6, WALL_COB=7, WALL_PARK_HOME=8, WALL_CURTAIN=9. +# +# Code 8 (system built) falls OUT of `known_types` and previously resolved +# only via the `walls[].description` fallback; a cert lodging no description +# silently defaulted to cavity. Translate it here so the int code alone +# resolves. `WALL_PARK_HOME` (calc 8) is never dispatched, so reusing API +# code 8 for system built is collision-free at this layer. +# +# Code 9 (cob) is NOT handled here: calc `WALL_CURTAIN`=9 (set by the +# Summary path's "CW" mapping, with a `curtain_wall_age`) intercepts it in +# the `construction == WALL_CURTAIN` branch above. The gov-API cob code is +# therefore translated to `WALL_COB` upstream in the API mapper (where the +# source is unambiguously the gov enum), so it never reaches this collision. +# Code 6 (basement) is left to the mapper's basement machinery. +_GOV_API_WALL_CODE_TO_TYPE: Final[dict[int, int]] = { + 8: WALL_SYSTEM_BUILT, # gov API "System built" +} + # Surveyor-text -> wall-construction code, evaluated in priority order so that # "sandstone" beats "stone", "solid brick" beats "brick", etc. Used only as a @@ -598,6 +608,11 @@ def u_wall( } if construction in known_types: wall_type = construction + elif construction in _GOV_API_WALL_CODE_TO_TYPE: + # A gov-API construction code (system built / cob) that diverges from + # the WALL_* code-space — resolve it directly, independent of the + # surveyor description (RdSAP 10 §5.7 Table 6 rows still apply). + wall_type = _GOV_API_WALL_CODE_TO_TYPE[construction] else: wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) # RdSAP 10 §5.7 Table 13 + §5.8 (PDF p.41-42) — uninsulated solid @@ -689,7 +704,6 @@ def u_wall( ) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY - or _cavity_described_as_filled(description) ): return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) @@ -871,9 +885,30 @@ def u_roof( return u if description is not None: desc = description.lower() - if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS): + no_insulation = any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS) + limited_insulation = any( + marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS + ) + if (no_insulation or limited_insulation) and is_flat_roof and age_band is not None: + # FLAT roof reached here only when insulation_thickness_mm is None + # — the lodged thickness is undetermined ('ND'/'AB'/absent). Per + # RdSAP 10 §5.11.4 (PDF p.44) "U-values in Table 18 are used when + # thickness of insulation cannot be determined", so the column (3) + # flat-roof age-band default applies — NOT the uninsulated 2.30. + # The "no/limited insulation" text is RdSAP's as-built rendering: + # at old bands (A-D) the column (3) default IS 2.30 (so those certs + # are unchanged), but a newer-band flat roof carries the age-band + # insulation as built (band H = 0.35, F = 0.68, not 2.30). The + # roof's deterministic energy_efficiency_rating confirms it (e.g. + # cert 0390-2753 band H lodges rating 3 = moderate U, not the + # rating-1 that 2.30 implies). PITCHED roofs are deliberately NOT + # routed here — their "no insulation" text is load-bearing (the + # broad 'ND'→Table-18 reroute was empirically net-negative for + # pitched lofts). + return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) + if no_insulation: return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K - if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS): + if limited_insulation: return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 diff --git a/domain/sap10_ml/sap_efficiencies.py b/domain/sap10_ml/sap_efficiencies.py index d62db300..29550950 100644 --- a/domain/sap10_ml/sap_efficiencies.py +++ b/domain/sap10_ml/sap_efficiencies.py @@ -16,6 +16,8 @@ from __future__ import annotations from typing import Final, Optional +from domain.sap10_calculator.exceptions import UnmappedSapCode + # --------------------------------------------------------------------------- # Table 4a + Table 4b — space-heating seasonal efficiency by code @@ -60,7 +62,13 @@ _SPACE_EFF_BY_CODE: Final[dict[int, float]] = { 607: 0.45, 609: 0.58, 610: 0.72, 611: 0.85, 612: 0.20, 613: 0.90, # Room heaters — liquid. 621: 0.55, 622: 0.65, 623: 0.60, 624: 0.70, 625: 0.94, - # Room heaters — solid (column B non-HETAS). + # Room heaters — solid. SAP 10.2 Table 4a (p.167): column (A) is the + # minimum for HETAS-approved appliances, column (B) for other + # appliances. RdSAP defaults to column (B) when approval is not + # lodged (Elmhurst worksheet "solid fuel 9" code 636 → (206)=70 = B), + # so these are the column-(B) space efficiencies: 631 open fire 32, + # 632 +back boiler 50, 633 closed room heater 60, 634 +boiler 65, + # 635 pellet stove 65, 636 +boiler 70. 631: 0.32, 632: 0.50, 633: 0.60, 634: 0.65, 635: 0.65, 636: 0.70, # Room heaters — electric. 691: 1.00, 692: 1.00, 693: 1.00, 694: 1.00, @@ -148,6 +156,15 @@ def seasonal_efficiency( eff = _CATEGORY_FALLBACK_EFF.get(main_heating_category) if eff is not None: return eff + # Reached when neither code nor category resolved. If SOMETHING was + # lodged but unmapped, surface it (the blind 0.80 gas-boiler default + # catastrophically misrates heat pumps / storage). Only a genuinely + # data-free call (both absent) keeps the 0.80 "no data" default. + if sap_main_heating_code is not None or main_heating_category is not None: + raise UnmappedSapCode( + "seasonal_efficiency (code/category)", + (sap_main_heating_code, main_heating_category), + ) return 0.80 @@ -159,13 +176,15 @@ def water_heating_efficiency( Codes 901/914 ("from main / from second main") inherit the main code's seasonal efficiency. Code 902 ("from secondary") falls back to typical. - Unknown -> 0.78 (gas-combi typical). + Absent (None) -> 0.78 (gas-combi typical). A code PRESENT but in no SAP + 10.2 Table 4a HW row raises `UnmappedSapCode` rather than silently taking + the 0.78 default — the forcing function that surfaces a new HW code. """ if water_heating_code is None: return 0.78 eff = _WATER_EFF_BY_CODE.get(water_heating_code) if eff is None: - return 0.78 + raise UnmappedSapCode("water_heating_code (efficiency)", water_heating_code) if eff == 0.0: # sentinel for "inherit" return seasonal_efficiency(main_heating_code) return eff diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index 040466b5..2c7c3b68 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -96,6 +96,8 @@ def make_sap_heating( water_heating_code: Optional[int] = 901, water_heating_fuel: Optional[int] = 26, cylinder_size: Optional[Union[int, str]] = None, + cylinder_volume_measured_l: Optional[int] = None, + immersion_heating_type: Optional[Union[int, str]] = None, cylinder_insulation_type: Optional[int] = None, cylinder_insulation_thickness_mm: Optional[int] = None, cylinder_thermostat: Optional[str] = None, @@ -115,6 +117,8 @@ def make_sap_heating( water_heating_code=water_heating_code, water_heating_fuel=water_heating_fuel, cylinder_size=cylinder_size, + cylinder_volume_measured_l=cylinder_volume_measured_l, + immersion_heating_type=immersion_heating_type, cylinder_insulation_type=cylinder_insulation_type, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, cylinder_thermostat=cylinder_thermostat, diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index f7e31c70..1018ce30 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -114,20 +114,26 @@ def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote( assert result == pytest.approx(0.55, abs=0.001) -def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None: - # Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge - # wall_insulation_type=4 ("as-built / assumed") together with the - # description "Cavity wall, as built, insulated (assumed)". The - # assessor is saying: this cavity is filled, but I haven't measured - # the thickness. Spec footnote on Table 6 covers this: "If a wall - # is known to have additional insulation but the insulation thickness - # is unknown, use the row in the table for 50 mm insulation" — but - # legacy convention (used by the production recommendation engine) - # is to route this to the Filled-cavity row, U = 0.7 at A-E. We - # follow the legacy convention here for parity with the cert assessor. +def test_u_wall_cavity_as_built_insulated_assumed_routes_to_as_built_row() -> None: + # Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)" + # with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit + # cavity fill. Per RdSAP 10 Table 6 (England) the "Filled cavity" row's + # † footnote ("assumed as built") applies only at bands I-M, where it + # coincides with "Cavity as built"; at bands A-H the filled row is for a + # GENUINE fill. So an as-built cavity uses the "Cavity as built" row: + # band E = 1.5, NOT the filled 0.7. + # + # Slice S0380.210 corrected this for the "partial insulation (assumed)" + # variant but left "insulated (assumed)" on the filled row by a legacy + # production convention — the SAME latent A-H bug. The API SAP-accuracy + # cohort over-rated band-G/H "insulated (assumed)" cavities by a clean + # +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60); bands I-M were + # unaffected (rows coincide). A genuine fill lodges the distinct "Cavity + # wall, filled cavity" (wall_insulation_type=2), caught by the + # explicit-code branch. # Act - result = u_wall( + result_e = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, @@ -136,19 +142,30 @@ def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() wall_insulation_type=4, description="Cavity wall, as built, insulated (assumed)", ) + # Band I: "Cavity as built" and "Filled cavity" rows coincide (0.45), + # so the routing change is a no-op there — the corpus-confirmed pivot. + result_i = u_wall( + country=Country.ENG, + age_band="I", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + description="Cavity wall, as built, insulated (assumed)", + ) - # Assert - assert result == pytest.approx(0.7, abs=0.001) + # Assert — band E → as-built 1.5 (not filled 0.7); band I → 0.45 (rows coincide). + assert abs(result_e - 1.5) <= 0.001 + assert abs(result_i - 0.45) <= 0.001 def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None: # Arrange — the same wall_insulation_type=4 ("as-built / assumed") # cert population also contains 686 "Cavity wall, as built, no - # insulation (assumed)" entries which must continue to route to the - # Cavity-as-built row of Table 6 (U=1.5 at band E). The "no - # insulation" substring marker takes precedence over the - # "insulated"-substring filled-cavity rule, so this case is - # disambiguated from "Cavity wall, as built, insulated (assumed)". + # insulation (assumed)" entries which route to the Cavity-as-built row + # of Table 6 (U=1.5 at band E) — as do ALL as-built cavity variants + # ("insulated" / "partial insulation" / "no insulation") now that the + # as-built path no longer special-cases the insulation adjective. # Act result = u_wall( @@ -180,8 +197,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> N # four SAP metrics on the as-built row (band F = 1.0) and under-counts # PE by ~28 kWh/m² on the filled row — the legacy parity was a latent # bug at bands A-H (bands I-M coincide per the Table 6 † footnote). - # The "insulated (assumed)" variant still routes to filled (see the - # heat_transmission `_cavity_described_as_filled` sibling test). + # A later slice extended the same fix to the "insulated (assumed)" + # variant (see the as-built-insulated sibling test above). # Act result = u_wall( @@ -434,6 +451,29 @@ def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> No assert result == pytest.approx(0.60, abs=0.001) +def test_u_wall_gov_api_system_built_code_8_resolves_without_description() -> None: + # Arrange — the gov-EPC API `wall_construction` enum diverges from the + # calculator's internal WALL_* code-space: API 8 = "System built" (calc + # WALL_SYSTEM_BUILT = 6; calc 8 = park home). The 43-cert system-built + # cohort currently resolves only via the `walls[].description` fallback; + # with no description, code 8 silently defaulted to cavity (1.5) instead + # of the system-built U (band E as-built = 1.7). + + # Act — code 8, NO description. + result = u_wall( + country=Country.ENG, age_band="E", construction=8, + insulation_thickness_mm=0, description=None, + ) + reference = u_wall( + country=Country.ENG, age_band="E", construction=WALL_SYSTEM_BUILT, + insulation_thickness_mm=0, + ) + + # Assert — code 8 is system-built (1.7), not the cavity default (1.5). + assert abs(result - reference) <= 1e-9 + assert abs(result - 1.7) <= 1e-9 + + def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None: # Arrange — no signal at all. @@ -861,6 +901,46 @@ def test_u_roof_unknown_flat_insulation_uses_table18_flat_column() -> None: assert abs(result - 0.35) <= 0.01 +def test_u_roof_flat_no_insulation_undetermined_thickness_uses_table18_by_age() -> None: + # Arrange — a flat roof lodged "Flat, no insulation" / "Flat, limited + # insulation" with an UNDETERMINED thickness (parsed to None from + # 'ND'/'AB') must take the Table 18 column (3) flat-roof age-band + # default per RdSAP 10 §5.11.4 (PDF p.44), NOT the uninsulated 2.30. + # The "no/limited insulation" text is RdSAP's as-built rendering — at + # old bands the column (3) default IS 2.30 (so they're unchanged), but + # a newer-band flat roof carries the age-band insulation as built. + # Cert 0390-2753 (top-floor flat, band H, "Flat, no insulation", + # thickness 'ND', roof rating 3 = moderate) drove a -31.78 SAP error at + # the 2.30 value; band H column (3) = 0.35. + + # Act — band H "no insulation" → 0.35; band F "limited insulation" → 0.68; + # band C "no insulation" → unchanged 2.30 (column (3) default at C). + band_h = u_roof( + country=Country.ENG, age_band="H", insulation_thickness_mm=None, + description="Flat, no insulation", is_flat_roof=True, + ) + band_f = u_roof( + country=Country.ENG, age_band="F", insulation_thickness_mm=None, + description="Flat, limited insulation", is_flat_roof=True, + ) + band_c = u_roof( + country=Country.ENG, age_band="C", insulation_thickness_mm=None, + description="Flat, no insulation", is_flat_roof=True, + ) + # A PITCHED roof "no insulation" with undetermined thickness is NOT + # rerouted — its text is load-bearing (2.30 stays). + pitched = u_roof( + country=Country.ENG, age_band="H", insulation_thickness_mm=None, + description="Pitched, no insulation", is_flat_roof=False, + ) + + # Assert + assert abs(band_h - 0.35) <= 0.01 + assert abs(band_f - 0.68) <= 0.01 + assert abs(band_c - 2.30) <= 0.01 + assert abs(pitched - 2.30) <= 0.01 + + def test_u_roof_age_band_j_pitched_returns_table18_value() -> None: # Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K. diff --git a/domain/sap10_ml/tests/test_sap_efficiencies.py b/domain/sap10_ml/tests/test_sap_efficiencies.py index 76f513b9..b8245d6c 100644 --- a/domain/sap10_ml/tests/test_sap_efficiencies.py +++ b/domain/sap10_ml/tests/test_sap_efficiencies.py @@ -51,6 +51,22 @@ def test_seasonal_efficiency_ground_source_heat_pump_returns_table4a_value() -> assert result == pytest.approx(2.30, abs=0.005) +def test_seasonal_efficiency_solid_fuel_room_heaters_use_table4a_column_b() -> None: + # Arrange — SAP 10.2 Table 4a (p.167) solid-fuel room heaters give + # column (A) for HETAS-approved appliances and column (B) for other + # appliances. RdSAP defaults to column (B) when HETAS approval is not + # lodged (Elmhurst worksheet "solid fuel 9" code 636 → (206)=70 = B): + # 631 open fire 32, 633 closed room heater 60, 634 with boiler 65, + # 635 pellet stove 65, 636 with boiler 70. + + # Act / Assert + assert seasonal_efficiency(sap_main_heating_code=631) == 0.32 + assert seasonal_efficiency(sap_main_heating_code=633) == 0.60 + assert seasonal_efficiency(sap_main_heating_code=634) == 0.65 + assert seasonal_efficiency(sap_main_heating_code=635) == 0.65 + assert seasonal_efficiency(sap_main_heating_code=636) == 0.70 + + def test_seasonal_efficiency_oil_boiler_returns_table4b_value() -> None: # Arrange — Table 4b, code 126 standard oil 1998+ -> 80% winter. @@ -275,3 +291,48 @@ def test_fuel_unit_price_recognises_api_code_29_electricity_not_community() -> N def test_fuel_unit_price_recognises_api_code_27_lpg_not_community() -> None: # Arrange / Act — gov API code 27 = LPG not community -> bulk LPG 7.60 p/kWh. assert fuel_unit_price_p_per_kwh(fuel_code=27) == pytest.approx(7.60, abs=0.01) + + +# ----- Robustness: strict-raise on a lodged-but-unmapped code ----- + + +def test_seasonal_efficiency_raises_on_present_unmapped_code_and_category() -> None: + # Arrange — a main-heating SAP code AND category that resolve in NEITHER + # _SPACE_EFF_BY_CODE nor the category/room-heater fallbacks. The blind + # 0.80 gas-boiler default "catastrophically misrates heat pumps and + # storage" (per the table comment), so a lodged-but-unmapped pairing must + # surface as UnmappedSapCode, not silently rate as a gas boiler. + from domain.sap10_calculator.exceptions import UnmappedSapCode + + # Act / Assert + with pytest.raises(UnmappedSapCode): + seasonal_efficiency(sap_main_heating_code=8888, main_heating_category=99) + + +def test_seasonal_efficiency_no_data_still_defaults_to_gas_boiler() -> None: + # Arrange — when NOTHING is lodged (code and category both absent), the + # 0.80 typical-gas default is the correct "no data" fallback, not a raise. + # Act + result = seasonal_efficiency(sap_main_heating_code=None, main_heating_category=None) + + # Assert + assert result == 0.80 + + +def test_water_heating_efficiency_raises_on_present_unmapped_code() -> None: + # Arrange — a water-heating code that exists in NO SAP 10.2 Table 4a HW + # row must surface rather than silently take the 0.78 gas-combi default. + from domain.sap10_calculator.exceptions import UnmappedSapCode + + # Act / Assert + with pytest.raises(UnmappedSapCode): + water_heating_efficiency(water_heating_code=9999, main_heating_code=None) + + +def test_water_heating_efficiency_absent_code_still_defaults() -> None: + # Arrange — no water-heating code lodged (None) keeps the typical default. + # Act + result = water_heating_efficiency(water_heating_code=None, main_heating_code=None) + + # Assert + assert result == 0.78 diff --git a/scripts/decompose_co2_pe_error.py b/scripts/decompose_co2_pe_error.py new file mode 100644 index 00000000..a025a738 --- /dev/null +++ b/scripts/decompose_co2_pe_error.py @@ -0,0 +1,124 @@ +"""Decompose the API-path CO2/PE over-estimate against lodged EPC figures. + +The corpus integration test surfaced a systematic +5-10% over-estimate on +CO2 and PE while cost/SAP is well-calibrated. This script profiles the +structure of that bias: multiplicative vs additive, per-component shares, +and segmentation by fuel / PV / heating type — to localise the cause. +""" +from __future__ import annotations + +import json +import statistics as stats +from collections import defaultdict +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) + +CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +def main() -> None: + docs = [ + json.loads(line) + for line in CORPUS.read_text().splitlines() + if line.strip() + ] + rows: list[dict[str, Any]] = [] + for doc in docs: + lodged_sap = doc.get("energy_rating_current") + lodged_co2 = doc.get("co2_emissions_current") # t/yr + lodged_pe = doc.get("energy_consumption_current") # kWh/m2/yr + if lodged_pe is None or lodged_co2 is None: + continue + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + res = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + except Exception: + continue + tfa = res.intermediate["tfa_m2"] + im = res.intermediate + rows.append({ + "cert": doc.get("rrn") or "", + "sap_err": res.sap_score_continuous - (lodged_sap or 0), + "our_pe": res.primary_energy_kwh_per_m2, + "lodged_pe": lodged_pe, + "pe_diff": res.primary_energy_kwh_per_m2 - lodged_pe, + "pe_ratio": res.primary_energy_kwh_per_m2 / lodged_pe if lodged_pe else 0, + "our_co2": res.co2_kg_per_yr / 1000.0, + "lodged_co2": lodged_co2, + "co2_diff": res.co2_kg_per_yr / 1000.0 - lodged_co2, + "co2_ratio": (res.co2_kg_per_yr / 1000.0) / lodged_co2 if lodged_co2 else 0, + # PE components per m2 + "space_pe": im["space_heating_pe_kwh_per_m2"], + "hw_pe": im["hot_water_pe_kwh_per_m2"], + "other_pe": im["other_pe_kwh_per_m2"], + "pv_pe": im["pv_pe_offset_kwh_per_m2"], + "mains_gas": (doc.get("sap_energy_source") or {}).get("mains_gas"), + "has_pv": bool((doc.get("sap_energy_source") or {}).get("photovoltaic_supply")), + "tfa": tfa, + }) + + n = len(rows) + print(f"decomposed {n} certs\n" + "=" * 70) + + def summ(key: str) -> str: + vals = [r[key] for r in rows] + return (f"median={stats.median(vals):+.3f} mean={stats.mean(vals):+.3f} " + f"p10={sorted(vals)[n//10]:+.3f} p90={sorted(vals)[n*9//10]:+.3f}") + + print(f"PE diff (kWh/m2): {summ('pe_diff')}") + print(f"PE ratio (our/lod): {summ('pe_ratio')}") + print(f"CO2 diff (t/yr) : {summ('co2_diff')}") + print(f"CO2 ratio (our/lod): {summ('co2_ratio')}") + print("=" * 70) + + # multiplicative vs additive: correlate pe_diff with lodged_pe magnitude + lod = [r["lodged_pe"] for r in rows] + dif = [r["pe_diff"] for r in rows] + mean_lod, mean_dif = stats.mean(lod), stats.mean(dif) + cov = sum((l - mean_lod) * (d - mean_dif) for l, d in zip(lod, dif)) / n + var = sum((l - mean_lod) ** 2 for l in lod) / n + slope = cov / var + print(f"PE: regress pe_diff ~ lodged_pe -> slope={slope:+.4f} intercept={mean_dif - slope*mean_lod:+.3f}") + print(" (slope~0 => additive constant; slope>0 => multiplicative)") + print("=" * 70) + + # segment + def seg(name: str, pred: Any) -> None: + s = [r for r in rows if pred(r)] + if not s: + return + print(f" {name:28s} n={len(s):4d} PEdiff_med={stats.median(r['pe_diff'] for r in s):+6.2f} " + f"PEratio_med={stats.median(r['pe_ratio'] for r in s):.3f} " + f"CO2diff_med={stats.median(r['co2_diff'] for r in s):+.3f} " + f"SAPerr_med={stats.median(r['sap_err'] for r in s):+.2f}") + + print("SEGMENTS:") + seg("mains_gas=Y", lambda r: r["mains_gas"] in (True, 1, "Y")) + seg("mains_gas=N", lambda r: r["mains_gas"] not in (True, 1, "Y")) + seg("ALL", lambda r: True) + print("=" * 70) + print("PE COMPONENT MEANS (kWh/m2):") + for k in ("space_pe", "hw_pe", "other_pe", "pv_pe", "our_pe", "lodged_pe"): + print(f" {k:12s} mean={stats.mean(r[k] for r in rows):7.2f} " + f"median={stats.median(r[k] for r in rows):7.2f}") + print("=" * 70) + # Well-calibrated-SAP certs: energy is right, so PE diff isolates factor/scope. + cal = [r for r in rows if abs(r["sap_err"]) < 0.3] + print(f"WELL-CALIBRATED-SAP (|sap_err|<0.3) n={len(cal)}:") + print(f" PE diff median={stats.median(r['pe_diff'] for r in cal):+.2f} " + f"ratio={stats.median(r['pe_ratio'] for r in cal):.3f}") + print(f" CO2 diff median={stats.median(r['co2_diff'] for r in cal):+.3f} " + f"ratio={stats.median(r['co2_ratio'] for r in cal):.3f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/profile_case34.py b/scripts/profile_case34.py new file mode 100644 index 00000000..21484cc2 --- /dev/null +++ b/scripts/profile_case34.py @@ -0,0 +1,95 @@ +"""Decompose simulated case 34 (electric-storage corridor flat) vs its +dr87/P960 worksheet, channel by channel. + +Routes the tracked fixture +`backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf` +through extractor -> mapper -> calculator (Summary path) and prints the §3 +heat-transmission breakdown + the space-heating demand / ventilation / +gains / MIT intermediates against the worksheet line refs. + +The fabric (33), bridging (36), (31) and the door channel are EXACT after +HEAD c10881ae. The +46.3 kWh/yr (+0.41%) space-heating over-count was the +§2 (13) draught lobby: a corridor flat assumes a lobby (0.0), not the 0.05 +no-lobby penalty (RdSAP 10 spec p.30) — closed at HEAD 450e33e1, which lands +effective air change (25)m on the worksheet (avg 0.6024) and SAP at 35.3130 +vs ws 35.3094 (Δ +0.0036, sub-2dp-rounding). The residual now sits at the +worksheet's own 2dp display floor (walls -0.017 W/K). Use this to audit. + + PYTHONPATH=/workspaces/model python scripts/profile_case34.py +""" +import re +import subprocess +from pathlib import Path + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, + heat_transmission_section_from_cert, +) + +_FIXTURE = ( + Path(__file__).parent.parent + / "backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf" +) + + +def _pages(pdf: Path) -> list[str]: + info = subprocess.run( + ["pdfinfo", str(pdf)], capture_output=True, text=True, check=True + ).stdout + pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr] + pages: list[str] = [] + for i in range(1, pc + 1): + layout = subprocess.run( + ["pdftotext", "-layout", "-f", str(i), "-l", str(i), str(pdf), "-"], + capture_output=True, text=True, check=True, + ).stdout + toks: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + toks.append("") + continue + toks.extend([p for p in re.split(r"\s{2,}", line.strip()) if p]) + pages.append("\n".join(toks)) + return pages + + +def main() -> None: + sn = ElmhurstSiteNotesExtractor(_pages(_FIXTURE)).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(sn) + ht = heat_transmission_section_from_cert(epc) + print("== §3 HEAT TRANSMISSION (all EXACT at c10881ae) ==") + print(f"(31) ext elem area = {ht.total_external_element_area_m2:.4f} | ws 120.369") + print(f"(29a) walls = {ht.walls_w_per_k:.4f} | ws 30.902") + print(f"(30) roof = {ht.roof_w_per_k:.4f} | ws 91.954") + print(f"(28a) floor = {ht.floor_w_per_k:.4f} | ws 27.986") + print(f"(26) doors = {ht.doors_w_per_k:.4f} | ws 8.14 (corridor 1.4 + ext 3.0)") + print(f"(33) fabric = {ht.fabric_heat_loss_w_per_k:.4f} | ws 207.484") + print(f"(36) bridging = {ht.thermal_bridging_w_per_k:.4f} | ws 18.054") + print(f"(37) total fabric = {ht.total_w_per_k:.4f} | ws 225.538") + + res = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + im = res.intermediate + print("\n== RESIDUAL CLOSED at 450e33e1: was +46.3 kWh, now -0.96 kWh ==") + keys = [ + "useful_space_heating_kwh_per_yr", # ws (98) = 11357.24 + "infiltration_ach", "infiltration_w_per_k", + "internal_gains_annual_avg_w", # ws (73)/(84) + "mean_internal_temp_annual_avg_c", # ws (85)-(93) + ] + for k in keys: + if k in im: + print(f" {k} = {round(im[k], 4)}") + print( + "\nworksheet targets: (98) space heat=11357.24 | (35) TMP=250 |" + " (25)m eff ach 0.46-0.64 | (20) shelter=0.85 | (19) sheltered sides=2" + ) + print(f"\nSAP cont = {res.sap_score_continuous:.4f} | ws 35.3094 | " + f"Δ = {res.sap_score_continuous - 35.3094:+.4f}") + + +if __name__ == "__main__": + main() diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 5e8e1576..81b9f692 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -67,6 +67,7 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: cylinder_insulation_type=1, cylinder_insulation_thickness_mm=120, cylinder_thermostat="Y", + immersion_heating_type=1, has_hot_water_cylinder=True, meter_type="Dual", ) diff --git a/tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json b/tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json new file mode 100644 index 00000000..3e4b64aa --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json @@ -0,0 +1 @@ +{"uprn": 100010371693, "roofs": [{"description": "Pitched, 400+ mm loft insulation", "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5}], "walls": [{"description": "Cavity wall, filled cavity", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}], "floors": [{"description": "Solid, no insulation (assumed)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}], "status": "entered", "tenure": 2, "window": {"description": "Fully double glazed", "energy_efficiency_rating": 2, "environmental_efficiency_rating": 2}, "addendum": {"addendum_numbers": [8]}, "lighting": {"description": "Good lighting efficiency", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, "postcode": "PR6 0AZ", "hot_water": {"description": "From main system", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, "post_town": "CHORLEY", "built_form": 2, "created_at": "2026-05-12 09:06:05", "door_count": 2, "region_code": 19, "report_type": 2, "sap_heating": {"number_baths": 0, "cylinder_size": 1, "shower_outlets": [{"shower_wwhrs": 1, "shower_outlet_type": 2}], "number_baths_wwhrs": 0, "water_heating_code": 901, "water_heating_fuel": 26, "secondary_fuel_type": 29, "main_heating_details": [{"has_fghrs": "N", "main_fuel_type": 26, "boiler_flue_type": 2, "fan_flue_present": "Y", "heat_emitter_type": 1, "ttzc_index_number": 200131, "emitter_temperature": 0, "main_heating_number": 1, "main_heating_control": 2112, "main_heating_category": 2, "main_heating_fraction": 1, "central_heating_pump_age": 0, "main_heating_data_source": 1, "main_heating_index_number": 19080}], "immersion_heating_type": "NA", "secondary_heating_type": 691, "has_fixed_air_conditioning": "false"}, "sap_version": 10.2, "sap_windows": [{"pvc_frame": "true", "glazing_gap": 12, "orientation": 3, "window_type": 1, "glazing_type": 3, "window_width": 0.4, "window_height": 1.3, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 3, "window_type": 1, "glazing_type": 3, "window_width": 0.4, "window_height": 1.3, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 3, "window_type": 1, "glazing_type": 3, "window_width": 1.75, "window_height": 1.3, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 7, "window_type": 1, "glazing_type": 3, "window_width": 1.1, "window_height": 1.1, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 7, "window_type": 1, "glazing_type": 3, "window_width": 1.2, "window_height": 1.2, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}], "schema_type": "RdSAP-Schema-21.0.1", "uprn_source": "Energy Assessor", "country_code": "ENG", "main_heating": [{"description": "Boiler and radiators, mains gas", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}], "air_tightness": {"description": "(not tested)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, "dwelling_type": "Semi-detached bungalow", "language_code": 1, "pressure_test": 4, "property_type": 1, "address_line_1": "27 Epping Place", "assessment_type": "RdSAP", "completion_date": "2026-05-12", "inspection_date": "2026-05-07", "wet_rooms_count": 2, "extensions_count": 0, "measurement_type": 1, "total_floor_area": 39, "transaction_type": 5, "conservatory_type": 1, "heated_room_count": 2, "registration_date": "2026-05-12", "sap_energy_source": {"mains_gas": "Y", "meter_type": 2, "pv_connection": 2, "photovoltaic_supply": [[{"pitch": 3, "peak_power": 1.35, "orientation": 7, "overshading": 1}], [{"pitch": 3, "peak_power": 1.35, "orientation": 3, "overshading": 1}]], "wind_turbines_count": 0, "gas_smart_meter_present": "true", "is_dwelling_export_capable": "true", "wind_turbines_terrain_type": 2, "electricity_smart_meter_present": "true"}, "secondary_heating": {"description": "Room heaters, electric", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, "lzc_energy_sources": [11], "sap_building_parts": [{"identifier": "Main Dwelling", "wall_dry_lined": "N", "wall_thickness": 300, "floor_heat_loss": 7, "roof_construction": 4, "wall_construction": 4, "building_part_number": 1, "sap_floor_dimensions": [{"floor": 0, "room_height": {"value": 2.48, "quantity": "metres"}, "floor_insulation": 1, "total_floor_area": {"value": 39.2, "quantity": "square metres"}, "party_wall_length": {"value": 7, "quantity": "metres"}, "floor_construction": 1, "heat_loss_perimeter": {"value": 18.2, "quantity": "metres"}}], "wall_insulation_type": 2, "construction_age_band": "C", "party_wall_construction": 0, "wall_thickness_measured": "Y", "roof_insulation_location": 2, "roof_insulation_thickness": "400mm+", "wall_insulation_thickness": "NI", "floor_insulation_thickness": "NI"}], "solar_water_heating": "N", "habitable_room_count": 2, "heating_cost_current": {"value": 670, "currency": "GBP"}, "insulated_door_count": 0, "co2_emissions_current": 1.2, "energy_rating_average": 60, "energy_rating_current": 82, "lighting_cost_current": {"value": 29, "currency": "GBP"}, "main_heating_controls": [{"description": "Time and temperature zone control", "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5}], "has_hot_water_cylinder": "false", "heating_cost_potential": {"value": 601, "currency": "GBP"}, "hot_water_cost_current": {"value": 203, "currency": "GBP"}, "mechanical_ventilation": 2, "percent_draughtproofed": 100, "suggested_improvements": [{"sequence": 1, "typical_saving": {"value": 68, "currency": "GBP"}, "indicative_cost": "\u00a35,000 - \u00a310,000", "improvement_type": "W2", "improvement_details": {"improvement_number": 58}, "improvement_category": 5, "energy_performance_rating": 84, "environmental_impact_rating": 84}], "co2_emissions_potential": 1.0, "energy_rating_potential": 84, "kitchen_duct_fans_count": 0, "kitchen_room_fans_count": 1, "kitchen_wall_fans_count": 0, "lighting_cost_potential": {"value": 29, "currency": "GBP"}, "schema_version_original": "21.0.1", "hot_water_cost_potential": {"value": 203, "currency": "GBP"}, "renewable_heat_incentive": {"water_heating": 1148.49, "space_heating_existing_dwelling": 5025.34}, "draughtproofed_door_count": 2, "mechanical_vent_duct_type": 1, "energy_consumption_current": 181, "has_fixed_air_conditioning": "false", "multiple_glazed_proportion": 100, "non_kitchen_duct_fans_count": 0, "non_kitchen_room_fans_count": 1, "non_kitchen_wall_fans_count": 0, "calculation_software_version": "5.02r0344", "energy_consumption_potential": 158, "environmental_impact_current": 81, "current_energy_efficiency_band": "B", "environmental_impact_potential": 84, "has_heated_separate_conservatory": "false", "potential_energy_efficiency_band": "B", "mechanical_ventilation_index_number": 500777, "co2_emissions_current_per_floor_area": 30, "low_energy_fixed_lighting_bulbs_count": 5, "incandescent_fixed_lighting_bulbs_count": 0, "is_mechanical_vent_approved_installer_scheme": "false"} \ No newline at end of file diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 08a25d75..4531c08d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -30,6 +30,7 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, PhotovoltaicArray, + SapAlternativeWall, SapFloorDimension, SapVentilation, ) @@ -48,12 +49,21 @@ from domain.sap10_calculator.exceptions import ( ) from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, + _apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage] + _cylinder_thermostat_present, # pyright: ignore[reportPrivateUsage] _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] + _heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage] + _main_fuel_code, # pyright: ignore[reportPrivateUsage] + _mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] + _mev_default_data_iuf, # pyright: ignore[reportPrivateUsage] + _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage] + dimensions_from_cert, + _table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -84,6 +94,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( ventilation_from_cert, ) from domain.sap10_calculator.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record +from domain.sap10_calculator.worksheet.mev import mev_decentralised_kwh_per_yr from tests.domain.sap10_calculator.worksheet import _elmhurst_worksheet_000477 as _w000477 from domain.sap10_calculator.worksheet.water_heating import ( combi_loss_monthly_kwh_table_3b_row_1_instantaneous, @@ -142,12 +153,17 @@ def _typical_semi_detached_epc(): @pytest.mark.parametrize( "wall_construction, wall_insulation_type, expected_tmp", [ - # RdSAP 10 §5.16 Table 22 (PDF p.48) — timber frame (5), cob (7), - # park home (8) are always low-mass, regardless of insulation. + # RdSAP 10 §5.16 Table 22 (PDF p.48) — timber frame (5), cob (7) + # are always low-mass, regardless of insulation. (5, 4, 100.0), # timber frame, as-built (5, 2, 100.0), # timber frame, filled cavity — still 100 (7, 4, 100.0), # cob - (8, 1, 100.0), # park home, external insulation — still 100 + # Wall code 8 with no park-home property is gov-API SYSTEM BUILT + # (calc 8 = park home only on the Summary path) → masonry. Table 22 + # lists system build as masonry (250 as-built); the park-home + # low-mass case is covered separately below (needs property_type). + (8, 1, 250.0), # system built (gov-API code 8), external insulation + (8, 4, 250.0), # system built (gov-API code 8), as-built # Masonry WITH internal insulation (ins 3 = internal, 7 = # filled cavity + internal) → low-mass 100. (3, 3, 100.0), # solid brick + internal @@ -197,6 +213,36 @@ def test_thermal_mass_parameter_follows_rdsap_table_22( assert abs(tmp - expected_tmp) <= 1e-9 +def test_thermal_mass_wall_code_8_is_park_home_only_when_property_type_says_so() -> None: + # Arrange — RdSAP 10 §5.16 Table 22 (PDF p.48): timber frame / cob / + # PARK HOME are low-mass (100); system build is masonry (250 as-built). + # Wall_construction code 8 is overloaded: the Summary path's "PH" + # mapping uses 8 for park home, but the gov-API enum uses 8 for SYSTEM + # BUILT (Summary system build is code 6). So code 8 may only take the + # park-home low-mass value when the dwelling really is a park home — + # otherwise it is gov-API system built (masonry). Same construction + # code, opposite thermal mass, disambiguated by `property_type`. + def _epc(property_type: Optional[str]) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, + country_code="ENG", property_type=property_type, + sap_building_parts=[ + make_building_part(wall_construction=8, wall_insulation_type=4), + ], + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + ), + ) + + # Act + tmp_park_home: float = _thermal_mass_parameter_kj_per_m2_k(_epc("Park home")) + tmp_system_built: float = _thermal_mass_parameter_kj_per_m2_k(_epc("House")) + + # Assert — park home → low-mass 100; system built (code 8) → masonry 250. + assert abs(tmp_park_home - 100.0) <= 1e-9 + assert abs(tmp_system_built - 250.0) <= 1e-9 + + def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> None: # Arrange — heat-network main heating (Table 4a code 301 = community # heating with CHP/boilers; main_heating_category=6). Cert age band @@ -231,6 +277,485 @@ def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005) +def test_heat_network_flat_rate_charging_applies_table_4c3_space_factor() -> None: + # Arrange — community heat-network main (Table 4a code 301, cat 6), age + # band E (Table 12c DLF = 1.41). Control 2307 = "Flat rate charging, + # TRVs". SAP 10.2 Table 4c(3) (PDF p.169) multiplies the heat-network + # heat requirement by 1.05 for flat-rate charging (worksheet (305) → + # (307) = (98c) × (302) × (305) × (306)). Flat-rate billing gives no + # incentive to economise, so demand rises. The factor folds into the + # delivered-per-fuel efficiency alongside the DLF: 1 / (DLF × 1.05). + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2307, # Flat rate charging, TRVs + main_heating_category=6, + sap_main_heating_code=301, + ) + part = make_building_part(construction_age_band="E") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — efficiency = 1 / (DLF 1.41 × Table 4c(3) factor 1.05). + assert abs(inputs.main_heating_efficiency - 1.0 / (1.41 * 1.05)) <= 1e-9 + + +def test_heat_network_charging_linked_to_use_has_no_table_4c3_penalty() -> None: + # Arrange — same community heat-network main, but control 2306 = + # "Charging system linked to use of heating, programmer and TRVs". SAP + # 10.2 Table 4c(3) (PDF p.169) gives factor 1.0 (charges track usage, so + # no demand penalty), leaving the efficiency at the bare 1/DLF — i.e. the + # 2307 flat-rate penalty above must NOT fire here. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, # Charging linked to use, programmer + TRVs + main_heating_category=6, + sap_main_heating_code=301, + ) + part = make_building_part(construction_age_band="E") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — efficiency = 1 / DLF 1.41, no Table 4c(3) multiplier. + assert abs(inputs.main_heating_efficiency - 1.0 / 1.41) <= 1e-9 + + +def test_heat_network_dlf_uses_first_non_empty_building_part_age_band() -> None: + # Arrange — the GOV.UK API lodges a junk empty leading building part + # (all fields absent) before the real Main Dwelling. The dwelling age + # band (here A, 1.20 DLF per SAP 10.2 Table 12c) must be read from the + # first NON-empty part, not `sap_building_parts[0]` — otherwise the + # heat-network DLF defaults to the K-or-newer 1.50, inflating the + # distribution loss by 30%. Reproduces cert 8536-0929-6500-0815-7206. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + empty_leading_part = make_building_part(construction_age_band="") + main_dwelling_part = make_building_part(construction_age_band="A") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[empty_leading_part, main_dwelling_part], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — age band A → Table 12c DLF = 1.20 → efficiency = 1/1.20, + # NOT the empty-band default DLF 1.50 (1/1.50 = 0.667). + assert abs(inputs.main_heating_efficiency - 1.0 / 1.20) <= 1e-9 + + +def test_heat_network_dhw_no_cylinder_applies_sap_10_2_hiu_default_store() -> None: + # Arrange — community heat-network main (Table 4a code 301, cat 6), + # DHW from the network (WHC 901), NO cylinder lodged. SAP 10.2 p.24 + # "Heat networks" (c): when neither a PCDB HIU nor a lodged cylinder + # applies, "a measured loss of 1.72 kWh/day should be used, corrected + # using Table 2b. This is equivalent to a cylinder of 110 litres and a + # factory insulation thickness of 50 mm". RdSAP 10 Table 29: heat- + # network DHW → cylinder thermostat assumed present → Table 2b temp + # factor 0.60. A heat network is NOT a combi boiler, so the Table 3a + # keep-hot combi loss must NOT apply. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + part = make_building_part(construction_age_band="A") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[main], water_heating_code=901, + ), + ) + + # Act — preprocess the HIU default store, then run the §4 cascade. + epc_with_store = _apply_heat_network_hiu_default_store(epc) + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc_with_store, + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="A", + pcdb_record=None, + ) + + # Assert — combi keep-hot loss suppressed; storage loss = the SAP p.24 + # default 1.72 kWh/day × Table 2b 0.60 × 365 ≈ 376.7 kWh/yr. + assert wh_result is not None + assert sum(wh_result.combi_loss_monthly_kwh) == 0.0 + expected_storage_kwh = 1.72 * 0.60 * 365.0 + assert abs(sum(wh_result.solar_storage_monthly_kwh) - expected_storage_kwh) <= 1.0 + + +def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None: + # Arrange — heat-network DHW (Table 4a code 301, cat 6, WHC 901) with + # the SAP p.24 HIU default store applied. SAP 10.2 Table 3 (PDF p.160) + # verbatim: "For heat networks apply the formula above with p = 1.0 and + # h = 3 for all months." So the primary loss is independent of the + # cylinder-thermostat / separately-timed hours (which would give h=5/3) + # and equals Σ n_m × 14 × (0.0091×1.0×3 + 0.0263) = 273.9 kWh/yr. + # Community biomass fuel (31) collides with electricity code 31, so + # `_separately_timed_dhw` would route to h=5/3 — but the Table 3 heat- + # network rule must override that to h=3 regardless of the DHW fuel. + # This reproduces cert 8536-0929-6500-0815-7206. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=31, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + part = make_building_part(construction_age_band="A") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_code=901, + water_heating_fuel=31, + ), + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=_apply_heat_network_hiu_default_store(epc), + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="A", + pcdb_record=None, + ) + + # Assert — p=1.0, h=3 all months → 365 × 14 × (0.0091×3 + 0.0263). + assert wh_result is not None + expected_primary_kwh = 365.0 * 14.0 * (0.0091 * 3.0 + 0.0263) + assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6 + + +def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None: + # Arrange — SAP 10.2 §9.4.9 (PDF p.32): "A cylinder thermostat should be + # assumed to be present when the domestic hot water is obtained from a + # heat network, an immersion heater, a thermal store, a combi boiler or + # a CPSU." A direct-acting electric boiler (SAP code 191) heats the + # cylinder by electric resistance (immersion-equivalent) → assumed + # present even with no lodged cylinder thermostat. Reproduces cert + # 2474-3059-4202-4496-3200 (Summary path: WHC 901, main SAP 191). + direct_electric = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # standard electricity + heat_emitter_type=0, + emitter_temperature="NA", + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=191, + ) + part = make_building_part(construction_age_band="D") + epc_191 = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[direct_electric], + water_heating_code=901, # from main, no lodged cylinder stat + ), + ) + # A gas boiler is NOT in the §9.4.9 list — its cylinder keeps the + # absent-thermostat default (Table 2b ×1.3) when none is lodged. + gas_boiler = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, + ) + epc_gas = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_boiler], water_heating_code=901, + ), + ) + + # Act / Assert — 191 electric DHW assumes present; gas boiler does not. + assert _cylinder_thermostat_present(epc_191, direct_electric) is True + assert _cylinder_thermostat_present(epc_gas, gas_boiler) is False + + +def _cylinder_epc( + *, cylinder_size: int, cylinder_volume_measured_l: Optional[int] = None, + immersion_heating_type: Optional[int] = None, main: Optional[MainHeatingDetail] = None, +) -> EpcPropertyData: + """A minimal cylinder-bearing epc for Table 28 size-code resolution.""" + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, + country_code="ENG", has_hot_water_cylinder=True, + sap_building_parts=[make_building_part(construction_age_band="D")], + sap_heating=make_sap_heating( + main_heating_details=[main] if main is not None else None, + cylinder_size=cylinder_size, + cylinder_volume_measured_l=cylinder_volume_measured_l, + immersion_heating_type=immersion_heating_type, + ), + ) + + +def test_cylinder_size_exact_code_6_uses_lodged_measured_volume() -> None: + # Arrange — RdSAP 10 §10.5 Table 28 (PDF p.55): the "Exact" cylinder-size + # descriptor (gov-API code 6) lodges the measured volume in litres via + # `cylinder_size_measured`; SAP uses the actual size when present. The + # map previously held only codes 2/3/4 → code 6 fell through to None, + # skipping the Table-13 high-rate fraction (20 certs in the API sample). + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage] + ) + epc = _cylinder_epc(cylinder_size=6, cylinder_volume_measured_l=150) + + # Act + volume_l = _hot_water_cylinder_volume_l(epc) + + # Assert — the lodged 150 L is used verbatim, not a descriptor default. + assert volume_l is not None and abs(volume_l - 150.0) <= 1e-9 + + +def test_cylinder_present_but_size_not_determined_defaults_to_normal_110l() -> None: + # Arrange — RdSAP 10 §10.5 (PDF p.55): "If the actual size is not + # determined, the size of a hot-water cylinder is taken as according to + # Table 28." A cylinder IS present but no size descriptor resolves (gov + # API lodges `cylinder_size=0`) → the Table 28 baseline "Normal" default + # of 110 L, NOT None (which silently dropped the cylinder's storage loss + # AND the Table 13 high-rate fraction, over-rating the dwelling). + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage] + ) + epc = _cylinder_epc(cylinder_size=0) + + # Act + volume_l = _hot_water_cylinder_volume_l(epc) + + # Assert + assert volume_l is not None and abs(volume_l - 110.0) <= 1e-9 + + +def test_cylinder_size_inaccessible_code_5_solid_fuel_boiler_uses_160l() -> None: + # Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5) + # heated "from a solid fuel boiler" uses 160 litres. + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage] + ) + solid_fuel = MainHeatingDetail( + has_fghrs=False, main_fuel_type=5, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=1, sap_main_heating_code=153, # solid-fuel boiler + ) + epc = _cylinder_epc(cylinder_size=5, main=solid_fuel) + + # Act + volume_l = _hot_water_cylinder_volume_l(epc) + + # Assert + assert volume_l is not None and abs(volume_l - 160.0) <= 1e-9 + + +def test_cylinder_size_inaccessible_code_5_otherwise_uses_110l() -> None: + # Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5) + # that is neither off-peak dual immersion nor solid-fuel uses 110 litres. + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage] + ) + gas_boiler = MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + epc = _cylinder_epc(cylinder_size=5, main=gas_boiler) + + # Act + volume_l = _hot_water_cylinder_volume_l(epc) + + # Assert + assert volume_l is not None and abs(volume_l - 110.0) <= 1e-9 + + +def test_cylinder_size_inaccessible_code_5_off_peak_dual_immersion_uses_210l() -> None: + # Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5) + # heated by an off-peak electric DUAL immersion (immersion_heating_type=1) + # uses 210 litres. + from dataclasses import replace + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage] + ) + base = _cylinder_epc(cylinder_size=5, immersion_heating_type=1) # 1 = dual + # Off-peak (dual / Economy-7) meter, not the fixture's "Single" default. + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), + ) + + # Act + volume_l = _hot_water_cylinder_volume_l(epc) + + # Assert + assert volume_l is not None and abs(volume_l - 210.0) <= 1e-9 + + +def test_integrated_storage_heater_408_bills_table_12a_grid1_high_rate_fraction() -> None: + # Arrange — electric storage heaters (cat 7), SAP code 408, on an + # off-peak 7-hour (dual / Economy-7) meter. SAP 10.2 Table 12a Grid 1 + # (PDF p.191): code 408 is an "Integrated (storage + direct-acting) + # system" with a 0.20 space-heating high-rate fraction at 7-hour (NOT + # the 0.00 of "other storage heaters"). The scalar SH rate is therefore + # the blend 0.20 × high (15.29) + 0.80 × low (5.50) = 7.458 p/kWh. + from dataclasses import replace + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2401, + main_heating_category=7, # electric storage heaters + sap_main_heating_code=408, + ) + base = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band="E")], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — blended scalar rate 0.20×15.29 + 0.80×5.50 = 7.458 p/kWh. + assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.07458) <= 1e-9 + + +def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None: + # Arrange — same off-peak storage cert but SAP code 401 ("other storage + # heaters"): Table 12a Grid 1 gives a 0.00 high-rate fraction → the heat + # is charged wholly at the 7-hour low rate (5.50 p/kWh). Guards that the + # 408 integrated-system 0.20 fraction does NOT leak to other codes. + from dataclasses import replace + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2401, + main_heating_category=7, + sap_main_heating_code=401, + ) + base = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band="E")], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — 100% low rate, 5.50 p/kWh. + assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.0550) <= 1e-9 + + +def test_no_system_electric_heaters_assumed_code_699_bills_direct_acting_split() -> None: + # Arrange — "No system present: electric heaters assumed" lodges SAP + # Table 4a code 699 (electric room heaters) but RdSAP main_heating_ + # category 1, NOT 10, on a Dual (Economy-7) meter. RdSAP 10 §12 Rule 3 + # (PDF p.62) routes electric room heaters (691-694, 699) to the 10-hour + # tariff, and SAP 10.2 Table 12a Grid 1 (PDF p.191) gives the "other + # direct-acting electric" row a 0.50 high-rate fraction at 10-hour. + # The scalar SH rate is therefore the blend 0.50 × high (14.68) + 0.50 + # × low (7.50) = 11.09 p/kWh — NOT the 7.50 p/kWh of 100% off-peak that + # the category-10-only gate produced when it missed this category-1 + # lodging. + from dataclasses import replace + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2699, + main_heating_category=1, # NOT 10 — the "electric heaters assumed" form + sap_main_heating_code=699, + ) + base = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band="E")], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — blended 0.50×14.68 + 0.50×7.50 = 11.09 p/kWh. + assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.1109) <= 1e-9 + + def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: # Arrange — heat-network main (Table 4a code 301 = community heating, # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution @@ -395,15 +920,13 @@ def test_heat_network_distribution_electricity_none_for_individual_main() -> Non def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: # Arrange — when main heating is a heat network AND water heating - # inherits from main (water_heating_code=901), the HW also incurs - # the network's distribution losses. The water efficiency must be - # overridden to 1/DLF so that the delivered HW kWh (and therefore - # cost/CO2/PE applied to it) reflects q_useful × DLF. - # Compare against a gas-boiler baseline at the same age band: the - # heat-network HW kWh should be greater by the ratio 0.80/(1/DLF) = - # DLF × 0.80 = 0.80 × 1.41 = 1.128 (i.e. ~13% higher) since the - # non-heat-network baseline inherits water efficiency 0.80 from - # the heat-network main's pre-DLF efficiency. + # inherits from main (water_heating_code=901), the HW also incurs the + # network's distribution losses: the water efficiency is overridden to + # 1/DLF, so the delivered HW FUEL kWh = q_useful (the §4 output) × DLF. + # Asserted directly as fuel ÷ output to isolate the DLF scaling from + # the §4 loss structure (the SAP 10.2 p.24 HIU default store now + # supplies storage + primary loss in q_useful — see + # `_apply_heat_network_hiu_default_store`). part = make_building_part(construction_age_band="E") hn_main = MainHeatingDetail( @@ -426,9 +949,34 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: ), ) - # Comparable gas-boiler baseline that ALSO inherits a 0.80 water - # efficiency through `water_heating_code=901` for direct comparison. - # Use sap_main_heating_code = None so cascade returns 0.80 default. + # Act — HW fuel kWh from the full cascade, and the §4 output (q_useful) + # from the worksheet on the same (HIU-store-applied) epc. + hn_hw_fuel = cert_to_inputs(hn_epc).hot_water_kwh_per_yr + wh_result, _ = _water_heating_worksheet_and_gains( + epc=_apply_heat_network_hiu_default_store(hn_epc), + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="E", + pcdb_record=None, + ) + + # Assert — delivered HW fuel = q_useful × DLF; age E → Table 12c 1.41. + assert wh_result is not None + assert abs(hn_hw_fuel / wh_result.output_kwh_per_yr - 1.41) <= 1e-6 + + +def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: + # Arrange — water_heating_code 950 = "hot-water-only heat network + # (boilers)" (SAP 10.2 Table 4a HW section, plant eff 0.80). RdSAP 10 + # §10 (spec p.36) requires the Table 12c distribution loss applied on + # top of the plant efficiency for water-heating-only heat networks, so + # the delivered HW fuel = q_useful × DLF / 0.80. The DLF must fire on + # the WHC ALONE — independent of the main, which here is an ordinary + # gas boiler (NOT a heat network), mirroring cert 9093 (whc 950 + a + # warm-air main). Compare against a non-heat-network baseline at the + # same 0.80 water efficiency (whc 901 from the same gas main): the 950 + # HW fuel must exceed it by exactly the DLF (age E → 1.41). + part = make_building_part(construction_age_band="E") gas_main = MainHeatingDetail( has_fghrs=False, main_fuel_type=26, @@ -437,7 +985,18 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: main_heating_control=2106, main_heating_category=2, ) - gas_epc = make_minimal_sap10_epc( + hw_network_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_code=950, # hot-water-only heat network + ), + ) + # Baseline: HW from the same gas main (eff 0.80), no heat network → no DLF. + baseline_epc = make_minimal_sap10_epc( total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, country_code="ENG", @@ -449,11 +1008,11 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: ) # Act - hn_hw = cert_to_inputs(hn_epc).hot_water_kwh_per_yr - gas_hw = cert_to_inputs(gas_epc).hot_water_kwh_per_yr + hw_network: float = cert_to_inputs(hw_network_epc).hot_water_kwh_per_yr + baseline: float = cert_to_inputs(baseline_epc).hot_water_kwh_per_yr - # Assert — DLF (1.41) for age E × 0.80 baseline / (1/1.41) HN = 1.128. - assert hn_hw / gas_hw == pytest.approx(1.41 * 0.80 / 1.0, abs=0.02) + # Assert — the HW-only-heat-network fuel is scaled up by the age-E DLF. + assert abs(hw_network / baseline - 1.41) <= 0.02 def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: @@ -522,6 +1081,27 @@ def test_main_heating_fraction_does_not_override_table11_secondary_default() -> assert inputs.secondary_heating_fraction == pytest.approx(0.1, abs=0.001) +def test_secondary_fraction_fires_when_secondary_lodged_via_description_only() -> None: + # Arrange — SAP 10.2 Table 11 / RdSAP §A.2.2: a gas boiler main (cat 2, + # not in the §A.2.2 forced-secondary set) whose cert lodges NO integer + # `secondary_heating_type` but DOES carry a secondary DESCRIPTION (the + # gov-API path surfaces the secondary only as a description, e.g. + # "Portable electric heaters (assumed)") must cost the secondary at its + # Table 11 0.10 fraction. Previously this returned 0.0 — the secondary + # was dropped (sec_kWh=0) → a clean systematic SAP over-rate (+2.7 med). + from domain.sap10_calculator.rdsap.cert_to_inputs import _secondary_fraction # pyright: ignore[reportPrivateUsage] + + main = _gas_boiler_detail() # cat 2, code 102 — not forced-secondary + + # Act + no_secondary = _secondary_fraction(main, None, secondary_lodged=False) + description_lodged = _secondary_fraction(main, None, secondary_lodged=True) + + # Assert — a description-only lodged secondary fires the 0.10 fraction. + assert no_secondary == 0.0 + assert abs(description_lodged - 0.10) <= 1e-9 + + def test_main_heating_fraction_missing_falls_back_to_table11_default() -> None: # Arrange — when main_heating_fraction isn't lodged AND the cert # has a secondary system lodged, Table 11's 0.10 default still @@ -687,6 +1267,38 @@ def test_pv_export_credit_input_reports_rdsap10_table_32_rate() -> None: assert abs(inputs.pv_export_credit_gbp_per_kwh - 0.1319) <= 1e-6 +def test_pv_not_export_capable_zeroes_exported_kwh() -> None: + # Arrange — two identical PV dwellings (same array), one connected to an + # export-capable meter and one not. SAP 10.2 Appendix M1 (PDF p.94): + # "EPV,ex,m = 0 if the PV system is not connected to an export-capable + # meter." A non-export-capable dwelling earns no export — only the + # onsite β consumption offsets demand — so its exported kWh must be 0, + # while the export-capable twin exports a positive amount. The onsite + # (dwelling) portion is identical for both. + capable = _typical_semi_detached_epc() + capable.sap_energy_source.photovoltaic_arrays = [ + PhotovoltaicArray(peak_power=3.0, pitch=2, orientation=5, overshading=1), + ] + capable.sap_energy_source.is_dwelling_export_capable = True + not_capable = _typical_semi_detached_epc() + not_capable.sap_energy_source.photovoltaic_arrays = [ + PhotovoltaicArray(peak_power=3.0, pitch=2, orientation=5, overshading=1), + ] + not_capable.sap_energy_source.is_dwelling_export_capable = False + + # Act + cap_inputs = cert_to_inputs(capable) + nocap_inputs = cert_to_inputs(not_capable) + + # Assert — exported kWh zeroed when not export-capable; onsite unchanged. + assert (cap_inputs.pv_exported_kwh_per_yr or 0.0) > 0.0 + assert (nocap_inputs.pv_exported_kwh_per_yr or 0.0) == 0.0 + assert abs( + (cap_inputs.pv_dwelling_kwh_per_yr or 0.0) + - (nocap_inputs.pv_dwelling_kwh_per_yr or 0.0) + ) <= 1e-9 + + def test_pv_generation_differentiates_arrays_by_orientation() -> None: # Arrange — two single-array PVs at the same kWp / pitch / overshading, # one facing South and one facing North. Per SAP10.2 Appendix M @@ -1009,6 +1621,95 @@ def test_ventilation_from_cert_routes_mev_decentralised_to_extract_or_piv_outsid assert abs(w25 - expected) <= 1e-4 +def test_corridor_flat_assumes_draught_lobby_present_zeroing_line_13() -> None: + # Arrange — RdSAP 10 Specification (10-06-2025) p.30, "Draught lobby": + # "add infiltration 0.05 if draught lobby is not present, or use 0.0 if + # present. ... Flat or maisonette: Assume draught lobby if entrance door + # is facing corridor (heated or unheated) or stairwell." A SHELTERED + # alternative wall is the RdSAP §5.9 wall-to-unheated-corridor surface — + # the same evidence the corridor door rides on — so the flat's entrance + # faces a corridor and a draught lobby is assumed present, zeroing line + # (13). Simulated case 34 (cert 001431 storage flat): the cascade + # previously added the 0.05 no-lobby penalty, over-counting (16)/(18) by + # 0.05 ACH → +46 kWh/yr space demand → SAP −0.18. + from dataclasses import replace + + corridor_part = replace( + make_building_part(construction_age_band="G"), + sap_alternative_wall_1=SapAlternativeWall( + wall_area=12.5, wall_dry_lined="N", wall_construction=4, + wall_insulation_type=4, wall_thickness_measured="Y", + wall_insulation_thickness="NI", is_sheltered=True, + ), + ) + exposed_part = make_building_part(construction_age_band="G") + corridor_flat = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG", + sap_building_parts=[corridor_part], + ) + exposed_flat = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG", + sap_building_parts=[exposed_part], + ) + + # Act + v_corridor = ventilation_from_cert(corridor_flat) + v_exposed = ventilation_from_cert(exposed_flat) + + # Assert — the corridor flat zeroes (13); a flat with no sheltered + # corridor wall keeps the 0.05 no-lobby penalty (cannot be determined). + assert abs(v_corridor.draught_lobby_ach - 0.0) <= 1e-9 + assert abs(v_exposed.draught_lobby_ach - 0.05) <= 1e-9 + # The lobby removes 0.05 ACH from (16); shelter (21) drops proportionally. + assert v_corridor.infiltration_rate_ach < v_exposed.infiltration_rate_ach + assert abs( + (v_exposed.infiltration_rate_ach - v_corridor.infiltration_rate_ach) - 0.05 + ) <= 1e-9 + + +def test_index_less_mev_uses_table_4g_default_sfp_for_fan_electricity() -> None: + # Arrange — an MEV system with NO PCDB record (index absent / not in + # Table 322). SAP 10.2 §2.6.3 / Table 4g gives a default specific fan + # power of 0.8 W/(l/s); Table 4g note 3 (PDF p.176) requires multiplying + # it by the default-data in-use factor (Table 329 system_type 10, IUF + # 2.5) before use as the SFPav in the §5 Table 4f line (230a) + # `SFPav × 1.22 × V`. So the effective SFPav is 0.8 × 2.5 = 2.0. A + # natural dwelling contributes nothing. + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage] + _mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] + dimensions_from_cert, + ) + + base = _typical_semi_detached_epc() + mev_no_index = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, + region_code="1", sap_building_parts=base.sap_building_parts, + sap_windows=base.sap_windows, sap_heating=base.sap_heating, + sap_ventilation=SapVentilation( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ), + ) + natural = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4, + region_code="1", sap_building_parts=base.sap_building_parts, + sap_windows=base.sap_windows, sap_heating=base.sap_heating, + sap_ventilation=SapVentilation(extract_fans_count=0), + ) + + # Act + mev_kwh = _mev_decentralised_kwh_per_yr_from_cert(mev_no_index) + natural_kwh = _mev_decentralised_kwh_per_yr_from_cert(natural) + + # Assert — IUF-adjusted SFPav (0.8 × 2.5) × 1.22 × V for the index-less + # MEV; zero for the naturally-ventilated dwelling. + volume = dimensions_from_cert(mev_no_index).volume_m3 + expected = _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * 2.5 * 1.22 * volume + assert abs(mev_kwh - expected) <= 1e-6 + assert mev_kwh > 0.0 + assert natural_kwh == 0.0 + + 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 @@ -1418,8 +2119,14 @@ def test_is_electric_main_dual_fuel_table_32_code_10_is_not_electric() -> None: # Act / Assert — dual fuel (Table 32 10) must NOT be electric assert _is_electric_main(dual_fuel_main) is False - # Sanity — Table 32 electric codes 30-40 still classify as electric - for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60): + # Sanity — Table 32 electric codes still classify as electric. Code 33 + # is EXCLUDED here: as a lodged *main fuel-type* the gov-API enum 33 + # means COAL (description-vs-code audit), which `_main_fuel_code` + # canonicalises to House coal (Table code 11) before `_is_electric_main` + # — so a main fuel-type 33 is NOT electric. The Table 32 electricity-10h + # code 33 is only ever used internally for the dual-rate tariff split + # (never as a main fuel-type), so it is unaffected. + for t32_electric in (30, 31, 32, 34, 35, 38, 40, 60): electric_main = MainHeatingDetail( has_fghrs=False, main_fuel_type=t32_electric, heat_emitter_type=1, emitter_temperature=1, main_heating_control=2105, @@ -1468,6 +2175,165 @@ def test_is_electric_water_dual_fuel_table_32_code_10_is_not_electric() -> None: ) +def test_solid_fuel_main_prices_at_table_32_solid_rate_not_colliding_code() -> None: + # Arrange — the gov-API `main_fuel_type` enum (confirmed by the + # description-vs-code audit on `main_heating[].description`) carries + # 5 = anthracite and 33 = coal. Both COLLIDE with a same-valued Table + # 32 code of a different fuel: code 5 = bulk LPG secondary (12.19 p), + # code 33 = electricity 10-hour low rate (7.5 p). The shared price + # lookup checks the Table-32 dict first, so without normalisation an + # anthracite main billed at 12.19 p and a coal main at 7.5 p — driving + # the cohort's worst cert (2100 anthracite, -61 SAP). `_main_fuel_code` + # now canonicalises the colliding gov-API enum to its Table 32 code at + # the fuel-TYPE boundary (5 -> 15 anthracite 3.64 p; 33 -> 11 house + # coal 3.67 p) — and the canonical coal code is no longer mis-flagged + # electric. The electricity-10h TARIFF code 33 (dual-rate split) is + # untouched because it never enters as a main fuel-type. + from domain.sap10_calculator.tables.table_12a import Tariff + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _main_fuel_code, # pyright: ignore[reportPrivateUsage] + ) + anthracite_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=5, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=2, sap_main_heating_code=158, + ) + coal_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=33, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=2, sap_main_heating_code=158, + ) + + # Act + anthracite_code = _main_fuel_code(anthracite_main) + coal_code = _main_fuel_code(coal_main) + anthracite_rate = _space_heating_fuel_cost_gbp_per_kwh( + anthracite_main, Tariff.STANDARD, SAP_10_2_SPEC_PRICES + ) + coal_rate = _space_heating_fuel_cost_gbp_per_kwh( + coal_main, Tariff.STANDARD, SAP_10_2_SPEC_PRICES + ) + + # Assert — canonical codes + solid-fuel rates (not 12.19 p / 7.5 p), + # and coal is no longer classed as electric. + assert anthracite_code == 15 + assert coal_code == 11 + assert abs(anthracite_rate - 0.0364) <= 1e-6 + assert abs(coal_rate - 0.0367) <= 1e-6 + assert _is_electric_main(coal_main) is False + + +def test_heat_network_biomass_community_fuel_resolves_to_table12_community_code() -> None: + # Arrange — a heat-network main (SAP Table 4a code 301 = community + # heating; main_heating_category=6) lodging gov-API `main_fuel_type` + # 31 = "biomass (community)" per epc_codes.csv. The enum value 31 + # COLLIDES with the Table-32 7-hour-low-rate electricity code 31, so + # without the gated translation `is_electric_fuel_code(31)` flags this + # community-scheme main as electric and `_is_electric_main` routes its + # cost through the off-peak electricity branch — bypassing the + # heat-network rate (cert 8536 biomass-community, -17.2 SAP). Per + # RdSAP 10 §C / SAP 10.2 Table 12 the community biomass row is code 43 + # (the same row the backwards-compat enum 12 maps to). + biomass_community_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=31, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2306, + main_heating_category=6, sap_main_heating_code=301, + ) + + # Act + fuel_code = _main_fuel_code(biomass_community_main) + factor_code = _heat_network_factor_fuel_code(biomass_community_main) + + # Assert — community biomass Table-12 row, NOT electricity, and the + # main is no longer mis-classified as electric. + assert fuel_code == 43 + assert factor_code == 43 + assert _is_electric_main(biomass_community_main) is False + + +def test_non_heat_network_electricity_code_30_is_not_remapped_to_community() -> None: + # Arrange — the colliding community translation must be GATED on + # heat-network context: the cascade writes the bare Table-32 code 30 + # (`_STANDARD_ELECTRICITY_FUEL_CODE`) as genuine grid electricity on + # non-community certs (e.g. the RdSAP no-water-heating immersion + # default — cert 2211). A non-heat-network main carrying code 30 must + # stay electric, NOT be remapped to community waste (Table-12 42). + electric_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=30, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=191, + ) + + # Act + fuel_code = _main_fuel_code(electric_main) + + # Assert — unchanged grid-electricity code, still electric. + assert fuel_code == 30 + assert _is_electric_main(electric_main) is True + + +def test_heat_network_unmapped_community_collision_fuel_raises() -> None: + # Arrange — a heat-network main lodging a colliding community fuel the + # translation table does NOT cover must raise rather than silently + # mis-price it as the same-numbered grid-electricity code (the + # strict-raise principle — a community fuel that "falls through" is a + # mapping gap to surface, not a default to swallow). Simulate the gap + # by removing biomass (31) from the translation table. + from unittest.mock import patch + + import domain.sap10_calculator.rdsap.cert_to_inputs as cti + + main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=31, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2306, + main_heating_category=6, sap_main_heating_code=301, + ) + patched = dict(cti.API_FUEL_TO_TABLE_12) + del patched[31] + + # Act / Assert — the gap surfaces loudly instead of resolving to the + # colliding electricity code 31. + with patch.object(cti, "API_FUEL_TO_TABLE_12", patched): + with pytest.raises(UnmappedSapCode): + _heat_network_community_fuel_code(31, main) + + +def test_table_12_factor_fuel_unmapped_code_raises() -> None: + # Arrange — a fuel code that is neither translatable via + # API_FUEL_TO_TABLE_12 nor already a recognised Table-12/32 fuel code. + # 998 is a deliberately out-of-range sentinel for "novel/unknown fuel". + unmapped_fuel: Final[int] = 998 + + # Act / Assert — the gap surfaces loudly instead of passing the code + # through to the silent mains-gas default in unit_price_p_per_kwh / + # co2_factor_kg_per_kwh / primary_energy_factor (the cert-8536 + # community-collision class). Strict-raise per [[reference-unmapped-sap-code]]. + with pytest.raises(UnmappedSapCode) as excinfo: + _table_12_factor_fuel_code(unmapped_fuel) + + # Assert — the raised error names the field and the offending value. + assert excinfo.value.field == "table_12_factor_fuel" + assert excinfo.value.value == unmapped_fuel + + +def test_table_12_factor_fuel_recognised_codes_preserve_get_semantics() -> None: + # Arrange — recognised inputs must behave EXACTLY like the prior + # `API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough: a gov-API enum is + # translated (26 mains-gas-not-community -> Table-12 code 1); a code + # already in the Table-12 numbering passes through unchanged (51 = heat- + # network mains gas). + api_enum_mains_gas: Final[int] = 26 + table_12_heat_network_gas: Final[int] = 51 + + # Act + translated: int = _table_12_factor_fuel_code(api_enum_mains_gas) + passthrough: int = _table_12_factor_fuel_code(table_12_heat_network_gas) + + # Assert — identical to the old .get(fuel, fuel) results, no behaviour drift. + assert translated == 1 + assert passthrough == 51 + + def test_responsiveness_solid_fuel_sap_code_160_returns_0p50_per_table_4a() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"): # @@ -2563,6 +3429,45 @@ def test_api_type_1_gable_kind_maps_sheltered_and_connected_codes() -> None: assert _api_type_1_gable_kind(3) == "connected_wall" +def test_api_mechanical_ventilation_maps_extract_systems_to_cascade_kind() -> None: + # Arrange — RdSAP-Schema-21 `mechanical_ventilation` enum → the SAP + # 10.2 §2 (24a..d) MechanicalVentilationKind dispatch. The mapper + # previously dropped this field on the API path, so every mechanical + # system defaulted to NATURAL and under-stated its ventilation heat + # loss (the MEV-dc cohort, code 2, over-rated +1.90 SAP, n=20). + from datatypes.epc.domain.mapper import ( + _api_mechanical_ventilation_kind, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert — extract / positive-input-from-outside systems carry + # the (24c) kind; MV-no-HR carries (24b); natural and PIV-from-loft + # stay NATURAL; MVHR (4) is deferred (efficiency not yet plumbed) and + # stays NATURAL rather than mis-modelling as MV. + assert _api_mechanical_ventilation_kind(0) is None # natural + assert _api_mechanical_ventilation_kind(1) == "MV" # MV, no HR + assert _api_mechanical_ventilation_kind(2) == "EXTRACT_OR_PIV_OUTSIDE" # MEV dc + assert _api_mechanical_ventilation_kind(3) == "EXTRACT_OR_PIV_OUTSIDE" # MEV c + assert _api_mechanical_ventilation_kind(4) is None # MVHR (deferred) + assert _api_mechanical_ventilation_kind(5) is None # PIV from loft + assert _api_mechanical_ventilation_kind(6) == "EXTRACT_OR_PIV_OUTSIDE" # PIV outside + assert _api_mechanical_ventilation_kind(None) is None + + +def test_api_mechanical_ventilation_unmapped_code_raises() -> None: + # Arrange — an out-of-range `mechanical_ventilation` integer is a + # spec-coverage gap: raise rather than silently default to NATURAL + # (which would under-state ventilation heat loss). Mirror of the + # `_api_sheltered_sides` / `_api_type_1_gable_kind` strict-raise. + from datatypes.epc.domain.mapper import ( + UnmappedApiCode, # pyright: ignore[reportPrivateUsage] + _api_mechanical_ventilation_kind, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + with pytest.raises(UnmappedApiCode): + _api_mechanical_ventilation_kind(7) + + def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None: # Arrange — a Detailed (§3.10) assessment DOES measure slope / flat # ceiling, so they must be retained (regression guard so the @@ -3250,8 +4155,9 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None: # 10-hour. `_hot_water_fuel_cost_gbp_per_kwh` previously billed any # electric off-peak HW at 100% low rate (its TODO), over-crediting the # HP-DHW cat-4 cluster. Electric IMMERSION (WHC 903) is a different - # Table 12a row (off-peak immersion 0.17 / Table 13) and must stay on - # the 100%-low-rate fallback here. + # Table 12a row (Table 13) — without the cylinder volume / occupancy / + # immersion-type inputs (not passed here) it falls back to the + # 100%-low-rate scalar; the Table 13 blend is locked separately below. from domain.sap10_calculator.tables.table_12a import Tariff from domain.sap10_calculator.rdsap.cert_to_inputs import ( _hot_water_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] @@ -3284,6 +4190,56 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None: assert abs(rate_immersion - 0.0750) <= 1e-6 +def test_hot_water_immersion_off_peak_bills_at_table_13_blend() -> None: + # Arrange — SAP 10.2 Table 12a (PDF p.191) "Immersion water heater" + # row routes the WH column to Table 13 (PDF p.197). For an electric + # immersion (WHC 903) on an off-peak tariff with a known cylinder + # volume + occupancy + immersion type, the HW bills at the Table 13 + # high-rate fraction blend, NOT 100% at the off-peak low rate. A dual + # immersion (small fraction) bills only a little above the low rate; a + # single immersion (large fraction) bills much closer to the high rate. + # Pre-slice both billed 100% at the 7-hour low rate 5.50 p (£0.0550), + # under-costing the dwelling and over-rating it (median +0.98 SAP + # across the off-peak WHC-903 API cohort). + from domain.sap10_calculator.tables.table_12a import Tariff + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + ) + from domain.sap10_calculator.tables.table_13 import ( + electric_dhw_high_rate_fraction, + ) + high_p, low_p = 15.29, 5.50 # Table 32 codes 32 / 31 (7-hour) + n_occupants = 2.7395 # Appendix J Table 1b N at 100 m² + + # Act — 110 L cylinder, occupancy N(100), dual (False) vs single (True). + rate_dual = _hot_water_fuel_cost_gbp_per_kwh( + 29, None, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES, + water_heating_code=903, cylinder_volume_l=110.0, + occupancy_n=n_occupants, immersion_single=False, + ) + rate_single = _hot_water_fuel_cost_gbp_per_kwh( + 29, None, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES, + water_heating_code=903, cylinder_volume_l=110.0, + occupancy_n=n_occupants, immersion_single=True, + ) + + # Assert — each rate equals its Table 13 blend; single > dual; both + # strictly above the 100%-low fallback (5.50 p) it replaces. + frac_dual = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=n_occupants, + single_immersion=False, tariff=Tariff.SEVEN_HOUR, + ) + frac_single = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=n_occupants, + single_immersion=True, tariff=Tariff.SEVEN_HOUR, + ) + expected_dual = (frac_dual * high_p + (1 - frac_dual) * low_p) / 100 + expected_single = (frac_single * high_p + (1 - frac_single) * low_p) / 100 + assert abs(rate_dual - expected_dual) <= 1e-9 + assert abs(rate_single - expected_single) <= 1e-9 + assert rate_single > rate_dual > 0.0550 + + def test_space_heating_pcdb_heat_pump_without_sap_code_bills_at_app_n_high_rate() -> None: # Arrange — an API-path heat pump resolves via its PCDB Table 362 # index alone (data_source=1, no Table-4a SAP code lodged), so @@ -4720,6 +5676,44 @@ def test_table_4c_no_boiler_interlock_applies_minus_5_dhw_adjustment_when_cylind ) +def test_controls_2107_2111_count_as_no_room_thermostat_per_sap_9_4_11() -> None: + # Arrange — SAP 10.2 §9.4.11 (PDF p.66): a boiler with no room + # thermostat (or an equivalent device — flow switch / boiler energy + # manager) has no interlock; "TRVs alone ... do not perform the boiler + # interlock function" and a fixed bypass exists precisely to keep water + # circulating when the TRVs close. Control 2107 ("Programmer, TRVs and + # bypass") and 2111 ("TRVs and bypass") therefore carry the same + # no-room-thermostat treatment as 2101/2102 — including the Table 4f + # footnote a) ×1.3 circulation-pump uplift — which they previously + # missed (the set held only 2101/2102). Control 2106 ("Programmer, room + # thermostat and TRVs") HAS a room thermostat → interlock → no uplift. + # A 2013+ wet gas boiler pumps 41 kWh (Table 4f); ×1.3 = 53.3. + import dataclasses + + from domain.sap10_calculator.rdsap.cert_to_inputs import _table_4f_circulation_pump_kwh # pyright: ignore[reportPrivateUsage] + + base = _gas_boiler_detail() # cat-2 wet boiler + no_stat_2107 = dataclasses.replace( + base, main_heating_control=2107, central_heating_pump_age=2, + ) + no_stat_2111 = dataclasses.replace( + base, main_heating_control=2111, central_heating_pump_age=2, + ) + with_stat_2106 = dataclasses.replace( + base, main_heating_control=2106, central_heating_pump_age=2, + ) + + # Act + pump_2107 = _table_4f_circulation_pump_kwh(no_stat_2107) # pyright: ignore[reportPrivateUsage] + pump_2111 = _table_4f_circulation_pump_kwh(no_stat_2111) # pyright: ignore[reportPrivateUsage] + pump_2106 = _table_4f_circulation_pump_kwh(with_stat_2106) # pyright: ignore[reportPrivateUsage] + + # Assert — bypass/TRVs-only controls get the ×1.3 uplift; 2106 does not. + assert abs(pump_2107 - 41.0 * 1.3) <= 1e-9 + assert abs(pump_2111 - 41.0 * 1.3) <= 1e-9 + assert abs(pump_2106 - 41.0) <= 1e-9 + + def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_main_is_gas_oil_boiler_with_cylinder_no_thermostat() -> None: """SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": @@ -6545,3 +7539,45 @@ def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> No # Act / Assert assert _heat_network_standing_charge_gbp(epc, main) is None + + +def test_mev_default_data_iuf_is_table_329_system_type_10_value() -> None: + # Arrange / Act — SAP 10.2 Table 4g note 3 (PDF p.176) directs the + # default SFP to "the appropriate in-use factor for default data from + # the PCDB" = Table 329 system_type 10, which lodges IUF 2.5 (identical + # across rigid/flexible/no-duct columns). + iuf = _mev_default_data_iuf() + + # Assert + assert abs(iuf - 2.5) <= 1e-9 + + +def test_index_less_mev_applies_table_4g_note_3_default_data_iuf() -> None: + # Arrange — an MEV (mechanical extract) dwelling with NO PCDB index. + # SAP 10.2 Table 4g gives the default SFP 0.8 W/(l/s), and note 3 + # requires multiplying it by the default-data in-use factor (2.5) before + # use as SFPav in the (230a) fan-electricity formula. Before this fix + # the raw 0.8 was used directly, under-billing fan electricity by 2.5x + # and over-rating the index-less MEV cohort by ~+1.3 SAP. + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part()], + sap_ventilation=SapVentilation( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE", + ), + ) + + # Act + fan_kwh = _mev_decentralised_kwh_per_yr_from_cert(epc) + volume_m3 = dimensions_from_cert(epc).volume_m3 + expected = mev_decentralised_kwh_per_yr( + sfp_av_w_per_l_per_s=_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S * 2.5, + dwelling_volume_m3=volume_m3, + ) + + # Assert — fan electricity follows the IUF-adjusted SFPav (2.0), i.e. + # 2.5x the raw-0.8 value, not the raw default. + assert fan_kwh > 0.0 + assert abs(fan_kwh - expected) <= 1e-9 diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 99dbad76..a01b25e8 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -224,8 +224,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0300-2747-7640-2526-2135", actual_sap=78, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+0.9264, - expected_co2_resid_tonnes_per_yr=+0.2495, + expected_pe_resid_kwh_per_m2=-0.1033, + expected_co2_resid_tonnes_per_yr=+0.1488, notes=( "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "(no Table 4b code). Cert lodges open_flues_count=1 + " @@ -245,7 +245,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "CO2 / PE factors through the cert's mains-gas " "`secondary_fuel_type` (mirroring the cost-side Slice 58 " "fix), closing PE +8.28 → +0.93 and CO2 −0.25 → +0.25 — " - "second-biggest cohort PE closure to date." + "second-biggest cohort PE closure to date. Slice S0380.xx " + "(SAP 10.2 Appendix J step 1a — Noutlets includes electric " + "showers) halved this cert's mixer-shower HW (1 mixer + 1 " + "electric → /2 not /1), removing the double-counted shower " + "demand: PE +0.93 → -0.10, CO2 +0.25 → +0.15 (both toward 0)." ), ), _GoldenExpectation( @@ -983,6 +987,31 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 +def test_api_mapper_preserves_mechanical_ventilation_pcdb_index_for_mev_fan_energy() -> None: + # Arrange — cert 1300 is a decentralised-MEV dwelling + # (mechanical_ventilation=2) lodging a PCDB index (500777) + duct type + # (1=Flexible). The API path previously DROPPED + # `mechanical_ventilation_index_number`, so the §5 Table 4f line (230a) + # MEV fan electricity (`SFPav × 1.22 × V`, PCDB Tables 322/329) + # short-circuited to 0 in `_mev_decentralised_kwh_per_yr_from_cert` — + # leaving the fan running cost off the bill (+0.9 SAP over-rate residual + # on the MEV cohort once the §2 ventilation heat loss was fixed). + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] + ) + + doc = _load_cert("1300-7634-0922-3203-3563") + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — the PCDB pointer + duct type survive the API mapping, and the + # (230a) MEV fan electricity resolves to a positive contribution. + assert epc.mechanical_ventilation_index_number == 500777 + assert epc.mechanical_vent_duct_type == 1 + assert _mev_decentralised_kwh_per_yr_from_cert(epc) > 0.0 + + def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None: """Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP API code for a roof window ("Roof of Room" rooflight / inclined diff --git a/tests/domain/sap10_calculator/test_table_13.py b/tests/domain/sap10_calculator/test_table_13.py new file mode 100644 index 00000000..1063fe15 --- /dev/null +++ b/tests/domain/sap10_calculator/test_table_13.py @@ -0,0 +1,110 @@ +"""SAP 10.2 Table 13 — high-rate fraction for electric DHW heating. + +Locks `electric_dhw_high_rate_fraction` against the published table grid +and the Note-2 clamp at +`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`, +page 197. Table 12a's "Immersion water heater" row (PDF p.191) routes +electric immersion DHW here. + +The helper evaluates the Note-2 equations, which the spec offers as an +exact alternative to the rounded grid — the pins below check that the +equations reproduce the published 2-dp cells. +""" +from __future__ import annotations + +from domain.sap10_calculator.tables.table_12a import Tariff +from domain.sap10_calculator.tables.table_13 import ( + electric_dhw_high_rate_fraction, +) + +# Appendix J Table 1b occupancy N at a few total floor areas (m²) — the +# anchor for the V/N grid cells below. Computed from the same piecewise +# formula the §4 worksheet uses (water_heating.assumed_occupancy). +_N_AT_TFA_100 = 2.7395 # N(100 m²) +_N_AT_TFA_60 = 1.9816 # N(60 m²) + +# Table 13 high-rate-fraction grid cells (PDF p.197), keyed by (floor +# area row, cylinder litres column, tariff, single?) → published value. +_GRID_TOL = 0.005 # the published grid is rounded to 2 dp + + +def test_table_13_dual_immersion_matches_published_grid() -> None: + # Arrange — SAP 10.2 Table 13 (PDF p.197), 110 L cylinder, dual + # immersion. Floor area 100 m² row: 7-hour = 0.18, 10-hour = 0.10. + + # Act + seven = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, + single_immersion=False, tariff=Tariff.SEVEN_HOUR, + ) + ten = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, + single_immersion=False, tariff=Tariff.TEN_HOUR, + ) + + # Assert + assert abs(seven - 0.18) <= _GRID_TOL + assert abs(ten - 0.10) <= _GRID_TOL + + +def test_table_13_single_immersion_matches_published_grid() -> None: + # Arrange — SAP 10.2 Table 13 (PDF p.197), 110 L cylinder, single + # immersion. Floor area 100 m² row: 7-hour = 0.61, 10-hour = 0.23. + # Single immersion carries a much larger high-rate fraction than dual. + + # Act + seven = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, + single_immersion=True, tariff=Tariff.SEVEN_HOUR, + ) + ten = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, + single_immersion=True, tariff=Tariff.TEN_HOUR, + ) + + # Assert + assert abs(seven - 0.61) <= _GRID_TOL + assert abs(ten - 0.23) <= _GRID_TOL + + +def test_table_13_large_cylinder_single_immersion_clamps_to_zero() -> None: + # Arrange — SAP 10.2 Table 13 Note 2 (PDF p.197): "If these formulae + # give a value less than zero, set the high-rate fraction to zero." A + # 210 L cylinder with single immersion on a 10-hour tariff falls below + # zero (the published 210 L 10-hour column is 0), so the helper clamps. + + # Act + fraction = electric_dhw_high_rate_fraction( + cylinder_volume_l=210.0, occupancy_n=_N_AT_TFA_60, + single_immersion=True, tariff=Tariff.TEN_HOUR, + ) + + # Assert + assert fraction == 0.0 + + +def test_table_13_eighteen_and_twenty_four_hour_bill_full_low_rate() -> None: + # Arrange — SAP 10.2 Table 12a (PDF p.191) is titled "High-rate + # fractions for systems using 7-hour and 10-hour tariffs"; its + # "Immersion water heater" row lists the tariff as "7-hour or 10-hour" + # only, routing to Table 13. An 18-hour / 24-hour tariff is OUTSIDE the + # table's scope: it provides at least 18 hours/day at the low rate, more + # than enough to heat any immersion cylinder off-peak, so the high-rate + # fraction is 0 (100% billed at the low rate). The Elmhurst dr87 + # worksheet for solid fuel 5 (cert 001431: 18-hour meter, 110 L dual + # immersion, WHC 903) bills HW (245) high-rate = 0.0 kWh, (246) low-rate + # = 100% — confirming high_frac = 0 for an 18-hour immersion DHW. + + # Act + eighteen = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, + single_immersion=False, tariff=Tariff.EIGHTEEN_HOUR, + ) + twenty_four = electric_dhw_high_rate_fraction( + cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100, + single_immersion=False, tariff=Tariff.TWENTY_FOUR_HOUR, + ) + + # Assert + assert eighteen == 0.0 + assert twenty_four == 0.0 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case38.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case38.py new file mode 100644 index 00000000..f96b17ce --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case38.py @@ -0,0 +1,116 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 38" worksheet — a mains-gas dwelling with a code-117 +regular boiler (1979-1997, winter 66%), control 2102 (programmer, no room +thermostat → −5pp interlock → (206)=61%), and a **mains-gas condensing gas +fire secondary** (SAP code 611). + +This is the realistic re-generation of "simulated case 37": case 37 lodged +the same dwelling's code-605 gas fire on BIOGAS (7.60 p/kWh), which the +Elmhurst Summary export cannot carry (it lodges only the secondary SAP +code, not its sub-fuel — see `_elmhurst_secondary_fuel_from_sap_code`), so +the mains-gas modal default left a +7 SAP gap that was purely the biogas +sub-fuel. With a mains-gas secondary the whole cascade reproduces the +worksheet EXACTLY, confirming the boiler-efficiency / control-2102 / +secondary handling is all correct. + +Like 000565 / the _rr cases / case 20 / 21, this fixture does NOT hand- +build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 38/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf` so the +test runs without depending on the unstaged workspace. + +Worksheet pin targets (P960-0001-001431, "11a. SAP rating" block): +- SAP value (un-rounded, before (258) integer rounding) = 60.9152 +- (272) Total CO2, kg/year = 5801.0770 + +Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]: +pins are abs <= 1e-3 against the worksheet PDF (the worksheet prints the +SAP value to 4 dp). +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case38.pdf" +) + +LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 60.9152 +LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 5801.0770 +_PIN_ABS: Final[float] = 1e-3 + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-38 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + +def test_case38_mains_gas_secondary_reproduces_the_worksheet_sap_and_co2() -> None: + # Arrange — the full extractor -> mapper -> calculator pipeline on the + # simulated case-38 Summary (mains-gas boiler 117 + mains-gas + # condensing gas-fire secondary 611). + epc = build_epc() + + # Act + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert — the SAP-rating block reproduces the worksheet exactly. + assert ( + abs(result.sap_score_continuous - LINE_258_SAP_VALUE_CONTINUOUS) + <= _PIN_ABS + ) + assert abs(result.co2_kg_per_yr - LINE_272_TOTAL_CO2_KG_PER_YR) <= _PIN_ABS diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 24fbe082..3153f1bc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -36,6 +36,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( heat_transmission_from_cert, ) from domain.sap10_calculator.worksheet.heat_transmission import ( + _alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage] _joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage] _part_geometry, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage] @@ -150,6 +151,112 @@ 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_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 + # directly in place of the §5.11 construction-default cascade. The gov-EPC + # API surfaces the assessor's roof U as `roof_u_value`; the gov open data + # can redact the backing insulation thickness, leaving the §5.11 cascade + # to mis-derive an uninsulated U. Cert 7921-0052-0940-5007-0663 lodges a + # "Pitched, sloping ceiling" (rc=8) age-C roof with no thickness — the + # cascade returns the uninsulated 2.30 W/m²K (→ -23 SAP) where the lodged + # roof_u_value is 0.2. Geometry: 100 m² plan → sloped roof area = + # 100 / cos(30°) = 115.47 m². roof_w_per_k must follow the lodged 0.2 + # (0.2 × 115.47 = 23.094 W/K), NOT the 2.30 × 115.47 = 265 W/K default. + main = make_building_part( + construction_age_band="C", + wall_construction=3, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=8, + 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_construction_type = "Pitched, sloping ceiling" + main.roof_u_value = 0.2 + 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 — 0.2 × (100 / cos(30°)) = 0.2 × 115.470 = 23.094 W/K. + assert abs(result.roof_w_per_k - 23.094) <= 1e-3 + + +def test_lodged_wall_u_value_overrides_construction_default() -> None: + # Arrange — RdSAP 10 §5.1: a lodged main-wall U-value (the gov-EPC API + # `wall_u_value`, the assessor's RdSAP output) overrides the §5.6/§5.7 + # construction-default cascade. Cohort certs 2021/7505 lodge solid-brick + # (rc=3) walls with the open data's insulation redacted, where the lodged + # wall_u_value (0.34) is far below the cascade's uninsulated default → the + # cascade under-rates by ~-2.6 SAP. Geometry chosen so the gross wall area + # (no openings) is 80 m²: net wall U×A must follow 0.34, NOT the default. + 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.wall_u_value = 0.34 + 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 — gross wall = perimeter 40 m × height 2.5 m = 100 m²; no windows + # or doors in this minimal cert → net wall area 100 m². 0.34 × 100 = 34.0. + assert abs(result.walls_w_per_k - 34.0) <= 1e-6 + + +def test_lodged_floor_u_value_overrides_iso_13370_cascade() -> None: + # Arrange — RdSAP 10 §5.1: a lodged ground-floor U-value (the gov-EPC API + # `floor_u_value`) overrides the BS EN ISO 13370 / Table 19 cascade. + 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.floor_u_value = 0.12 + 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 — 0.12 × 100 m² floor = 12.0 W/K. + assert abs(result.floor_w_per_k - 12.0) <= 1e-6 + + def test_exposed_timber_floor_age_b_uses_table_20_u_120_not_iso_13370() -> None: # Arrange — RdSAP10 §5.13 Table 20: a part whose lowest floor sits # over outside air (or unheated space) rather than soil takes its @@ -327,16 +434,22 @@ def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row assert result.walls_w_per_k == pytest.approx(170.0, abs=1.0) -def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: - # Arrange — the modal RdSAP encoding for a retrofitted-cavity dwelling: - # wall_construction=4 (cavity), wall_insulation_type=4 (as-built / - # assumed), and walls[0].description = "Cavity wall, as built, - # insulated (assumed)". The assessor has determined the cavity is - # filled but hasn't lodged a thickness. Without the description-based - # dispatcher, the cascade would return U=1.5; with it, the Filled- - # cavity row of Table 6 applies: U=0.7 at band E. +def test_cavity_as_built_insulated_assumed_uses_as_built_row() -> None: + # Arrange — wall_construction=4 (cavity), wall_insulation_type=4 + # (as-built / assumed), walls[0].description = "Cavity wall, as built, + # insulated (assumed)". This is the AS-BUILT state, not a retrofit fill: + # per RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote + # ("assumed as built") applies only at bands I-M, where it coincides + # with "Cavity as built"; at bands A-H the filled row is for a genuine + # fill. So band E uses the "Cavity as built" row U=1.5, NOT filled 0.7. + # + # Prior code special-cased the "insulated" adjective to the filled row + # (legacy convention); the API SAP-accuracy cohort over-rated band-G/H + # "insulated (assumed)" cavities by +1.4 / +1.6 SAP median (filled 0.35 + # vs as-built 0.60). A genuine fill renders the distinct "Cavity wall, + # filled cavity" (wall_insulation_type=2), caught separately. # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey - # → gross_wall = 100 m². walls_w_per_k expected = 0.7 × 100 = 70 W/K. + # → gross_wall = 100 m². walls_w_per_k expected = 1.5 × 100 = 150 W/K. main = make_building_part( identifier="Main Dwelling", construction_age_band="E", @@ -367,8 +480,8 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: # Act result = heat_transmission_from_cert(epc) - # Assert - assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) + # Assert — Cavity-as-built row at band E = 1.5 W/m²K (not filled 0.7). + assert result.walls_w_per_k == pytest.approx(150.0, abs=1.0) def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: @@ -381,8 +494,8 @@ def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: # RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs # "Filled cavity" band F = 0.40. A genuine fill renders the distinct # "Cavity wall, filled cavity" description (wall_insulation_type=2), - # caught separately. Contrast the "insulated (assumed)" variant above, - # which the assessor judges as filled. + # caught separately. The "insulated (assumed)" variant above now routes + # to the same as-built row (all as-built adjectives coincide at A-H). # # Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, # cavity type 4, "partial insulation (assumed)") closes all four SAP @@ -1187,6 +1300,131 @@ def test_alternative_wall_uses_own_construction_and_deducts_from_main_wall_area( ) +def test_sheltered_alternative_wall_applies_table4_0p5_resistance() -> None: + # Arrange — RdSAP 10 Table 4 (PDF p.22) "Sheltered" wall: a sub-area + # adjacent to an unheated buffer carries an added external surface + # resistance R=0.5 m²K/W, so its U reduces to 1/(1/U + 0.5). The gov + # EPC API lodges this per alt-wall as `sheltered_wall="Y"` (mapped to + # `is_sheltered`). Two identical timber-frame as-built alt sub-areas + # (cavity-band-A → uninsulated cascade U), one exposed, one sheltered. + from dataclasses import replace + + from domain.sap10_ml.rdsap_uvalues import Country + + area = 42.0 + exposed = SapAlternativeWall( + wall_area=area, wall_dry_lined="N", wall_construction=5, + wall_insulation_type=4, wall_thickness_measured="Y", + wall_insulation_thickness="NI", is_sheltered=False, + ) + sheltered = replace(exposed, is_sheltered=True) + + # Act + exposed_wpk = _alt_wall_w_per_k( + alt_wall=exposed, country=Country.ENG, age_band="A", wall_description=None, + ) + sheltered_wpk = _alt_wall_w_per_k( + alt_wall=sheltered, country=Country.ENG, age_band="A", wall_description=None, + ) + + # Assert — the sheltered U is the exposed U with R=0.5 added. + exposed_u = exposed_wpk / area + expected_sheltered_u = 1.0 / (1.0 / exposed_u + 0.5) + assert abs(sheltered_wpk - expected_sheltered_u * area) <= 1e-9 + assert sheltered_wpk < exposed_wpk + + +def test_corridor_door_on_sheltered_alt_wall_uses_table26_u_1p4() -> None: + # Arrange — RdSAP 10 Table 26 (PDF p.51): a door opening to an unheated + # corridor/stairwell has U=1.4 (any age), versus 3.0 for an external + # door (age A-J). RdSAP §3.7 (p.18): "the door of a flat/maisonette to + # an unheated stairwell or corridor ... is deducted from the sheltered + # wall area" — i.e. the corridor door sits on the sheltered alt wall, + # not the main wall. Simulated case 34: a flat with a sheltered alt + # (corridor) wall + 2 doors → 1 corridor door (U=1.4 on the alt wall) + + # 1 external door (U=3.0 on the main wall). + from dataclasses import replace + + main = replace( + make_building_part( + construction_age_band="B", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=50.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0, + ), + ], + ), + sap_alternative_wall_1=SapAlternativeWall( + wall_area=12.5, wall_dry_lined="N", wall_construction=4, + wall_insulation_type=4, wall_thickness_measured="Y", + wall_insulation_thickness="NI", is_sheltered=True, + ), + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[main], + ) + + # Act + no_corridor = heat_transmission_from_cert(epc, door_count=2, corridor_door_count=0) + with_corridor = heat_transmission_from_cert(epc, door_count=2, corridor_door_count=1) + + # Assert — no-corridor: both doors external at the age-default U. + door_u = no_corridor.doors_w_per_k / (2 * 1.85) + # with-corridor: 1 external @door_u + 1 corridor @1.4 (both 1.85 m²). + assert abs(with_corridor.doors_w_per_k - (1.85 * door_u + 1.85 * 1.4)) <= 0.02 + # The corridor door (U=1.4) is cheaper than an external door (U≈3.0) and + # its area moves off the main wall onto the sheltered alt wall, so the + # net fabric heat loss drops. + assert with_corridor.fabric_heat_loss_w_per_k < no_corridor.fabric_heat_loss_w_per_k + + +def test_main_wall_dry_lining_applies_table_14_resistance() -> None: + # Arrange — RdSAP 10 §5.7/§5.8 (PDF p.40-41), Table 14: a dry-lined + # (including lath-and-plaster) uninsulated wall adds R=0.17 m²K/W: + # U_adj = 1/(1/U₀ + 0.17). A solid-brick (construction 3) age-A wall + # with a measured 230 mm thickness has U₀=1.70 (Table 13, 200-280 mm + # band) → dry-lined U=1/(1/1.70+0.17)=1.32 (2 d.p.). The alt-wall path + # already applies this; the MAIN wall dropped the `dry_lined` kwarg, so + # every lodged `wall_dry_lined=Y` main wall was billed at the un-adjusted + # U — under-rating solid-brick stock (API wall_construction=3 cohort: + # 48 dry-lined certs at 10% within-0.5, signed -1.33). + from dataclasses import replace + + base_part = make_building_part( + construction_age_band="A", + wall_construction=3, # solid brick + wall_insulation_type=0, # uninsulated (as-built) + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=50.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0, + ), + ], + ) + not_dry = replace(base_part, wall_thickness_mm=230, wall_dry_lined=False) + dry = replace(base_part, wall_thickness_mm=230, wall_dry_lined=True) + epc_not_dry = make_minimal_sap10_epc( + total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[not_dry], + ) + epc_dry = make_minimal_sap10_epc( + total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[dry], + ) + + # Act + ht_not_dry = heat_transmission_from_cert(epc_not_dry, door_count=0) + ht_dry = heat_transmission_from_cert(epc_dry, door_count=0) + + # Assert — same net wall area, so the W/K ratio is the U ratio: the + # dry-lined wall is 1.32/1.70 = 0.776× the as-built wall. + assert ht_not_dry.walls_w_per_k > 0.0 + expected_ratio = 1.32 / 1.70 + assert abs(ht_dry.walls_w_per_k / ht_not_dry.walls_w_per_k - expected_ratio) <= 0.005 + assert ht_dry.fabric_heat_loss_w_per_k < ht_not_dry.fabric_heat_loss_w_per_k + + def test_window_uses_effective_u_value_with_curtain_resistance_per_sap10_2_section_3_2() -> None: """SAP10.2 §3.2: the window U-value used for heat-transmission is the effective form `U_eff = 1/(1/U_raw + 0.04)` — the 0.04 m²K/W is the diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index e8e85b1e..81086c46 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -344,6 +344,34 @@ def test_hot_water_mixer_showers_two_outlets_distributes_shower_count_evenly() - assert t == pytest.approx(s, abs=1e-9), f"month {m+1}" +def test_hot_water_mixer_showers_counts_electric_showers_in_noutlets() -> None: + # Arrange — SAP 10.2 Appendix J step 1a (PDF p.~): "Establish how many + # shower outlets are present in the dwelling, Noutlets (INCLUDING in the + # count any instantaneous electric showers)". The mixer (42a) demand + # divides N_shower by this TOTAL outlet count, so a coexisting electric + # shower reduces each mixer outlet's share. Adding one electric shower to + # a single-mixer dwelling makes Noutlets=2 → the mixer demand halves + # (vs. counting only the mixer outlet, which over-counts main HW and + # under-rates the dwelling). + mixer_only = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=2.0, has_bath=True, + mixer_shower_flow_rates_l_per_min=(8.0,), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + ) + + # Act — same single mixer outlet, but one electric shower also present. + with_electric = hot_water_mixer_showers_monthly_l_per_day( + n_occupants=2.0, has_bath=True, + mixer_shower_flow_rates_l_per_min=(8.0,), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + n_electric_showers=1, + ) + + # Assert — N_shower / 2 instead of / 1 → exactly half the mixer demand. + for m, (only, both) in enumerate(zip(mixer_only, with_electric)): + assert abs(both - only / 2.0) <= 1e-9, f"month {m+1}" + + @pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES]) def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture: ModuleType) -> None: """SAP10.2 §4 line (44)m via Appendix J equation J13: diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 5712a62c..fb7c1c07 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -70,7 +70,7 @@ def test_run_one_returns_a_plan_and_prints_the_table( assert len(plan.measures) >= 1 printed: str = capsys.readouterr().out assert "Plan SAP" in printed - assert "cavity_wall_insulation" in printed + assert "air_source_heat_pump" in printed def test_run_modelling_inspects_a_plan_without_baseline_or_lodged_performance() -> None: diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index af020aee..6b8bc9a4 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -80,35 +80,28 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: # Assert — the Plan ran and every fired measure names its trigger fields. assert report.plan is not None assert report.plan_error is None - # This gas dwelling lodges an electric secondary heater (SAP 691) on a - # category-2 main, so secondary-heating removal (ADR-0028) is a very cheap - # SAP lever (\£250); the Optimiser reaches the target band via the fabric - # stack + that removal, leaving the \£12k ASHP unselected (it owns the - # economics — ADR-0024). + # The gain-maximising package: the efficient representative ASHP (ADR-0025) + # plus solid-floor insulation. The cavity wall + its forced mechanical + # ventilation (ADR-0016) are NOT selected — the wall earns +SAP alone but + # the forced-ventilation penalty makes the pair net-negative, so the + # Optimiser correctly leaves them out (see test_measure_dependency / + # test_optimiser for the forced-edge unit coverage; cavity_wall + + # mechanical_ventilation trigger fields are exercised in + # test_cavity_wall_recommendation / the ventilation generator tests). triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) assert set(triggers) == { - "cavity_wall_insulation", - "mechanical_ventilation", "solid_floor_insulation", - "secondary_heating_removal", - } - # Cavity-fill fired off an uninsulated cavity wall; its dependent MEV fired - # because no mechanical ventilation is lodged. - assert triggers["cavity_wall_insulation"].triggers == { - "wall_construction": 4, - "wall_insulation_type": 4, - } - assert triggers["mechanical_ventilation"].triggers == { - "mechanical_ventilation_kind": None, + "air_source_heat_pump", } # Solid-floor insulation fired off an uninsulated solid ground floor. assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } - # Secondary-heating removal fired off the lodged secondary (SAP code 691). - assert triggers["secondary_heating_removal"].triggers == { - "secondary_heating_type": 691, + # The ASHP bundle fired off the gas-dwelling main it replaces. + assert triggers["air_source_heat_pump"].triggers == { + "property_type": "0", + "main_heating_category": 2, } @@ -174,18 +167,20 @@ def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: # Act report: PropertyReport = build_property_report(path) - # Assert — 0036 reaches the target band with solid-floor insulation plus - # secondary-heating removal (it lodges an electric secondary, SAP 691, on a - # gas main — a cheap SAP lever, ADR-0028), and nothing else. The cheaper-to- - # target pair displaces the LED upgrade the Optimiser used to add. + # Assert — 0036's gain-maximising package is solid-floor insulation plus the + # low-energy-lighting upgrade (it lodges 7 low-energy + 0 incandescent fixed + # bulbs, so the LED top-up is a cheap positive-SAP lever, ADR-0023), and + # nothing else. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) - assert set(triggers) == {"solid_floor_insulation", "secondary_heating_removal"} + assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"} assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } - assert triggers["secondary_heating_removal"].triggers == { - "secondary_heating_type": 691, + assert triggers["low_energy_lighting"].triggers == { + "incandescent_fixed_lighting_bulbs_count": 0, + "cfl_fixed_lighting_bulbs_count": None, + "low_energy_fixed_lighting_bulbs_count": 7, } diff --git a/tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py b/tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py new file mode 100644 index 00000000..7a2c1add --- /dev/null +++ b/tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py @@ -0,0 +1,165 @@ +"""Regression pin: a PV battery is SAP-cost-NEUTRAL on an export-capable +STANDARD-tariff dwelling, but still moves primary energy / CO2 and DOES +help the rating on an off-peak tariff. + +SPEC EVIDENCE — the user-simulated Elmhurst P960 worksheet pair at +`sap worksheets/golden fixture debugging/simulated case 35 (without +battery)` and `simulated case 36 (with battery)` (cert 001431, 5 kWh, +export-capable, mains-gas, standard tariff). The official SAP rating uses +"10a. Fuel costs - using Table 12 prices", where PV used-in-dwelling and +PV exported are BOTH priced at 13.19 p/kWh (export code 60 == import code +30, ADR-0010). A battery only redistributes PV between those two equally- +priced lines (worksheet split 1128/2326 -> 1951/1504 kWh), so the PV +credit (252) = -455.6458 and the SAP (258) = 88.0859 are IDENTICAL with +and without the battery: ΔSAP = exactly 0. The battery DOES move the EPC's +consumer £-bill (a SEPARATE "10a ... using BEDF prices (595)" block at +import 27.67 / export 5.81, £696 -> £515/yr) — but that block never feeds +(258). It also moves CO2 (272) / PE (286), since used vs exported carry +different factors. + +This guards two things against silent regression: + 1. The export price == import price (13.19) that makes the credit split- + invariant on standard tariff — if someone changes table_32 code 60, + the standard-tariff ΔSAP would stop being 0 and Test A fails. + 2. The Appendix M1 §3c battery β-split is actually wired — if a refactor + dropped the battery, PE would stop responding (Test A's PE assertion) + AND the off-peak benefit would vanish (Test B). + +The β-coefficient formula itself is unit-tested in +tests/domain/sap10_calculator/worksheet/test_photovoltaic.py; this is the +end-to-end API-path (`from_api_response` -> cert_to_inputs -> calculator) +counterpart over the committed RdSAP-21.0.1 corpus. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, cast + +import pytest + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + +# RdSAP meter_type 2 = Single (standard tariff). Anything else (1 Dual / 4 +# Dual-24h / 5 18-hour) is an off-peak tariff where self-consumed PV +# displaces a higher unit rate than the export credit — see _rdsap_tariff. +_STANDARD_METER_TYPE = 2 + + +def _load_corpus() -> list[dict[str, Any]]: + if not _CORPUS.exists(): + return [] + return [ + json.loads(line) + for line in _CORPUS.read_text().splitlines() + if line.strip() + ] + + +def _energy_source(doc: dict[str, Any]) -> dict[str, Any]: + es = doc.get("sap_energy_source") + return cast("dict[str, Any]", es) if isinstance(es, dict) else {} + + +def _has_real_pv_and_battery(doc: dict[str, Any]) -> bool: + es = _energy_source(doc) + if not es.get("is_dwelling_export_capable"): + return False + if (es.get("pv_battery_count") or 0) < 1: + return False + pv = es.get("photovoltaic_supply") + # A bare "no details" array / "none_or_no_details" dict carries no PV. + if isinstance(pv, dict) and "none_or_no_details" in pv: + return False + return True + + +def _sap_pe(doc: dict[str, Any]) -> tuple[float, float]: + epc = EpcPropertyDataMapper.from_api_response(doc) + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + return result.sap_score_continuous, result.primary_energy_kwh_per_m2 + + +def _with_battery_zeroed(doc: dict[str, Any]) -> dict[str, Any]: + zeroed = deepcopy(doc) + zeroed["sap_energy_source"]["pv_battery_count"] = 0 + return zeroed + + +def test_battery_is_sap_cost_neutral_on_export_capable_standard_tariff() -> None: + # Arrange — every export-capable, standard-tariff (meter 2) corpus cert + # that carries both a real PV array and a battery (the worksheet case + # 35/36 profile: mains-gas, single-rate meter). + corpus = _load_corpus() + if not corpus: + pytest.skip(f"no corpus at {_CORPUS}") + standard_battery_certs = [ + doc + for doc in corpus + if _has_real_pv_and_battery(doc) + and _energy_source(doc).get("meter_type") == _STANDARD_METER_TYPE + ] + assert standard_battery_certs, ( + "no export-capable standard-tariff PV+battery cert in the corpus — " + "this regression pin has nothing to guard" + ) + + # Act / Assert — toggling the battery off leaves the continuous SAP + # EXACTLY unchanged (export price == import price 13.19 makes the + # onsite/export split cost-irrelevant), while at least one cert's + # primary energy DOES respond (proving the battery is modelled, not + # silently dropped). + pe_deltas: list[float] = [] + for doc in standard_battery_certs: + sap_on, pe_on = _sap_pe(doc) + sap_off, pe_off = _sap_pe(_with_battery_zeroed(doc)) + assert abs(sap_on - sap_off) <= 1e-9, ( + "battery changed SAP on a standard-tariff export-capable cert: " + f"{sap_on} vs {sap_off}" + ) + pe_deltas.append(pe_on - pe_off) + + assert min(pe_deltas) < -1e-6, ( + "no standard-tariff battery cert showed a primary-energy effect — " + "the App-M1 §3c battery β-split may have been dropped" + ) + + +def test_battery_improves_sap_on_export_capable_off_peak_tariff() -> None: + # Arrange — export-capable PV+battery certs on an OFF-PEAK tariff + # (meter_type != 2). Here self-consumed PV displaces high-rate import + # (e.g. 7-hour high 15.29 p/kWh) which exceeds the 13.19 export credit, + # so a battery (more self-consumption) lowers cost. + corpus = _load_corpus() + if not corpus: + pytest.skip(f"no corpus at {_CORPUS}") + off_peak_battery_certs = [ + doc + for doc in corpus + if _has_real_pv_and_battery(doc) + and _energy_source(doc).get("meter_type") != _STANDARD_METER_TYPE + ] + if not off_peak_battery_certs: + pytest.skip("no export-capable off-peak PV+battery cert in the corpus") + + # Act / Assert — the battery STRICTLY raises the SAP, confirming the + # standard-tariff neutrality above is a price coincidence (import == + # export) and not a dropped-battery no-op. + for doc in off_peak_battery_certs: + sap_on, _ = _sap_pe(doc) + sap_off, _ = _sap_pe(_with_battery_zeroed(doc)) + assert sap_on - sap_off > 1e-6, ( + "battery did not improve SAP on an off-peak export-capable cert: " + f"{sap_on} vs {sap_off}" + ) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py new file mode 100644 index 00000000..13e9f6e6 --- /dev/null +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -0,0 +1,155 @@ +"""End-to-end SAP-accuracy gauge over the committed RdSAP-21.0.1 corpus. + +Drives the full API path — raw gov-EPC response → ``from_api_response`` → +``cert_to_inputs`` → ``calculate_sap_from_inputs`` — across all 1000 certs in +``backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl`` and pins the +aggregate accuracy of our continuous SAP (and CO2 / PE) against each cert's +lodged figures. This is the committed regression guard for the headline +"% within 0.5 SAP of the lodged rating" gauge that the per-cert mapper work +optimises (mirrors scripts/eval_api_sap_accuracy.py, but on the in-repo +corpus so it runs in CI without the /tmp sample). + +SCOPE — RdSAP-21.0.1 ONLY. It is the RdSAP 10 / SAP 10.2-era schema, so its +lodged ``energy_rating_current`` was produced by the same SAP methodology we +compute, making it a fair accuracy target. The pre-SAP10 schemas (17.x-20.0.0) +lodge SAP 2012 ratings — a different underlying calculation — so they are NOT +expected to match and are excluded here (their mapper coverage is guarded by +test_mapper_corpus.py instead). + +The asserted thresholds are deterministic floors/ceilings over the fixed +corpus: tighten them whenever a slice improves the gauge (ratchet, never +loosen). Run ``pytest -s`` to see the live metrics line. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) + +_CORPUS = Path( + "backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" +) + +# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips). +# Current: SAP within-0.5 = 66.9%, SAP MAE = 1.039 (SAP 10.2 Appendix M1 +# EPV,ex=0 for non-export-capable PV, this slice: 7 Wybourn +19.1 -> +6.5, +# 4 Lime Ave +11.1 -> +0.4, 8 Hatherleigh +7.6 -> -0.2 — PE/CO2 matched +# lodged but the export cost credit alone was over-rating). Prior slices: +# unsized-cylinder Table 28 110 L; Table 12a code-408 0.20 storage fraction; +# heat-network Table 4c(3) flat-rate charging factor. +# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below). +# PE MAE = 14.7 kWh/m2/yr (signed +9.1). +# +# The SAP (cost) gauge is the optimised target — its floor/ceiling are TIGHT. +# CO2 and PE are reported + LOOSELY guarded: both run ~+5% high vs the lodged +# figures. INVESTIGATED (see scripts/decompose_co2_pe_error.py): this is NOT a +# CO2/PE-factor or scope bug. Table 12 factors are spec-exact (SAP 10.2 p.188: +# mains gas PEF 1.130 / CO2 0.210, electricity 1.501 / 0.136) and worksheet +# (286)/(272) scope is identical to ours. Two real causes: +# 1. Per-cert mapper/demand fidelity. corr(SAP-err, PE-diff) = -0.54: when we +# over-estimate demand the cert under-rates on SAP AND over-estimates PE. +# Golden cert 0300-2747 (matched data) hits register PE to -0.10 kWh/m2, +# proving the engine is correct — the corpus bias is the aggregate of the +# same unclosed mapper gaps the SAP gauge chases, seen through a more +# sensitive (linear, no standing-charge/deflator damping) lens. +# 2. SAP cost is genuinely calibrated, NOT energy hiding behind it: a +5% gas +# space-heating bump moves SAP -0.6 (would show as a -0.6 corpus bias if +# 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. +_MIN_WITHIN_HALF_SAP = 0.65 +_MAX_SAP_MAE = 1.08 +_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current + + +def _load_corpus() -> list[dict[str, Any]]: + if not _CORPUS.exists(): + return [] + return [ + json.loads(line) + for line in _CORPUS.read_text().splitlines() + if line.strip() + ] + + +def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( + capsys: pytest.CaptureFixture[str], +) -> None: + # Arrange — the full in-repo 21.0.1 corpus. + corpus = _load_corpus() + if not corpus: + pytest.skip(f"no corpus at {_CORPUS}") + + sap_abs_errs: list[float] = [] + co2_signed_errs_t: list[float] = [] # our − lodged, tonnes/yr + pe_signed_errs: list[float] = [] # our − lodged, kWh/m²/yr + skipped = 0 + + # Act — run the API → EpcPropertyData → calculator pipeline per cert. + for doc in corpus: + lodged_sap = doc.get("energy_rating_current") + if lodged_sap is None: + skipped += 1 + continue + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + except Exception: + # A mapper / calculator raise is a coverage gap tracked elsewhere + # (eval_api_sap_accuracy.py); here we gauge the certs that compute. + skipped += 1 + continue + + sap_abs_errs.append(abs(result.sap_score_continuous - lodged_sap)) + + lodged_co2_t = doc.get("co2_emissions_current") # tonnes/yr + if lodged_co2_t is not None: + co2_signed_errs_t.append(result.co2_kg_per_yr / 1000.0 - lodged_co2_t) + lodged_pe_per_m2 = doc.get("energy_consumption_current") # kWh/m²/yr (primary) + if lodged_pe_per_m2 is not None: + pe_signed_errs.append(result.primary_energy_kwh_per_m2 - lodged_pe_per_m2) + + n = len(sap_abs_errs) + within_half = sum(1 for e in sap_abs_errs if e < 0.5) / n + sap_mae = sum(sap_abs_errs) / n + co2_mae = sum(abs(e) for e in co2_signed_errs_t) / len(co2_signed_errs_t) + co2_bias = sum(co2_signed_errs_t) / len(co2_signed_errs_t) + pe_mae = sum(abs(e) for e in pe_signed_errs) / len(pe_signed_errs) + pe_bias = sum(pe_signed_errs) / len(pe_signed_errs) + + with capsys.disabled(): + print( + f"\n[RdSAP-21.0.1 corpus | {n} computed / {skipped} skipped]" + f"\n SAP within-0.5 = {within_half:.1%} MAE = {sap_mae:.3f}" + f"\n CO2 MAE = {co2_mae:.2f} t/yr (bias {co2_bias:+.2f} t/yr)" + f"\n PE MAE = {pe_mae:.1f} kWh/m2/yr (bias {pe_bias:+.1f})" + ) + + # Assert — SAP (cost) is the optimised gauge: tight floor/ceiling. CO2/PE + # are loose "don't regress" guards (see module + threshold notes). + assert within_half >= _MIN_WITHIN_HALF_SAP, ( + f"SAP within-0.5 {within_half:.1%} fell below floor " + f"{_MIN_WITHIN_HALF_SAP:.0%}" + ) + assert sap_mae <= _MAX_SAP_MAE, ( + f"SAP MAE {sap_mae:.3f} exceeded ceiling {_MAX_SAP_MAE}" + ) + assert co2_mae <= _MAX_CO2_MAE_TONNES, ( + f"CO2 MAE {co2_mae:.2f} t/yr exceeded ceiling {_MAX_CO2_MAE_TONNES}" + ) + assert pe_mae <= _MAX_PE_PER_M2_MAE, ( + f"PE MAE {pe_mae:.1f} kWh/m2/yr exceeded ceiling {_MAX_PE_PER_M2_MAE}" + ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 7caffe6f..192f07a5 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -384,29 +384,29 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert plan.energy_consumption_savings > 0.0 by_type = {rec.type: rec for rec in rec_rows} + # The gain-maximising package: the efficient representative heat pump + # (Vaillant aroTHERM plus 5 kW, ADR-0025) now raises SAP even on this gas + # dwelling, plus the cheap positive-SAP fabric/lighting/secondary measures. + # CAVITY-WALL INSULATION is NOT selected: it earns +2.9 SAP alone, but the + # fabric→ventilation forced dependency (ADR-0016) drags the wall+ventilation + # pair to a NET −1.8 SAP (−0.9 on top of the ASHP package), so the Optimiser + # correctly leaves the wall — and therefore its forced ventilation — out. + # (The forced wall→ventilation edge itself is covered by + # test_measure_dependency / test_optimiser; here we prove the end-to-end + # optimise→persist→telescope pipeline on the package the Optimiser keeps.) + # The sample EPC lodges 8 low-energy-unknown bulbs (LED upgrade, ADR-0023) + # and an electric secondary heater (SAP 691, removal offered per ADR-0028). assert set(by_type) == { - "cavity_wall_insulation", "suspended_floor_insulation", - "mechanical_ventilation", - # The sample EPC lodges 8 low-energy-unknown bulbs, so the LED upgrade is - # a cheap positive-SAP candidate the Optimiser also keeps (ADR-0023). "low_energy_lighting", - # The efficient representative heat pump (Vaillant aroTHERM plus 5 kW, - # ADR-0025) now raises SAP even on this gas dwelling, so the Optimiser - # also keeps the ASHP bundle in the least-cost-to-band package (ADR-0024). "air_source_heat_pump", - # The sample lodges an electric secondary (SAP 691), so removal is offered - # (ADR-0028); the Optimiser keeps it in its all-beneficial-measures package - # — its SAP gain is 0 once the ASHP (category 4) ignores the secondary, but - # the heater is still physically removed at its own cost. "secondary_heating_removal", } # Each persisted measure carries the catalogue id of the Product it installs # (the MaterialRow ids seeded above), replacing the retired # recommendation_materials BOM with a single material_id on the row. - assert by_type["cavity_wall_insulation"].material_id == 1 + assert by_type["air_source_heat_pump"].material_id == 5 assert by_type["suspended_floor_insulation"].material_id == 2 - assert by_type["mechanical_ventilation"].material_id == 3 assert by_type["low_energy_lighting"].material_id == 4 assert by_type["secondary_heating_removal"].material_id == 9 for rec in rec_rows: @@ -414,27 +414,12 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert rec.already_installed is False assert rec.sap_points is not None assert rec.estimated_cost is not None - # The forced ventilation costs two £450 units and is priced even though it - # was never a free choice in the pool. - vent_cost: float | None = by_type["mechanical_ventilation"].estimated_cost - assert vent_cost is not None - assert abs(vent_cost - 900.0) <= 1e-6 - # The insulation measures earn positive SAP; ventilation's contribution is - # not positive (it only ever costs SAP — ADR-0016). - wall_sap: float | None = by_type["cavity_wall_insulation"].sap_points - vent_sap: float | None = by_type["mechanical_ventilation"].sap_points - assert wall_sap is not None and vent_sap is not None - assert wall_sap > 0.0 - assert vent_sap <= 0.0 # Per-measure bill savings (telescoping cascade, ADR-0014 amendment): each # measure carries its delivered-kWh and £ saving, and they telescope exactly - # to the Plan's headline savings. Ventilation increases energy, so its - # savings are negative — and the telescoping still holds. + # to the Plan's headline savings. for rec in rec_rows: assert rec.kwh_savings is not None assert rec.energy_cost_savings is not None - vent_kwh: float | None = by_type["mechanical_ventilation"].kwh_savings - assert vent_kwh is not None and vent_kwh < 0.0 kwh_total: float = sum(rec.kwh_savings or 0.0 for rec in rec_rows) cost_total: float = sum(rec.energy_cost_savings or 0.0 for rec in rec_rows) assert plan.energy_consumption_savings is not None