diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index cdffe4b4..69c0b1ae 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -511,7 +511,21 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # residual (SAP +1.18, cost −£27 / CO2 −50 / PE −562) — likely a # cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as # forcing function for follow-up. - _CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+1.1783, expected_cost_resid_gbp=-27.1485, expected_co2_resid_kg=-49.8272, expected_pe_resid_kwh=-562.4367), + # Slice S0380.179 closed `no system` via RdSAP 10 §10.7 (PDF p.55) + # "No water heating system": the cert lodges §15.0 water code 999 + # (NON) + §15.1 "Cylinder Present: No", but per spec the calculation + # is done for an electric immersion heater on a Table 28 row-1 110 L + # cylinder with Table 29 row-1 age-band insulation (25 mm foam at age + # G). The P960 worksheet header confirms the engine's substitution + # (WHS 903 Single immersion, 110 L). Pre-slice the cascade trusted + # the lodged "no cylinder" → no storage loss (56) + a spurious Table + # 3a combi loss, and the wrong HW heat-gains propagated through §5/§7 + # to over-state the base MIT (+0.25 K), over-stating space fuel by + # +228 kWh. `_apply_rdsap_no_water_heating_system_default` injects + # the default cylinder before the section cascades, closing HW fuel + # (219) 1935.37 → 2529.69 EXACT AND the space residual in one move. + # ΔSAP +1.18 → <1e-4, all four metrics EXACT. + _CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), # Slice S0380.170 unblocked the 5 community-heating variants. Per # SAP 10.2 Table 12 (PDF p.189) the heat-network fuel code comes # from the §14.1 Community Heat Source × Community Fuel Type pair: @@ -920,6 +934,35 @@ def test_oil_6_absent_room_thermostat_applies_table_4f_pump_1_3_multiplier() -> ) +def test_no_system_assumes_rdsap_10_7_electric_immersion_default_cylinder() -> None: + # Arrange — the "no system" cert lodges §15.0 "Water Heating Code: + # NON / SapCode 999" and §15.1 "Hot Water Cylinder Present: No". Per + # RdSAP 10 §10.7 (PDF p.55) "No water heating system" verbatim: "the + # calculation is done for an electric immersion heater... for a + # cylinder defined by the first row of Table 28 (110 litres) and the + # first row of Table 29." The BRE-approved Elmhurst engine confirms + # it — the P960 worksheet header lodges "WHS: 903 Electric immersion, + # Single", a 110 L cylinder, and Table 29 age-band insulation (the + # corpus property is age G -> 25 mm foam), giving storage loss (56) = + # 594.32 kWh/yr. Worksheet HW (64) = (45) 1935.37 + (56) 594.32 = + # 2529.6927. Pre-slice the cascade trusted the lodged "no cylinder" + # so it added no storage loss (and a spurious Table 3a combi loss). + summary_pdf, _ = _variant_paths('no system') + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act — run the rating cascade and read the resolved HW fuel kWh. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — HW fuel = (45) + Table 29 110 L / 25 mm-foam storage loss + # = 2529.6927 (matches worksheet (64)/(219)). + assert abs(inputs.hot_water_kwh_per_yr - 2529.6927) <= 1e-3, ( + f"no system HW {inputs.hot_water_kwh_per_yr:.4f} kWh != 2529.6927 " + f"(RdSAP 10 §10.7 electric-immersion default 110 L cylinder)" + ) + + @pytest.mark.skipif( not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE, reason="all blocked variants have been unblocked (latest: S0380.170)", diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5fc529c2..83b4a378 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -50,7 +50,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification from __future__ import annotations import math -from dataclasses import dataclass +from dataclasses import dataclass, replace from decimal import ROUND_HALF_UP, Decimal from typing import Callable, Final, Literal, Optional @@ -4142,6 +4142,72 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { # "Foam" → factory-applied per SAP 10.2 Table 2 Note 2). _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 +# RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating +# code 999 (Elmhurst §15.0 "NON") signals that no DHW system was +# identified. Per spec the calculation is then done for an electric +# immersion heater + a cylinder defined by the first row of Table 28 +# (110 litres) and the first row of Table 29 (age-band insulation). +_WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999 +# Table 28 row 1 "Inaccessible — otherwise: 110 litres" → SAP cylinder +# size code 2 (Normal, 110 L). The immersion is single unless the meter +# is dual; the corpus "no system" cert's worksheet header lodges +# "Immersion Heater Type: Single" so the single-immersion path is used. +_CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2 +# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not +# accessible" — the §10.7 default cylinder uses the age-band insulation, +# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket +# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam. +_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = { + "G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38, +} + + +def _apply_rdsap_no_water_heating_system_default( + epc: EpcPropertyData, +) -> EpcPropertyData: + """RdSAP 10 §10.7 (PDF p.55) — when no water heating system is + identified (`water_heating_code == 999`), substitute the spec + default: an electric immersion heater (single — dual handling not + yet exercised) on a Table 28 row-1 110 L cylinder with Table 29 + row-1 age-band insulation and an assumed cylinder thermostat + (Table 29: "A cylinder thermostat should be assumed to be present + when DHW is from ... an immersion heater ..."). + + Returns `epc` unchanged when a real water heating system is lodged. + Otherwise returns a copy with `has_hot_water_cylinder=True` and the + `sap_heating` fields the WHC-903 electric-immersion + cylinder + cascade reads, so every downstream gate (storage loss, combi-loss + suppression, primary loss) sees the spec default. This mirrors the + Elmhurst engine's worksheet header for the corpus "no system" cert + (WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G). + + Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket) — + no corpus member exercises that combination and the SAP 10.2 Table 2 + loss-factor dispatch only has the factory-foam path plumbed. + """ + 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 + ) + band = (age_band or "")[:1].upper() + thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band) + if thickness_mm is None: + raise UnmappedSapCode( + "rdsap_10_7_default_cylinder_insulation_age_band", age_band + ) + sap_heating = replace( + epc.sap_heating, + water_heating_code=_WHC_ELECTRIC_IMMERSION, + water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE, + cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L, + cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY, + cylinder_insulation_thickness_mm=thickness_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 @@ -5483,6 +5549,12 @@ def cert_to_inputs( parity validation now relies on the Validation Cohort filter (inspection_date ≥ 2025-07-01) rather than a per-cert price override.""" + # RdSAP 10 §10.7 (PDF p.55) — substitute the electric-immersion + + # default-cylinder assumption before any section cascade runs when no + # water heating system is lodged (code 999). Rebinding `epc` here + # 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) dim = dimensions_from_cert(epc) # SAP §3 heat transmission + §2 ventilation cascades — see the # respective `_from_cert` helpers for cert→inputs mapping rules.