diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 12c33830..b3fde06b 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1090,7 +1090,28 @@ class ElmhurstSiteNotesExtractor: if inline_glazing_type is not None: glazing_type = inline_glazing_type else: - glazing_type = " ".join([*prefix, *suffix]).strip() + # The glazing-type phrase always starts with a glazing-start + # word (Single/Double/Triple/Secondary). The FIRST window in + # a building part has `before_start = 0`, so its prefix block + # reaches back into the wrapped windows-table header; the + # third header line's tail tokenises to "value value Proofed + # Shutters" (the "U value / g value / Draught Proofed / + # Permanent Shutters" column titles) and is neither an + # orientation nor a bp fragment, so it survives the pops. + # Drop any prefix fragments preceding the glazing-start word + # so they don't leak into the glazing type. + glazing_start = next( + ( + idx + for idx, frag in enumerate(prefix) + if frag.split(" ", 1)[0] in self._GLAZING_TYPE_PREFIX_WORDS + ), + None, + ) + glazing_prefix = ( + prefix[glazing_start:] if glazing_start is not None else prefix + ) + glazing_type = " ".join([*glazing_prefix, *suffix]).strip() # Building part: inline token wins; otherwise join prefix + suffix. if bp_inline is not None: diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf new file mode 100644 index 00000000..1a15e3da Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf differ diff --git a/backend/documents_parser/tests/test_elmhurst_extractor.py b/backend/documents_parser/tests/test_elmhurst_extractor.py index e0dca443..62c0e743 100644 --- a/backend/documents_parser/tests/test_elmhurst_extractor.py +++ b/backend/documents_parser/tests/test_elmhurst_extractor.py @@ -513,3 +513,112 @@ class TestLightingLedCflUnknown: def test_cfl_count_zero_when_unknown(self, result2: ElmhurstSiteNotes) -> None: assert result2.lighting.cfl_count == 0 + + +class TestWindowsLayoutHeaderRemnant: + """Regression for the first-window glazing-type header leak. + + Summary PDFs preprocessed from `pdftotext -layout` wrap the windows + table header across several lines. The third header line's tail + ("U value / g value / Draught Proofed / Permanent Shutters") tokenises + to "value value Proofed Shutters" and sits directly above the FIRST + window's data row. Because the first window in a building part has + `before_start = 0`, its prefix block reaches back into that header + remnant, which is neither an orientation nor a building-part fragment + and so survived into `glazing_type` as + "value value Proofed Shutters Double between 2002 and 2021". + + Reproduced from `sap worksheets/Recommendations Elmhurst Files/ + cavity_wall_insulation - main wall/before/Summary_001431.pdf` (3 + Manufacturer-data-source windows; only window 0 was corrupted). + """ + + # Faithful reproduction of the tokenised windows section (one page), + # captured verbatim from the Summary PDF above. The header remnant + # "value value Proofed Shutters" precedes window 0's wrapped glazing + # cell ("Double between 2002" / "and 2021"). + _WINDOWS_PAGE = "\n".join([ + "11.0 Windows:", + "Frame Frame Glazing", + "Building", + "U", + "g Draught Permanent", + "W", + "H", + "Area Glazing Type", + "Location", + "Orient. Data-Source", + "Type Factor Gap", + "Part", + "value value Proofed Shutters", + "Double between 2002", + "North", + "0.97 1.00 0.97", + "PVC", + "0.70", + "Main", + "External wall", + "Manufacturer 2.00", + "0.72", + "Yes", + "None", + "and 2021", + "West", + "Double between 2002", + "South", + "2.66 1.00 2.66", + "PVC", + "0.70", + "Main", + "External wall", + "Manufacturer 2.00", + "0.72", + "Yes", + "None", + "and 2021", + "East", + "Double between 2002", + "South", + "2.66 1.00 2.66", + "PVC", + "0.70", + "Main", + "External wall", + "Manufacturer 2.00", + "0.72", + "Yes", + "None", + "and 2021", + "East", + "12.0 Ventilation", + ]) + + @pytest.fixture(scope="class") + def windows(self) -> list[Window]: + return ElmhurstSiteNotesExtractor([self._WINDOWS_PAGE])._extract_windows() + + def test_window_count(self, windows: list[Window]) -> None: + # Arrange / Act / Assert + assert len(windows) == 3 + + def test_first_window_glazing_type_excludes_header_remnant( + self, windows: list[Window] + ) -> None: + # Arrange / Act / Assert — no "value value Proofed Shutters" leak. + assert windows[0].glazing_type == "Double between 2002 and 2021" + + def test_all_windows_share_clean_glazing_type( + self, windows: list[Window] + ) -> None: + # Arrange / Act / Assert — windows 1 and 2 were already clean; + # all three must agree after the fix. + assert [w.glazing_type for w in windows] == [ + "Double between 2002 and 2021" + ] * 3 + + def test_first_window_orientation_unaffected( + self, windows: list[Window] + ) -> None: + # Arrange / Act / Assert — trimming the glazing prefix must not + # disturb orientation extraction (North + West fragments). + assert windows[0].orientation == "North-West" diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 69c0b1ae..ab7889e4 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -68,13 +68,18 @@ _CORPUS_ROOT = ( # Per-pin absolute tolerances. Worksheet `SAP value` lodges 4 d.p., -# (255) total fuel cost 4 d.p., (272) total CO2 4 d.p., (286) Total -# Primary energy kWh/year 4 d.p. — pin at 1e-4 relative to lodged -# precision so any drift outside cascade float noise fires. -_SAP_RESID_ABS_TOLERANCE = 0.001 -_COST_RESID_ABS_TOLERANCE_GBP = 0.01 -_CO2_RESID_ABS_TOLERANCE_KG = 0.1 -_PE_RESID_ABS_TOLERANCE_KWH = 0.1 +# (255)/(355) total fuel cost 4 d.p., (272)/(383) total CO2 4 d.p., +# (286)/(483) Total Primary energy kWh/year 4 d.p. — so the hard floor +# on any residual is ~5e-5 (half a unit in the last printed digit), +# independent of cascade precision. Pin at 1e-4 on EVERY metric (per +# [[feedback-zero-error-strict]] / [[feedback-continuous-sap-tolerance]] +# — basically zero error across continuous SAP, cost, CO2 and PE) so +# any drift beyond PDF print-rounding fires loudly. All 41 variants hold +# at this tolerance; closures re-pin the smaller residual, never widen. +_SAP_RESID_ABS_TOLERANCE = 0.0001 +_COST_RESID_ABS_TOLERANCE_GBP = 0.0001 +_CO2_RESID_ABS_TOLERANCE_KG = 0.0001 +_PE_RESID_ABS_TOLERANCE_KWH = 0.0001 @dataclass(frozen=True) @@ -674,11 +679,90 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # distribution" line — 118.38 kWh billed at electricity factors # (CO2 0.1993, PE 1.760), not heat-network factors — the cascade # doesn't currently meter this. Next follow-up slice. - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-23.6007, expected_pe_resid_kwh=-208.2267), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1435.0874, expected_pe_resid_kwh=+1123.0063), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-98.9235, expected_pe_resid_kwh=-457.5428), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4401.8456, expected_pe_resid_kwh=+111.5798), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2939.6683, expected_pe_resid_kwh=+7481.5658), + # Slice S0380.180 wired the SAP 10.2 Appendix C §C3.2 (PDF p.51) + # heat-network distribution pumping electricity (worksheet (313) = + # 0.01 × [(307)+(310)]; CO2 (372) / PE (472) on Table 12d/12e fuel- + # code-50 monthly factors weighted by the monthly heat profile). + # CH1 (Boilers/Gas) closes FULLY — the (372)/(472) line was its + # entire remaining residual (un-defers the front the predecessor + # handover flagged "don't guess"; the factor source is §C3.2 + + # Table 12f, not an empirical constant). CH3 (HP/Elec) closes its + # distribution component (CO2 −98.92→−75.32, PE −457.54→−249.32); + # the remainder is the code-304 community-HP COP cascade (separate + # follow-up). CH2/CH4/CH6 gain their (372)/(472) component (CO2 + # +23.6, PE +208.2/+208.2/+208.2); their dominant CHP displaced- + # electricity credit residual (Table 12f + block 12b/13b) remains + # for the next slice. Elmhurst DISPLAYS the (372) energy column as + # 0.01 × (307) (space only) but computes emissions on 0.01 × + # (307+310) per the §C3.2 text — verified EXACT line-by-line. + # + # Slice S0380.182 wired the SAP 10.2 §12b/13b community-heating + # "CHP and boilers" (SAP code 302) CO2/PE cascade: per unit of + # network heat fuel H = (307)+(310), the effective generation factor + # = chp_frac × 100/(362) × f_fuel − chp_frac × (361)/(362) × f_disp + # + (1−chp_frac) × 100/(367) × f_fuel, where f_fuel is the Table 12 + # heat-network fuel factor (CHP + back-up boilers burn the same + # community fuel) and f_disp is the Table 12f credit factor for the + # CHP-generated electricity (Elmhurst uses "flexible operation" + # 0.420 CO2 / 2.369 PE). RdSAP 10 §C (p.58) defaults: heat eff 50% / + # electrical eff 25% / boiler eff 80%; CHP frac 0.35 per-cert. Also + # fixed Table 12 heat-network-oil CO2 (codes 53/56 0.298→0.335 per + # Table 12 p.189 — the code-302 oil cascade was the first to use it). + # CH2 (gas) + CH4 (oil) CO2 + PE now EXACT (<1e-4). CH6 (coal) CO2/PE + # shift sign: its worksheet lodges a manual DLF=1.0 (two adjoining + # dwellings) the Summary doesn't carry, so the cascade's DLF=1.45 + # over-scales H — pin + the CH6 SAP −7.49 / cost +£172 are the same + # DLF quirk (separate front, likely pin-forever). CH2/CH4 SAP +0.5277 + # / cost −£12.16 is the heat-network cost/standing residual exposed + # by S0380.175 (cost-side, untouched by this CO2/PE slice). CH3 + # unchanged (code 304 community-HP COP front). + # + # Slice S0380.183 closed the CH2/CH4 HW cost residual: per SAP 10.2 + # §10b the community-heating HW bills at the heat-network rate, not + # the Elmhurst §15.0 "Mains gas" placeholder. Worksheet (342) = + # (310) × the S0380.171 CHP heat-fraction blend (= the same rate as + # space heating (340)), not (310) × 3.48 p/kWh gas. Extended + # `_is_community_heating_hw_from_main` to include code 302 — the + # S0380.182 CO2/PE interception sits above this predicate's branch, + # so it now affects only the cost path. CH2 + CH4 are FULLY EXACT + # on all four metrics. CH6 SAP −7.49→−8.02 / cost +£172.68→+£184.84 + # (its HW now also bills the blend, compounding the DLF=1.0 quirk — + # same root, still the separate CH6 DLF front). + # + # Slice S0380.184 closed CH3 (HP/Elec, code 304) CO2 + PE: an + # electric-HP heat network meters grid electricity, so per SAP 10.2 + # Table 12 note (s)/(t) + block 12b/13b footnote (a) its (367)/(467) + # factor is the MONTHLY Table 12d/12e (fuel code 41) weighted by the + # network heat profile, then × 1/COP — not the annual 0.136/1.501. + # New `_is_heat_network_electric_main` routes the four factor helpers + # through the monthly cascade for code 304 (fuel 41). CH3 was + # SAP/cost EXACT; CO2 −75.32→+0.0000 (= (307+310)/3 × (0.1504−0.136)) + # and PE −249.32→−0.0000 (× (1.5569−1.501)) now EXACT. Non-electric + # heat networks (CH1 gas 51, CH6 coal 54) have no monthly factor set + # → unchanged. + # + # CH6 — PROVEN PIN-FOREVER (Summary-export gap, not a mapper miss). + # CH6's P960 *worksheet input* lodges Distribution Loss = "Two + # adjoining dwellings sharing a single heating system" → Value 0.0 → + # (306) DLF = 1.0000, whereas CH4 lodges "Calculated" → 1.5 → (306) = + # 1.4500. That DLF choice swings SAP / cost / CO2 / PE materially. + # But it is NOT in the Summary PDF: a controlled pair differing ONLY + # by the adjoining-dwellings setting (`CH adjoined dwellings/Summary_ + # 001431 (1) vs (2).pdf`) is byte-identical across every RdSAP INPUT + # field — the two Summaries differ solely in the derived header + # (SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A + # case-insensitive scan of the CH6 Summary for "distribution"/"adjoin" + # returns 0 hits. Since CH4 and CH6 Summaries are themselves identical + # bar fuel type, no Summary-derivable rule can yield CH4=1.45 AND + # CH6=1.0. Closing CH6 would require the P960 worksheet as a mapper + # input or an Elmhurst Summary-export change — neither is available. + # Pin held; do not re-litigate (verified 2026-06-02 with the + # user-supplied adjoining-dwellings pair). + _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0219, expected_cost_resid_gbp=+184.8376, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766), ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 7c6532a9..0ddb4baa 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4143,6 +4143,59 @@ _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = ( frozenset(range(120, 142)) ) +# SAP 10.2 Table 4b gas-boiler code range (PDF p.168). Rows 101-119 are +# "Gas boilers (including mains gas, LPG and biogas)" — 101-109 are +# 1998-or-later, 110-114 pre-1998 fan-assisted flue, 115-119 pre-1998 +# balanced/open flue. The code identifies the boiler TYPE/efficiency, not +# the specific carrier: the same row applies to mains gas, bulk/bottled +# LPG and biogas alike. The older Elmhurst export lodged §14.0 "Fuel +# Type: Mains gas" explicitly, but the newer form leaves §14.0 "Fuel +# Type" empty and lodges only the SAP code (e.g. 104 condensing combi, +# EES "BGW"). For these, §15.0 "Water Heating Fuel Type" names the +# carrier — a combi/boiler heats space + water from the one appliance — +# so it disambiguates mains-gas-vs-LPG. Codes 120-141 (CPSU + range +# cookers) are already covered by +# `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES`. +_GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = ( + frozenset(range(101, 120)) +) + +# SAP10 main-fuel codes in the gas / LPG family — the only carriers a +# Table 4b gas-boiler row (101-119) can have (mains gas, mains gas +# community, bottled/bulk/special-condition LPG). Per +# `_ELMHURST_MAIN_FUEL_TO_SAP10`: mains gas = 26, mains gas community = +# 1, LPG bottled/bulk/special = 5/6/7, "Bulk LPG" = 27. The §15.0 +# water-heating-fuel derivation is gated on the resolved fuel being one +# 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}) + + +def _elmhurst_gas_boiler_main_fuel( + sap_main_heating_code: Optional[int], + water_heating_fuel_code: Optional[int], +) -> Optional[int]: + """Derive a gas/LPG main-fuel code for a Table 4b gas boiler whose + §14.0 "Fuel Type" string is absent (newer Elmhurst export form). + + Returns the §15.0 water-heating fuel code when, and only when, the + SAP main-heating code is a Table 4b gas-boiler row (101-119) AND the + §15.0 fuel resolves to a gas/LPG carrier — the same combi/boiler + heats space + water, so §15.0 names the boiler's carrier. Returns + None otherwise (non-gas-boiler code, or §15.0 lodges a non-gas fuel + such as an electric immersion), leaving the caller to strict-raise. + + Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel + boilers" (PDF p.168) — rows 101-119 are gas-family boilers. + """ + if ( + sap_main_heating_code in _GAS_BOILER_SAP_MAIN_HEATING_CODES + and water_heating_fuel_code in _GAS_LPG_MAIN_FUEL_CODES + ): + return water_heating_fuel_code + return None + # Elmhurst §14.0 "Main Heating EES Code" → Table 32 main fuel code. # Empirically derived from the heating-systems corpus at @@ -4770,6 +4823,19 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES ): main_fuel_int = water_heating_fuel + # Gas / LPG boilers: SAP 10.2 Table 4b codes 101-119 (PDF p.168) + # identify a gas-family boiler but not the specific carrier (mains + # gas vs LPG vs biogas). The newer Elmhurst export leaves §14.0 + # "Fuel Type" empty and lodges only the SAP code (e.g. 104 condensing + # combi, EES "BGW"); the §15.0 "Water Heating Fuel Type" names the + # carrier because the same combi/boiler heats space + water. Adopt it + # only when it resolves to a gas/LPG fuel, so a regular boiler paired + # with an electric immersion (where §15.0 lodges "Electricity") still + # strict-raises rather than mis-billing the gas boiler as electric. + if main_fuel_int is None: + main_fuel_int = _elmhurst_gas_boiler_main_fuel( + mh.main_heating_sap_code, water_heating_fuel, + ) # Solid-fuel main heating: SAP code rows 150-160 (open / closed # room heaters with boiler) and 600-636 (independent solid-fuel # boilers) cover multiple distinct fuels under a single Table 4a diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 364ad23d..6b33c3f7 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -236,6 +236,17 @@ class CalculatorInputs: pumps_fans_primary_factor: Optional[float] = None lighting_primary_factor: Optional[float] = None electric_shower_primary_factor: Optional[float] = None + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity. For community-heating mains the network pump + # energy = 1% of (space + water) heat generated (worksheet (313)); + # its CO2 / PE (worksheet (372)/(472)) bill on Table 12d/12e monthly + # electricity factors (fuel code 50) weighted by the monthly heat + # profile. The energy + effective factors are precomputed in + # cert_to_inputs. 0.0 / None for individually-heated certs (no + # distribution loop) leaves the cascade unchanged. + heat_network_distribution_kwh_per_yr: float = 0.0 + heat_network_distribution_co2_factor_kg_per_kwh: Optional[float] = None + heat_network_distribution_primary_factor: Optional[float] = None # Generation offsets — applied as a cost credit against the ECF # numerator. SAP 10.2 Appendix M: PV self-consumption + export # collapse to a single credit at the export rate (Table 12 code 60). @@ -596,6 +607,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: electric_shower_co2 = ( inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor ) + # SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (372) — electricity + # for pumping water through a heat network's distribution system. + # Zero for individually-heated certs (factor None → 0.0). + heat_network_distribution_co2 = ( + inputs.heat_network_distribution_kwh_per_yr + * (inputs.heat_network_distribution_co2_factor_kg_per_kwh or 0.0) + ) co2 = ( main_heating_co2 + secondary_heating_co2 @@ -603,6 +621,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + pumps_fans_co2 + lighting_co2 + electric_shower_co2 + + heat_network_distribution_co2 ) # SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption # offsets grid imports at the IMPORT CO2 factor (Table 12d weighted @@ -662,6 +681,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + inputs.lighting_kwh_per_yr * lighting_primary_factor + inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor ) + # SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (472) — heat-network + # distribution pumping electricity primary energy (CO2 sister above). + heat_network_distribution_primary_kwh = ( + inputs.heat_network_distribution_kwh_per_yr + * (inputs.heat_network_distribution_primary_factor or 0.0) + ) # SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT # PEF (offsets grid imports); PV exports credit at the EXPORT PEF # ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When @@ -696,6 +721,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_heating_primary_kwh + hot_water_primary_kwh + other_primary_kwh + + heat_network_distribution_primary_kwh - pv_primary_offset_kwh, ) primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0 @@ -738,6 +764,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: "hot_water_co2_kg_per_yr": hot_water_co2, "pumps_fans_co2_kg_per_yr": pumps_fans_co2, "lighting_co2_kg_per_yr": lighting_co2, + "heat_network_distribution_co2_kg_per_yr": heat_network_distribution_co2, + "heat_network_distribution_pe_kwh_per_yr": heat_network_distribution_primary_kwh, "space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0, "hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0, "other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0, diff --git a/domain/sap10_calculator/docs/AGENT_GUIDE.md b/domain/sap10_calculator/docs/AGENT_GUIDE.md new file mode 100644 index 00000000..8edc10c0 --- /dev/null +++ b/domain/sap10_calculator/docs/AGENT_GUIDE.md @@ -0,0 +1,265 @@ +# SAP calculator — agent guide (start here) + +This is the **canonical onboarding doc** for working on the SAP 10.2 / +RdSAP 10 calculator. It is meant to get you productive **without reading +any historical handover**. The `HANDOVER_*.md` files in this directory +are point-in-time session notes (useful for the specific residual they +chase, ignore otherwise). For deep architecture/API see +[`SAP_CALCULATOR.md`](SAP_CALCULATOR.md). + +Three things this doc gives you: (1) the **accuracy bar** for the two +input paths, (2) the **debugging loop**, (3) the **tools & pipeline**. + +--- + +## 0. The one-paragraph mental model + +A cert's data comes in via one of two front-ends — an **Elmhurst Summary +PDF** (site-notes path) or an **EPC-register API JSON** (API path). Both +map to the same typed `EpcPropertyData`, which feeds a deterministic +cascade that reproduces the RdSAP10 engine. Our **ground truth is the +Elmhurst worksheet PDF** (U985 / P960 / dr87) — the per-line `(1)..(286)` +calculation, not the rounded values the EPC register lodges. We pin the +cascade against the worksheet to **abs = 1e-4 on every line ref**. + +--- + +## 1. Accuracy expectations — site-notes vs API + +The worksheet PDF is **always** the target. The EPC register's lodged +SAP/CO2/PE are rounded *and* carry Elmhurst's own residual, so matching +the lodged values is not the goal — matching the worksheet is. + +| Path | Input | When a worksheet PDF exists for the cert | API/site-notes-only (no worksheet) | +|---|---|---|---| +| **Site-notes** | Elmhurst Summary PDF → extractor → `from_elmhurst_site_notes` | **abs = 1e-4** on continuous SAP **and every populated line ref** and cost / CO2 / PE | n/a (we always have the worksheet for site-notes fixtures) | +| **API** | register JSON → `from_api_response` | **abs = 1e-4** on continuous SAP vs the worksheet (same bar as site-notes — the two paths must converge) | **±0.5** SAP vs the lodged register value (fallback only) | + +Three rules that fall out of this: + +- **Cross-mapper parity.** For a cert that has both an API JSON and an + Elmhurst Summary, the two paths must produce SAP within **1e-4 of each + other** *and* of the worksheet. The cascade output (not a structural + EPC diff) is the equivalence check. A divergence localises to one + mapper. +- **No tolerance widening.** A failing 1e-4 pin is a real cascade bug or + a fixture defect — diagnose it, don't relax it. No `rel=`, no `xfail`, + no adaptive ceilings. ΔSAP = 0.07 is **not** "closed". +- **±0.5 is a fallback, not a destination.** It's only for API-only + certs with no worksheet to check against. If you can get a worksheet, + the bar is 1e-4. + +Two documented, deliberate exceptions to "match the spec literal" live +in [`SAP_CALCULATOR.md` §8](SAP_CALCULATOR.md) ("Elmhurst-mirrored spec +divergences") — cases where the BRE-approved Elmhurst engine diverges +from the SAP 10.2 text and we mirror the engine. Add a §8 row only with +≥2-cert evidence. + +--- + +## 2. The tools & pipeline + +### 2.1 The two PDFs per cert + +- **`Summary_NNNNNN.pdf`** — the Elmhurst **site notes / input**. This is + what the assessor lodged: dimensions, fabric, heating system, controls, + cylinder, etc. It is the INPUT, equivalent to the API JSON. +- **The worksheet** — the **ground truth output**, every line ref + `(1)..(286)` to 4 d.p. Three families, all the same format: + - `U985-0001-NNNNNN.pdf` — the 6 gas-combi conformance fixtures. + - `P960-0001-NNNNNN.pdf` — the heating-systems corpus + community heating. + - `dr87-0001-NNNNNN.pdf` — the API-paired cohort ("Additional data with api"). + +### 2.2 The cascade pipeline (site-notes path) + +```python +import subprocess, re +from pathlib import Path +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + cert_to_inputs, cert_to_demand_inputs, local_climate_for_cert, +) +from domain.sap10_calculator.calculator import calculate_sap_from_inputs + +# 1. Summary PDF -> per-page text (pdftotext -layout, one string per page) +def summary_pdf_to_pages(pdf: Path) -> list[str]: + n = int(re.search(r"Pages:\s+(\d+)", + subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True).stdout).group(1)) + pages = [] + for i in range(1, n + 1): + layout = subprocess.run( + ["pdftotext", "-layout", "-f", str(i), "-l", str(i), str(pdf), "-"], + capture_output=True, text=True).stdout + pages.append("\n".join( + tok for line in layout.splitlines() for tok in re.split(r"\s{2,}", line.strip()) if tok)) + return pages + +pages = summary_pdf_to_pages(Path("sap worksheets/.../Summary_NNNNNN.pdf")) +site_notes = ElmhurstSiteNotesExtractor(pages).extract() # -> ElmhurstSiteNotes +epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) # -> EpcPropertyData + +# 2. Two cascades. RATING = SAP/EI rating (UK-avg climate, region 0). +# DEMAND = Current Carbon / Current PE / Fuel Bill (postcode climate, PCDB Table 172). +rating = calculate_sap_from_inputs(cert_to_inputs(epc)) +demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) # climate = local_climate_for_cert(epc) + +rating.sap_score_continuous # un-rounded SAP — pin THIS, not the integer +rating.total_fuel_cost_gbp +rating.co2_kg_per_yr +demand.primary_energy_kwh_per_yr +``` + +Shortcut: `Sap10Calculator().calculate(epc)` runs the rating cascade +(`cert_to_inputs` → `calculate_sap_from_inputs`) in one call. + +### 2.3 The API path + +Identical from `EpcPropertyData` onward — only the front-end changes: + +```python +import json +data = json.loads(Path("tests/domain/sap10_calculator/rdsap/fixtures/golden/.json").read_text()) +epc = EpcPropertyDataMapper.from_api_response(data) # -> EpcPropertyData +# ... same cert_to_inputs / calculate_sap_from_inputs as above +``` + +### 2.4 Section helpers — intermediate line refs + +Every worksheet section has a `
_section_from_cert(epc)` helper +returning a typed result with the line-ref values. Use these to inspect +where a residual originates **without** running the whole cascade +(`postcode_climate=` selects rating vs demand): + +```python +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + water_heating_section_from_cert, # §4 (42)..(65)m + heat_transmission_section_from_cert, # §3 (26)..(37) + internal_gains_section_from_cert, # §5 (66)..(73) + mean_internal_temperature_section_from_cert, # §7 (85)..(94) + space_heating_section_from_cert, # §8 (95)..(99) + fuel_cost_section_from_cert, # §10a (240)..(255) + environmental_section_from_cert, # §12 (261)..(274) + primary_energy_section_from_cert, # §13a (275)..(286) +) +wh = water_heating_section_from_cert(epc) +wh.energy_content_monthly_kwh # (45)m ; wh.output_kwh_per_yr # (62)/(64) +``` + +(Full table of helpers + line refs is in [`SAP_CALCULATOR.md` §1.3](SAP_CALCULATOR.md).) + +### 2.5 Reading the worksheet from the shell + +```bash +# Dump a worksheet line ref (e.g. (217)m water-heater monthly efficiency): +pdftotext -layout "sap worksheets/.../P960-0001-NNNNNN.pdf" - | grep -nE "\(217\)|\(62\)|\(210\)" +# Read a Summary input field (controls, cylinder, fuel): +pdftotext -layout "sap worksheets/.../Summary_NNNNNN.pdf" - | grep -niE "cylinder|control|interlock|fuel" +``` + +### 2.6 Where the test vectors live + +| Set | Location | What | +|---|---|---| +| 6 U985 conformance fixtures | `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_NNNNNN.py` (+ Summary PDFs in `backend/documents_parser/tests/fixtures/`) | Gas-combi certs, every line ref transcribed as `LINE_*` / `DEMAND_LINE_*` constants. Pinned in `worksheet/test_section_cascade_pins.py` + `worksheet/test_e2e_elmhurst_sap_score.py`. | +| Heating-systems corpus | `sap worksheets/heating systems examples//` (Summary + P960) | 41 variants of **one property** with only the heating system changed → any residual is attributable to the heating subsystem. Pinned in `backend/documents_parser/tests/test_heating_systems_corpus.py`. | +| API golden fixtures | `tests/domain/sap10_calculator/rdsap/fixtures/golden/.json` | Register JSON for the API path. | +| API + worksheet pairs | `sap worksheets/Additional data with api//` (Summary + dr87) | Certs that have BOTH an API JSON and a worksheet → cross-mapper parity checks. | + +--- + +## 3. The debugging loop + +When a cert's SAP/cost/CO2/PE is off, **never guess a fix** — walk it. + +1. **Reproduce & decompose.** Build the epc (extractor+mapper, or a + fixture's `build_epc()`), run both cascades, and see **which of the + four outputs** drifts. Cost/CO2/PE drift with the same sign as energy; + isolate the carrier. +2. **Find the section.** Walk the four metrics back to a worksheet + section: SAP off but cost EXACT often means a demand/gains issue; + cost off but energy EXACT means a price/factor issue; CO2/PE off but + cost EXACT means a factor issue. Use the §2.4 section helpers to get + the cascade's intermediate line refs. +3. **Per-line compare vs the worksheet.** `pdftotext -layout` the + worksheet and compare the cascade's `(45)/(56)/(62)/(210)/(217)m/...` + line-by-line against the PDF. The first diverging line ref is the bug. +4. **Localise to a layer.** + - cascade value present in worksheet but cascade has 0 / wrong → **calculator** gap (a spec rule not wired, or a dispatch gate). + - the input field the worksheet used isn't in `epc` → **mapper** (mis-mapped) or **extractor** (didn't capture the Summary field). Audit the Summary PDF for the field first — many lodgements are incomplete and the fixture, not the calculator, is wrong. +5. **Cite the spec.** Find the SAP 10.2 / RdSAP 10 rule (page + line) that + produces the worksheet's number. Confirm the worksheet matches the + spec literal; if it diverges, it's a candidate §8 Elmhurst-mirror + (needs ≥2-cert evidence). **SAP 10.2 only — never 10.3.** +6. **Cross-check vs API (when available).** If the cert has an API JSON + too, run `from_api_response` through the same cascade. If the API path + matches the worksheet but the site-notes path doesn't (or vice-versa), + the bug is in **that mapper**, not the calculator. If both diverge + identically, it's the **calculator/cascade**. +7. **Fix one cause, re-pin smaller.** TDD: one failing AAA test → one + impl → re-pin the (now smaller) residual. A spec-correct fix often + **exposes** the next residual that an offsetting bug was masking — + that's the next slice, not a regression. Don't conflate + `main_heating_category` (often `None` on Elmhurst Table 4b boilers) + with `sap_main_heating_code`. + +### Worked shape (real example: oil 6) + +Residual +3.05 SAP. (1) HW + space both off. (2) §4 HW efficiency. (3) +worksheet (210) space eff = 75 but Table 4b code 126 = 80; (217)m summer += 63 = 68−5 → a −5pp penalty. (4) the Summary lodges control `2101` ("no +thermostatic control of room temperature") → no room thermostat → P960 +header "Boiler Interlock: No". (5) RdSAP 10 §3 + SAP 10.2 Table 4c(2): +no room thermostat ⇒ not interlocked ⇒ −5pp Space+DHW. Fix the +`no_interlock` gate → space+HW fuel EXACT, residual collapses to a single +exposed pump cause (Table 4f footnote a) ×1.3) → next slice. Two slices, +fully closed. + +--- + +## 4. Run the suite + +```bash +PYTHONPATH=/workspaces/model python -m pytest \ + tests/domain/sap10_calculator/ \ + backend/documents_parser/tests/ \ + --no-cov -q -p no:cacheprovider +``` + +Conformance pins only: + +```bash +PYTHONPATH=/workspaces/model python -m pytest \ + tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py \ + tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py \ + backend/documents_parser/tests/test_heating_systems_corpus.py \ + --no-cov -q +``` + +Notes: +- `load_cells` tests pin against the gitignored `*.xlsx` reference + worksheet at repo root; they **skip** when it's absent (CI), run + locally when present. +- All new code passes `pyright` strict, zero errors. Tests use literal + `# Arrange / # Act / # Assert` headers and `abs(x - y) <= tol` (not + `pytest.approx`, which strict-pyright flags). +- Commit one slice per change, with the spec citation in the message. + +--- + +## 5. Spec PDFs on disk + +``` +domain/sap10_calculator/docs/specs/ + sap-10-2-full-specification-2025-03-14.pdf # SAP 10.2 (the methodology) + RdSAP 10 Specification 10-06-2025.pdf # RdSAP 10 (the reduced-data rules) + pcdb10.dat / pcdb_table_*.jsonl # PCDB (boilers, HPs, postcode weather) +``` + +Pages worth bookmarking: SAP 10.2 §7 MIT (p.28-32), Table 4b boiler eff +(p.168), Table 4c efficiency adjustments (p.169), Table 4e controls +(p.171-174), Table 4f auxiliary energy (p.175), Table 12 factors (p.191), +Appendix U region tables (p.124-127). RdSAP 10 §10 water heating (p.54-56, +incl. §10.7 no-water-heating default), Table 28/29 cylinder defaults, +Table 32 prices (p.95). +``` diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md new file mode 100644 index 00000000..e13f4d56 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md @@ -0,0 +1,172 @@ +# Handover — post Slices S0380.177..179 (+ infra/CI work) + +Branch: `feature/per-cert-mapper-validation`. **HEAD `af8e0d94`** +(post merge from main). Predecessor: +[`HANDOVER_POST_S0380_176.md`](HANDOVER_POST_S0380_176.md). + +## TL;DR + +The 41-variant heating-systems corpus is now **36 EXACT + 5 pinned**. +The only remaining residuals are the **5 community-heating (CH) variants** +— all `SAP code 302/301/304` heat-network systems. Everything else +(oil, electric, solid fuel, ASHP/GSHP, PCDB, "no system") is EXACT on +all four metrics (ΔSAP/Δcost/ΔCO2/ΔPE). + +Three closure slices + four infra changes landed this session: + +| Slice / change | HEAD | Scope | +|---|---|---| +| S0380.177 | `5276282d` | **oil 6 boiler interlock from room-thermostat absence.** Control code 2101 ("no thermostatic control of room temperature") ⇒ no room thermostat ⇒ per RdSAP 10 §3 NOT interlocked despite cylinderstat=Yes (P960 "Boiler Interlock: No") ⇒ SAP 10.2 Table 4c(2) −5pp Space+DHW. New `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES={2101,2102}`; `no_interlock` ORs room-thermostat absence with stored-HW cylinderstat absence; Space −5pp leg now fires for Table 4b non-PCDB boilers. | +| S0380.178 | `c054d712` | **oil 6 circulation pump ×1.3 for absent room thermostat.** SAP 10.2 Table 4f footnote a) (PDF p.175) "Multiply by 1.3 if room thermostat is absent" ⇒ 41 × 1.3 = 53.3 kWh = ws (230c). Closes oil 6 FULLY (same root cause as .177). | +| S0380.179 | `f2062a2f` | **RdSAP 10 §10.7 electric-immersion default for "no system".** Cert lodges water code 999 (NON) + "cylinder present: No", but §10.7 substitutes an electric immersion on a Table 28 row-1 110 L cylinder + Table 29 row-1 insulation. New `_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at the top of `cert_to_inputs` when `water_heating_code==999`. One fix closed HW (−594 kWh storage loss) AND the downstream space residual (+228, a HW-gains→MIT artifact). Closes "no system" FULLY. | +| appliances+cooking | `2f039aeb` | Threaded `appliances_kwh_per_yr` + `cooking_kwh_per_yr` (Appendix L L13/L14/L16a + L20) onto `SapResult`/`CalculatorInputs` for ADR-0014 BillDerivation. **Output-only, zero rating drift.** | +| test fixes | `0e484aaa` | Fixed 11 pre-existing CI failures from an absorbed PR: `test_appendix_u.py` signature drift + mislabelled "SAP 10.3"→10.2; `test_table_32.py` re-pinned oil(4)=5.44 / FAME(73)=7.64 to the worksheet-canonical values the table actually uses. | +| corpus PDFs | `d1c87d84` | Committed the 82 heating-corpus PDF fixtures (`sap worksheets/heating systems examples/`) so CI can run the residual pins. | +| **test move** | `d7d5084f` | **Moved all 5 calculator test dirs → `tests/domain/sap10_calculator/`** so CI (which collects `tests/`) runs them. SEE "Test layout changed" below — it changes every command. | + +## ⚠ Test layout changed this session — commands are different now + +The calculator tests **moved** out of `domain/sap10_calculator/.../tests` +into `tests/domain/sap10_calculator/{,worksheet,rdsap,climate,validation}`. +Cross-imports were rewritten `domain.sap10_calculator.worksheet.tests` +→ `tests.domain.sap10_calculator.worksheet`. Any old handover command +that references `domain/sap10_calculator/worksheet/tests/...` is STALE. + +**New full verification command** (replaces the old extended suite): + +```bash +PYTHONPATH=/workspaces/model python -m pytest \ + tests/domain/sap10_calculator/ \ + backend/documents_parser/tests/ \ + --no-cov -q -p no:cacheprovider +``` + +Expected at HEAD: **~2221 pass, 1 skipped, 0 fail** (the 1 skip is the +corpus blocked-variant `skipif`). The cascade-pin / golden / e2e +conformance suites are all under `tests/domain/sap10_calculator/`. + +**Two gotchas:** +1. `load_cells` tests (`tests/domain/sap10_calculator/worksheet/test_{dimensions,ventilation,water_heating}.py`) pin against the gitignored `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root. `_xlsx_loader.load_cells` `pytest.skip()`s when the xlsx is absent — so they run locally and skip in CI. If you're missing the xlsx locally, those skip (not fail). +2. **Uncommitted `pytest.ini` change** (came in with a main pull) REMOVES `tests/` + `domain/sap10_ml/tests` from `testpaths`. HEAD has them; the working tree strips them. This is NOT a slice change — confirm with the user before committing it, because removing `tests/` would un-collect the moved calculator tests. + +## Current residual state at HEAD `af8e0d94` + +### 36 variants EXACT (all four metrics < tolerance) + +``` +ashp, gshp, +electric 1, 2, 3, 5, 6, 7, 8, 9, 11, 12, 13, 14, +oil 1, oil 2, oil 3, oil 4, oil 5, oil 6, oil pcdb 1, oil pcdb 2, oil pcdb 3, +pcdb 1, pcdb 3, +solid fuel 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, +no system +``` + +### 5 community-heating variants pinned + +| Variant | SAP code | ΔSAP_c | Δcost | ΔCO2 | ΔPE | Closure driver | +|---|---:|---:|---:|---:|---:|---| +| CH6 (CHP/Coal) | 302 | −7.4942 | +£172.68 | −2939.67 | +7481.57 | SAP 302 CHP credit + DLF=1.0 P960 quirk | +| CH2 (CHP/Gas) | 302 | +0.5277 | −£12.16 | −1435.09 | +1123.01 | SAP 302 CHP credit (CO2 + PE) | +| CH4 (CHP/Oil) | 302 | +0.5277 | −£12.16 | −4401.85 | +111.58 | SAP 302 CHP credit (CO2) | +| CH3 (HP/Elec) | 304 | +0.0000 | −£0.00 | −98.92 | −457.54 | (372) electrical-distribution + HP COP | +| CH1 (Boilers/Gas) | 301 | +0.0000 | −£0.00 | −23.60 | −208.23 | (372) electrical-distribution factor | + +Blocked tier: **empty**. + +## Open fronts ranked by leverage + +### 1. SAP 302 CHP CO2/PE credit cascade (3 variants — CH2/CH4/CH6) — HIGHEST + +Closes the big CO2/PE residuals on CH2/CH4 AND the −7.49 SAP on CH6 +simultaneously. Spec: block 13b PE (PDF p.153) + 12b CO2 — the +displaced-electricity CHP credit lines (worksheet (363)-(366), +(464)/(466)/(468)): + +``` +Space heating from CHP (307a) × 100 ÷ (362) = ... (363) +less credit emissions −(307a)×(361) ÷ (362) = ... (364) +Water heated by CHP (310a) × 100 ÷ (362) = ... (365) +less credit emissions −(310a)×(361) ÷ (362) = ... (366) +Heat from heat source 2 [(307b)+(310b)] × 100 ÷ (467b) (468) +``` + +RdSAP 10 §C defaults (verified vs CH2/CH4/CH6 worksheet (461)/(462)): +CHP overall eff 75%, heat-to-power 2.0 → heat_eff 50% / electric_eff +25%; boiler eff 80%. The `.172` scaling helper already keys on +`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — add code 302 there once the +split formula is in place; the `.173` predicate +`_is_community_heating_hw_from_main` auto-activates. + +**⚠ UNRESOLVED per-line caveat — walk before hypothesising.** The +Elmhurst worksheet (463) energy column = `spec_formula × 0.8523` +uniformly across non-CHP heat-network rows (the 0.8523 also shows in +CH1 (467)). It is NOT RdSAP 10 / SAP 10.2 spec-derived. Per +[[feedback-spec-floor-skepticism]] / [[feedback-software-no-special-handling]], +DUMP the worksheet per-line and reconcile 0.8523 before baking any CHP +formula into the cascade. Likely 2-3 slices. + +### 2. CH1/CH3 (372)/(472) electrical-distribution CO2/PE — DEFERRED + +CH1/CH3 are SAP + cost EXACT; only CO2/PE remain. Worksheet (372) CO2 +factor = 0.1994 (block 11a) / 0.2114 (block 11b); PE = 1.7591 / 2.1872. +These don't match ANY Table 12 / 12d / 12e weighting derivable from the +(307) or (307)+(310) heating-demand monthly profile. (313) annual = +0.01 × (307) ONLY (verified across 5 variants, NOT 0.01 × (307+310) as +the spec text says). **Don't guess** — reverse-engineer the 0.1994 +factor from a wider variant set or find BRE documentation first. + +### 3. CH6 DLF=1.0 P960 quirk — architectural, likely pin-forever + +P960 input lodges `Distribution Loss: Two adjoining dwellings...` + +`Distribution Loss Value: 0.0` → ws (306) = 1.0000, but the Summary +doesn't carry anything distinguishing CH6 from CH4. Per §C3.1 the +manual-DLF override is legal but not surfaced by the Summary. +Recommendation: pin + document once the CHP credit lands. + +## Discipline (carried from every prior handover) + +- **Per-line walk worksheet → spec → fix.** All 3 slices this session + landed via per-line P960 dumps. Don't form a spec hypothesis without + per-line data (the 0.8523 + 0.1994 factors are the live examples). +- **Spec-floor skepticism cuts BOTH ways** — a spec-correct fix often + EXPOSES the next residual (oil 6 .177→.178; "no system" HW→space). + Apply the spec uniformly; the surfaced residual is the next target. +- **SAP 10.2 ONLY, never 10.3.** +- **Don't conflate `main_heating_category` and `sap_main_heating_code`** + — the Elmhurst mapper leaves `category=None` on Table 4b liquid-fuel + boilers; cascade gates must check both. +- **Target is < 1e-4 vs worksheet** — ΔSAP=0.07 is NOT closed. Re-pin + smaller; never widen tolerance, never xfail. +- **One slice = one commit**, spec citation in the message, trailer + `Co-Authored-By: Claude Opus 4.8 `. + +## Memories to load (in order) + +``` +project-heating-systems-corpus # HEAD af8e0d94, 36 EXACT + 5 pinned +feedback-sap-10-2-only-never-10-3 +feedback-software-no-special-handling +feedback-spec-floor-skepticism +feedback-worksheet-not-api-reference +feedback-spec-citation-in-commits +feedback-verify-handover-claims +feedback-zero-error-strict +feedback-commit-per-slice +feedback-aaa-test-convention +feedback-e2e-validation-philosophy +feedback-abs-diff-over-pytest-approx +feedback-one-e-minus-4-across-the-board +reference-unmapped-sap-code +reference-unmapped-api-code +project-oil-price-spec-divergence +``` + +## Master doc + +Architecture + API + validation: [`SAP_CALCULATOR.md`](SAP_CALCULATOR.md) +(§8 "Elmhurst-mirrored spec divergences" carries .163 HW dual-rate +annual + .164 §12.4.4 summer-immersion). If the CHP 0.8523 multiplier +resolves to an Elmhurst-vs-spec divergence, add §8.3. + +## Good luck. diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md new file mode 100644 index 00000000..0ec6fa30 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md @@ -0,0 +1,114 @@ +# Handover — post S0380.189 (thermal mass parameter / Table 22) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for the +methodology, accuracy bar, and pipeline — this doc only records *what this +session did* and *what is open*. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `e03f08cd` (S0380.189) +- **Baseline:** `2290 passed, 1 skipped, 0 failed` (the skip is the + gitignored xlsx `load_cells` test). Verify with the §4 suite command. + +--- + +## What this session shipped (S0380.185–189) + +| Slice | What | Spec | +|---|---|---| +| **.185** | Recorded the CH6 "pin-forever" proof — distribution-loss is an Elmhurst Summary-export gap, not a mapper miss (controlled adjoining-dwellings pair byte-identical in inputs). | — | +| **.186** | Added `test_golden_cert_pe_co2_matches_worksheet` — pins calc PE/CO2 for the 47 worksheet-backed certs against the dr87 `(286)`/`(272)` at full precision (not lodged register values). | Appendix U | +| **.187** | Appendix M1 §3a `D_PV,m` was missing **electric secondary** space heating `(215)m` — under-credited PV on gas-main+electric-secondary+PV certs. | SAP 10.2 App M1 §3a | +| **.188** | `D_PV,m` used the Appendix L **L12 lighting GAIN** (`= E_L×0.85`) instead of the **L10 lighting ELECTRICITY** `(232)`. Closed the whole PV cohort to 1e-4. Same gain-vs-electricity class as the S0380.73 cooking fix. | SAP 10.2 App L L10/L12, M1 §3a | +| **.189** | **Thermal mass parameter** was hardcoded 250 at all 5 §7/§8 call sites. Now `_thermal_mass_parameter_kj_per_m2_k(epc)` per Table 22. | RdSAP 10 §5.16 Table 22 (p.48) | + +After .186–.188 **all 47 worksheet-backed certs match calc≡worksheet at +<1e-4 on PE and CO2** (the convergence target). See +[`project-golden-coverage-state` memory] for the per-slice detail. + +--- + +## The S0380.189 diagnosis (template for the open work) + +Driven entirely by the **per-line walk** (AGENT_GUIDE §3) against a +**user-simulated worksheet** for a 6035-archetype property: + +- **Fixture:** `sap worksheets/golden fixture debugging/simulated case 1/` + — `Summary_001431 (1).pdf` (input) + `P960-0001-001431 - ….pdf` (worksheet + ground truth). A gas-combi mid-terrace, TFA 128, solid brick **with + internal insulation**, no PV/secondary/cylinder. (The `P960-…` prefix is + just the Elmhurst account id; cert is 001431.) **These PDFs are untracked** + — the user holds them locally. +- **Decompose:** PE +8.78 / SAP −1.76 / CO2 +0.21, *entirely* space-heating + demand (+838 kWh). Fabric `(26–37)`, internal gains `(73)`, climate + `(96)m`, HTC `(39)` all EXACT → localised to **§7 MIT** `(92)` +0.71 °C. +- **Root cause:** TMP hardcoded 250; cert is masonry **with internal + insulation** → RdSAP 10 §5.16 Table 22 = **100**. Wrong TMP → time + constant τ=Cm/(3.6·H) ≈40 h not 16 h → §7 temperature reduction too small + → MIT too high → space heating over-stated. +- **Fix + blast radius:** only golden cert **6035** re-pinned (SAP −6→−2, PE + +46.42→+19.16, CO2 +1.07→+0.42). All other fixtures are masonry-no-internal + → TMP unchanged. + +### Two diagnostic traps that cost false starts (read before debugging §7/§8) + +1. **The worksheet has TWO blocks per dwelling.** The first is the SAP-rating + block (UK-average climate, region 0); the second, under *"CALCULATION OF + EPC COSTS, EMISSIONS AND PRIMARY ENERGY"*, is the postcode block. The + **demand cascade (`cert_to_demand_inputs`) matches the POSTCODE block.** + Comparing calc HTC/MIT to the UK-avg block shows phantom gaps (e.g. HTC + "−10.8"); against the postcode block HTC is exact. +2. **`(84)` Total gains = internal `(73)` + solar `(83)`.** The calc's + `internal_gains_annual_avg_w` is `(73)` only — don't diff it against `(84)` + (a phantom "−248 W" gap that is just solar). + +Use the §2.4 section helpers (`mean_internal_temperature_section_from_cert`, +`space_heating_section_from_cert`, `internal_gains_section_from_cert`, +`local_climate_for_cert`) for the per-line walk. + +--- + +## OPEN — next slices (ranked) + +### 1. Summary-path `main_fuel_type` derivation from the SAP code ← do first +The Elmhurst **Summary PDF has no main-heating fuel field** — only the SAP +code (`14.0 Main Heating1 → Main Heating SAP Code 104`). So +`from_elmhurst_site_notes` leaves `main_fuel_type=''`, and `cert_to_inputs` +raises `MissingMainFuelType` (cert_to_inputs.py `_main_fuel_code`). This +**blocks the Summary path for every gas-combi cert**, including the simulated +case (I injected `main_fuel_type=26` to run the diagnosis). + +Fix: derive the fuel in the mapper (or `_main_fuel_code`) from +`sap_main_heating_code` via SAP 10.2 Table 4b (104 = condensing combi **mains +gas**), mirroring the existing strict-raise → derive pattern. Cite the table. +This is the higher-leverage win — it unblocks the whole site-notes gas-combi +population, not one cert. + +### 2. Pin the simulated 001431 case end-to-end (after #1) +Once #1 lands, the Summary path runs natively. Add it as a site-notes fixture +and pin every line ref at 1e-4. **NB:** with .189 + injected fuel, PE and CO2 +close to 1e-4 but **SAP showed +0.0007** vs a hand-computed target (`100 − +13.95×ECF` from the 4-dp `(257)`=1.6047). That is almost certainly ECF +rounding in the target, not a real gap — but **verify against the worksheet's +own continuous SAP** before declaring it closed (don't trust my hand value). + +### 3. Cert 6035 remaining +19 PE +6035 is API/lodged-only (no worksheet) so it can't be pinned past the lodged +register. The user can reproduce 6035 *exactly* in Elmhurst to get a +worksheet — offer to format the golden JSON +(`tests/domain/sap10_calculator/rdsap/fixtures/golden/6035-7729-2309-0879-2296.json`) +as Elmhurst inputs. With a worksheet, walk the remaining +19 the same way. + +### Carry-over (lower priority) +- `transform.py:973` treats `wall_construction in (5,6)` as timber-frame for + the ventilation structural-ACH split, but Table 22 / `rdsap_uvalues` + classify 6 = **system built (masonry)**, only 5/7/8 are timber/cob/park. + Possible latent ventilation-ACH bug — verify before touching. + +--- + +## Process notes +- One slice = one commit, spec citation in the message, `Co-Authored-By: + Claude Opus 4.8` trailer. AAA tests, `abs(x-y) <= tol` (not `pytest.approx`). +- The golden worksheet PE/CO2 pins (.186) re-pin smaller as gaps close — never + widen. The lodged-residual pins (`test_golden_cert_residual_matches_pin`) + carry the API-vs-register residual and move when the calc improves. diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index cbb1d1df..7e78d99c 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -1,5 +1,10 @@ # SAP 10.2 / RdSAP 10 calculator — module overview +> **New here? Start with [`AGENT_GUIDE.md`](AGENT_GUIDE.md)** — the +> accuracy bar (site-notes vs API), the debugging loop, and the +> tools/pipeline. This file is the deeper architecture + API reference; +> the `HANDOVER_*` files are point-in-time session notes, not onboarding. + Deterministic, bit-faithful replication of the RdSAP10 calculation engine. Validated against the 6 Elmhurst U985 worksheet PDFs at **abs=1e-4 on every line ref** for both the Rating cascade (UK-average climate, used @@ -495,3 +500,34 @@ shape (Table 12 annual where spec literal says monthly), so the gate is implemented under the same `dual-rate → annual on top of monthly` discipline. If a second §12.4.4-eligible cert worksheet diverges from this rule it should be raised against this row before re-tuning. + +### 8.3 Community-heating CHP uses Table 12f "flexible operation" by default + +**Slice S0380.182.** For RdSAP-defaulted community heating with CHP +(SAP code 302) that is **not** in the PCDB, the displaced-electricity +credit (worksheet (364)/(366) CO2 and (464)/(466) PE) needs a Table 12f +(PDF p.196) "fuel factor for electricity generated by CHP". Table 12f +offers three regimes per CHP vintage: + +| Regime | CO2 kg/kWh | PE | Note | +|---|---|---|---| +| export only | 0.394 | 2.345 | | +| **flexible operation** | **0.420** | **2.369** | needs assessor evidence | +| standard | 0.348 | 2.149 | "all other operating regimes" | + +Table 12f's own notes make **standard** the default ("Standard ... should +be used for all other operating regimes of gas CHP plants") and require +submitted evidence for **flexible**. Yet the BRE-approved Elmhurst rdSAP +engine emits **0.420 / 2.369 (flexible)** for these RdSAP-defaulted +community-CHP certs — verified line-by-line against the CH2 (gas) / CH4 +(oil) / CH6 (coal) corpus worksheets (364)/(366)/(464)/(466), all of +which carry 0.4200 CO2 and 2.3690 PE regardless of the community fuel. +RdSAP 10 §C (p.58) is silent on the Table 12f regime, so this is an +engine default not derivable from the spec text. + +Per [[feedback-software-no-special-handling]] / [[feedback-worksheet-not-api-reference]] +we mirror the engine: `_TABLE_12F_CHP_FLEXIBLE_{CO2,PE}` in +`cert_to_inputs`. CH2 + CH4 close to <1e-4 on both CO2 and PE with this +factor; "standard" (0.348/2.149) would leave a residual. If a future +PCDB-listed or evidence-backed CHP cert diverges, raise it against this +row before re-tuning. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5e3f5a77..0d99af01 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -210,7 +210,26 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13 _PENCE_TO_GBP: Final[float] = 0.01 +# RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter (TMP), +# keyed on the construction type of the MAIN building (not extensions): +# 100 kJ/m²K — timber frame, cob, park home (the three types regardless +# of internal insulation); OR masonry (stone, solid brick, +# cavity, system built) WITH internal insulation. +# 250 kJ/m²K — masonry WITHOUT internal insulation. +# This default is the masonry-no-internal value; `_thermal_mass_parameter_ +# kj_per_m2_k` lowers it to 100 for the Table 22 low-mass cases. Unknown / +# unmapped / curtain-wall constructions keep the 250 default (the +# pre-Table-22 behaviour, so no fixture regresses on a missing class). _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}) +# `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 +# (2), cavity+external (6), as-built (4), none (5) keep the masonry 250. +_TMP_INTERNAL_WALL_INSULATION_CODES: Final[frozenset[int]] = frozenset({3, 7}) # SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump # rows. Keyed on RdSAP API `central_heating_pump_age` enum: @@ -898,6 +917,21 @@ def _heat_network_heat_source_efficiency_scaling( return 1.0 / eff +def _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool: + """True when the main is a heat network whose generator runs on grid + electricity (Table 4a code 304 → Table 12 fuel code 41 "heat from + electric heat pump"). Such networks meter electricity, so SAP 10.2 + Table 12 note (s)/(t) + worksheet block 12b/13b footnote (a) require + the MONTHLY Table 12d/12e factors (not the annual average), weighted + by the network heat profile, before the 1/heat-source-eff (1/COP) + scaling. Non-electric heat networks (gas/oil/coal boilers, codes + 51/53/54) have no monthly factor set and keep the annual Table 12 + value.""" + if not _is_heat_network_main(main): + return False + return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None + + def _heat_network_dlf(age_band: Optional[str]) -> float: """RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by age band. Defaults to the K-or-newer value (1.50) when band missing. @@ -913,6 +947,168 @@ def _heat_network_dlf(age_band: Optional[str]) -> float: raise UnmappedSapCode("heat_network_age_band", age_band) +# 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) +# footnote (a) applies the monthly factors weighted by the heat profile. +_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE: Final[int] = 50 +# SAP 10.2 Appendix C §C3.2 (PDF p.51) — pumping energy = 1% of the +# energy required for space and water heating. +_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT: Final[float] = 0.01 + + +def _heat_network_distribution_electricity( + main: Optional[MainHeatingDetail], + space_heating_monthly_kwh: tuple[float, ...], + hot_water_output_monthly_kwh: tuple[float, ...], + efficiency: float, +) -> Optional[tuple[float, float, float]]: + """SAP 10.2 Appendix C §C3.2 (PDF p.51) — electricity for pumping + water through a heat network's distribution system. + + Spec verbatim: "CO2 emissions and Primary Energy associated with the + electricity used for pumping water through the distribution system + are allowed for by adding electrical energy equal to 1% of the + energy required for space and water heating." Worksheet line (313) = + 0.01 × [(307) + (310)]; its CO2 (372) and PE (472) bill on the + Table 12d / 12e monthly factors for fuel code 50 ("electricity for + pumping in distribution network"), weighted by the monthly heat + profile per worksheet footnote (a). + + (307)m = space-heating fuel and (310)m = water-heating fuel: for a + heat network the cascade models the heat-generator efficiency as + 1/DLF, so fuel = q_useful / efficiency = q_useful × DLF. The + monthly weighting of the Table 12d/12e factor is shape-only (the DLF + scalar cancels), and the energy carries the DLF. + + Returns (energy_kwh, co2_factor, pe_factor) for heat-network mains + (Table 4a 301-304 / category 6); None otherwise so the default + 0.0 / None fields leave individually-heated certs unchanged. + + NB Elmhurst's worksheet DISPLAYS the (372) energy column as 0.01 × + (307) (space only) but computes the EMISSIONS on 0.01 × (307+310) + per the §C3.2 text — verified line-by-line against the community- + heating corpus worksheets. We mirror the spec text (space + water). + """ + if not _is_heat_network_main(main) or efficiency <= 0.0: + return None + distribution_monthly_kwh = tuple( + _HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency + for sh, hw in zip( + space_heating_monthly_kwh, hot_water_output_monthly_kwh + ) + ) + energy_kwh = sum(distribution_monthly_kwh) + if energy_kwh <= 0.0: + return None + co2_monthly = co2_monthly_factors_kg_per_kwh( + _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE + ) + pe_monthly = pe_monthly_factors_kwh_per_kwh( + _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE + ) + if co2_monthly is None or pe_monthly is None: + return None + co2_factor = sum( + kwh * f for kwh, f in zip(distribution_monthly_kwh, co2_monthly) + ) / energy_kwh + pe_factor = sum( + kwh * f for kwh, f in zip(distribution_monthly_kwh, pe_monthly) + ) / energy_kwh + return (energy_kwh, co2_factor, pe_factor) + + +# SAP 10.2 Table 12 fuel code 302's worksheet path. Community heating +# "CHP and boilers" (Table 4a code 302). +_SAP_CODE_COMMUNITY_CHP_AND_BOILERS: Final[int] = 302 + +# SAP 10.2 Table 12f (PDF p.196) — fuel factors for the electricity +# GENERATED BY CHP (the displaced-grid credit, worksheet (364)/(464)). +# The BRE-approved Elmhurst rdSAP engine applies the "flexible +# operation" row (0.420 kg CO2/kWh, 2.369 PE) to RdSAP-defaulted +# community CHP that is not in the PCDB — verified line-by-line against +# the CH2/CH4/CH6 corpus worksheets (363)..(366) / (463)..(466). Table +# 12f's own notes make "standard" (0.348 / 2.149) the default and +# require assessor evidence for "flexible"; we mirror the certified +# engine per [[feedback-software-no-special-handling]] (documented as a +# spec divergence in SAP_CALCULATOR.md §8). +_TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH: Final[float] = 0.420 +_TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH: Final[float] = 2.369 + +# RdSAP 10 §C (PDF p.58) heat-network CHP defaults when the network is +# not in the PCDB: CHP overall efficiency 75% with heat-to-power ratio +# 2.0 → heat efficiency 50% (worksheet (362)) + electrical efficiency +# 25% (worksheet (361)); back-up boiler efficiency 80% (worksheet +# (367)). The CHP heat fraction (0.35 default) is per-cert via +# `community_heating_chp_fraction`. +_HEAT_NETWORK_CHP_HEAT_EFFICIENCY: Final[float] = 0.50 +_HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY: Final[float] = 0.25 +_HEAT_NETWORK_CHP_BOILER_EFFICIENCY: Final[float] = 0.80 + + +def _heat_network_code_302_effective_factor( + main: Optional[MainHeatingDetail], + *, + primary_energy: bool, +) -> Optional[float]: + """SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community + heating "CHP and boilers" (SAP code 302) — the effective per-kWh + factor to apply to the network heat fuel [(307) + (310)]. + + Per unit of network heat fuel H = (307) + (310), the worksheet sums: + + CHP fuel (363)/(463) = chp_frac × 100/(362) × f_fuel + less credit (364)/(464) = −chp_frac × (361)/(362) × f_disp + boiler fuel (368)/(468) = (1−chp_frac) × 100/(367) × f_fuel + + where f_fuel is the Table 12 heat-network fuel factor (the CHP unit + and the back-up boilers burn the same community fuel — verified vs + CH2 gas / CH4 oil / CH6 coal worksheets) and f_disp is the Table 12f + credit factor for the electricity the CHP generates. RdSAP 10 §C + defaults: (362) heat eff 50%, (361) electrical eff 25%, (367) boiler + eff 80%. + + Returns the blended factor for code-302 mains with the CHP-split + fields populated; None otherwise so callers fall through to the + existing single-fuel / heat-source-efficiency-scaling path. + + NB the worksheet PDF DISPLAYS the (368)/(468) boiler emissions + rounded/mis-aligned, but the (373)/(473)/(386)/(486) totals + reconcile only with the boiler at the FULL Table 12 fuel factor — + verified EXACT. + """ + if ( + main is None + or main.sap_main_heating_code != _SAP_CODE_COMMUNITY_CHP_AND_BOILERS + ): + return None + chp_fraction = main.community_heating_chp_fraction + boiler_fuel_code = main.community_heating_boiler_fuel_type + if chp_fraction is None or boiler_fuel_code is None: + return None + if primary_energy: + fuel_factor = primary_energy_factor(boiler_fuel_code) + displaced_factor = _TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH + else: + fuel_factor = co2_factor_kg_per_kwh(boiler_fuel_code) + displaced_factor = _TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH + boiler_fraction = 1.0 - chp_fraction + return ( + chp_fraction + * (1.0 / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY) + * fuel_factor + - chp_fraction + * ( + _HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY + / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY + ) + * displaced_factor + + boiler_fraction + * (1.0 / _HEAT_NETWORK_CHP_BOILER_EFFICIENCY) + * fuel_factor + ) + + @dataclass(frozen=True) class PriceTable: """Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and @@ -1334,24 +1530,26 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]: def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: """True iff the cert's WHC routes HW from the main heating system - (codes 901 / 902 / 914) AND the main is a single-source heat - network with a registered heat-source efficiency - (`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — currently SAP code 301 - boilers and 304 HP). + (codes 901 / 902 / 914) AND the main is a heat network the cascade + can cost/emission-rate: a registered single-source heat-source + efficiency (`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — SAP code 301 + boilers / 304 HP) OR code 302 (CHP and boilers). Elmhurst Summary §15.0 lodges `water_heating_fuel_type = "Mains gas"` on community-heating certs regardless of the actual heat-network source — without this guard the HW cost / CO2 / PE bills via the Mains-gas Table 12 code (3.48 p/kWh / 0.21 / 1.13) instead of the - heat-network code (4.24 p/kWh / Table 12 code 41 / 51). + heat-network rate. - SAP code 302 (CHP+boilers) is excluded because the 35%/65% split - requires the displaced-electricity credit line per spec block 13b - (464)/(466) on the HW side — same constraint as `_main_heating_ - co2_factor_kg_per_kwh` (S0380.172). Routing HW through main for - SAP 302 without the credit cascade would regress CO2 / PE; both - the SH and HW paths converge in a single follow-up slice that - wires the CHP credit + boiler-side factor split. + SAP code 302 (CHP+boilers) was previously excluded because the + 35%/65% split needs the displaced-electricity credit line (spec + block 12b/13b (364)/(366)/(464)/(466)). S0380.182 wired that credit + via `_heat_network_code_302_effective_factor`, which intercepts the + HW CO2/PE helpers ABOVE this predicate's branch — so including 302 + here now affects only the COST path, routing HW cost through + `_fuel_cost_gbp_per_kwh(main)` = the S0380.171 CHP heat-fraction + blend (the same rate as space heating, worksheet (342) = (310) × + blend). Closes the CH2/CH4 HW cost residual (S0380.183). """ if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False @@ -1359,7 +1557,10 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: if not _is_heat_network_main(main): return False code = main.sap_main_heating_code if main is not None else None - return isinstance(code, int) and code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY + return isinstance(code, int) and ( + code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY + or code == _SAP_CODE_COMMUNITY_CHP_AND_BOILERS + ) def _main_heating_efficiency(epc: EpcPropertyData) -> float: @@ -2109,24 +2310,39 @@ def _pv_eligible_demand_monthly_kwh( electric_shower_monthly_kwh: tuple[float, ...], pumps_fans_monthly_kwh: tuple[float, ...], main_1_fuel_monthly_kwh: tuple[float, ...], + secondary_fuel_monthly_kwh: tuple[float, ...], hot_water_monthly_kwh: tuple[float, ...], main_fuel_code_table_12: Optional[int], + secondary_fuel_code_table_12: Optional[int], water_heating_fuel_code_table_12: Optional[int], ) -> tuple[float, ...]: """SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand D_PV,m. Always includes lighting + appliances + cooking + electric - shower + pumps & fans. Includes E_space,m only when the main - heating fuel is electricity at the standard tariff (codes 30, 32, - 34, 35, 38 per spec). Includes E_water,m only when the water - heating fuel code is 30 (standard electricity) per spec. + shower + pumps & fans. Includes E_space,m (main AND secondary space + heating) only for the electric tariffs eligible for PV self-use + (codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when + the water heating fuel code is 30 (standard electricity) per spec. + + Secondary space heating is included on the same footing as main: + Appendix M1 §3a counts E_space,m as the dwelling's total electric + space-heating demand, which for a gas-main / electric-secondary + dwelling is the (215)m secondary fuel. Omitting it understates + D_PV,m in the heating months only — depressing the monthly β → + onsite split and under-crediting PV primary energy (the calc-vs- + worksheet (233a) gap localised on the cohort-2 gas+PV certs: + cert 3136 onsite 726.9 → 790.3 vs worksheet 792.1). The off-peak immersion × (243) Ewater branch and the Appendix G4 PV diverter adjustment are deferred — current cohort fixtures don't exercise them.""" - include_space = ( + include_main_space = ( main_fuel_code_table_12 is not None and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES ) + include_secondary_space = ( + secondary_fuel_code_table_12 is not None + and secondary_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES + ) include_water = ( water_heating_fuel_code_table_12 is not None and water_heating_fuel_code_table_12 in _PV_ELIGIBLE_WATER_HEATING_FUEL_CODES @@ -2140,8 +2356,10 @@ def _pv_eligible_demand_monthly_kwh( + electric_shower_monthly_kwh[m] + pumps_fans_monthly_kwh[m] ) - if include_space: + if include_main_space: d += main_1_fuel_monthly_kwh[m] + if include_secondary_space: + d += secondary_fuel_monthly_kwh[m] if include_water: d += hot_water_monthly_kwh[m] monthly.append(d) @@ -2535,6 +2753,13 @@ def _main_heating_co2_factor_kg_per_kwh( - zero-fuel cases (sum monthly_kwh == 0 → effective factor None; annual factor is the safe degenerate value) """ + # SAP 10.2 §12b — community-heating CHP+boilers (code 302): the + # blended CHP-credit + boiler generation CO2 factor (S0380.182). + code_302_co2 = _heat_network_code_302_effective_factor( + main, primary_energy=False, + ) + if code_302_co2 is not None: + return code_302_co2 if not _is_electric_main(main): # Heat-network mains (SAP codes 301 / 304) are non-electric per # `_is_electric_main` but require a heat-source-efficiency scaling @@ -2542,10 +2767,18 @@ def _main_heating_co2_factor_kg_per_kwh( # heat_source_eff × Table 12 CO2 factor. The cascade meters # network_input directly so scale the factor by 1/eff to land at # the spec's fuel-input × factor. - return ( - _co2_factor_kg_per_kwh(main) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + hn_fuel = _main_fuel_code(main) + if _is_heat_network_electric_main(main) and hn_fuel is not None: + # Electric-HP heat network (code 304 / fuel 41): the HP runs + # on grid electricity → MONTHLY Table 12d factors weighted by + # the network heat profile, then × 1/COP (S0380.184). + monthly = _effective_monthly_co2_factor( + main_fuel_monthly_kwh, hn_fuel, + ) + if monthly is not None: + return monthly * scaling + return _co2_factor_kg_per_kwh(main) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_co2_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -2600,6 +2833,13 @@ def _main_heating_primary_factor( Fallback to annual `primary_energy_factor` for non-electric mains and the same edge cases as the CO2 helper (no Table 12a row, unknown dual-rate codes, zero-fuel).""" + # SAP 10.2 §13b — community-heating CHP+boilers (code 302): the + # blended CHP-credit + boiler generation PE factor (S0380.182). + code_302_pe = _heat_network_code_302_effective_factor( + main, primary_energy=True, + ) + if code_302_pe is not None: + return code_302_pe fuel = _main_fuel_code(main) if not _is_electric_main(main): # PE-side mirror of `_main_heating_co2_factor_kg_per_kwh` @@ -2607,10 +2847,17 @@ def _main_heating_primary_factor( # (467) = network_input × 100 / heat_source_eff × Table 12 PE # factor; cascade meters network_input directly so scale by # 1/eff at lookup time. - return ( - primary_energy_factor(fuel) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + if _is_heat_network_electric_main(main) and fuel is not None: + # Electric-HP heat network (code 304 / fuel 41): MONTHLY + # Table 12e factors weighted by the network heat profile, + # then × 1/COP (S0380.184). + monthly = _effective_monthly_pe_factor( + main_fuel_monthly_kwh, fuel, + ) + if monthly is not None: + return monthly * scaling + return primary_energy_factor(fuel) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_pe_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -2858,6 +3105,16 @@ def _hot_water_co2_factor_kg_per_kwh( monthly HW fuel kWh — the calculator uses an annual-flat HW efficiency so the SHAPE of fuel monthly is identical to demand monthly, and `_effective_monthly_co2_factor` is shape-only).""" + # SAP 10.2 §12b — community-heating CHP+boilers (code 302) HW from + # main: the same blended CHP-credit + boiler generation CO2 factor + # as SH (S0380.182). Gated on WHC ∈ {901, 902, 914} so immersion- + # heated DHW on a CHP network keeps the lodged electric factor. + if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + code_302_co2 = _heat_network_code_302_effective_factor( + _water_heating_main(epc), primary_energy=False, + ) + if code_302_co2 is not None: + return code_302_co2 # Community heating + WHC ∈ {901, 902, 914}: HW heat is delivered # through the heat-network main, so HW CO2 must read the same # Table 12 heat-network code factor as SH, scaled by 1/heat_source_ @@ -2865,10 +3122,18 @@ def _hot_water_co2_factor_kg_per_kwh( # gas" is an Elmhurst placeholder that mis-routes the lookup. if _is_community_heating_hw_from_main(epc): main = _water_heating_main(epc) - return ( - _co2_factor_kg_per_kwh(main) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + hn_fuel = _main_fuel_code(main) + if _is_heat_network_electric_main(main) and hn_fuel is not None: + # Electric-HP heat network HW (code 304 / fuel 41): MONTHLY + # Table 12d factors weighted by the HW profile, × 1/COP + # (S0380.184) — mirror of the SH branch. + monthly = _effective_monthly_co2_factor( + hw_monthly_kwh, hn_fuel, + ) + if monthly is not None: + return monthly * scaling + return _co2_factor_kg_per_kwh(main) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_CO2_KG_PER_KWH @@ -2906,16 +3171,33 @@ def _hot_water_primary_factor( exactly to match the Elmhurst worksheet's (278) annual factor. The 41-variant heating-systems corpus closes its HW PE residual +25/+48 → 0 with this gate.""" + # SAP 10.2 §13b — community-heating CHP+boilers (code 302) HW from + # main: same blended CHP-credit + boiler generation PE factor as SH + # (S0380.182). Gated on WHC ∈ {901, 902, 914}. + if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + code_302_pe = _heat_network_code_302_effective_factor( + _water_heating_main(epc), primary_energy=True, + ) + if code_302_pe is not None: + return code_302_pe # Mirror of `_hot_water_co2_factor_kg_per_kwh` community-heating # branch (S0380.173): WHC ∈ {901, 902, 914} on a heat-network main # routes HW PE through the same Table 12 heat-network code as SH, # scaled by 1/heat_source_eff per spec block 13a (463)/(467). if _is_community_heating_hw_from_main(epc): main = _water_heating_main(epc) - return ( - primary_energy_factor(_main_fuel_code(main)) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + hn_fuel = _main_fuel_code(main) + if _is_heat_network_electric_main(main) and hn_fuel is not None: + # Electric-HP heat network HW (code 304 / fuel 41): MONTHLY + # Table 12e factors weighted by the HW profile, × 1/COP + # (S0380.184) — mirror of the SH branch. + monthly = _effective_monthly_pe_factor( + hw_monthly_kwh, hn_fuel, + ) + if monthly is not None: + return monthly * scaling + return primary_energy_factor(_main_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF @@ -3006,6 +3288,30 @@ def _int_or_none(value: object) -> Optional[int]: return value if isinstance(value, int) else None +def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: + """RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from + the MAIN building's wall construction. + + Timber frame / cob / park home → 100 kJ/m²K regardless of insulation. + Masonry (stone, solid brick, cavity, system built) → 100 with internal + insulation, else 250. Unknown / unmapped / curtain-wall constructions + fall back to the masonry default (250). See the Table 22 constant + comments above for the `wall_construction` / `wall_insulation_type` + code sets. TMP feeds the §7 time constant τ = Cm/(3.6·H); a wrong + (too-high) TMP slows the cooling rate, under-cuts the §7 temperature + reduction, and over-states mean internal temperature → space heating. + """ + parts: list[SapBuildingPart] = epc.sap_building_parts or [] + 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: + 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 + return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K + + @dataclass(frozen=True) class _VentilationCounts: open_flues: int = 0 @@ -3250,7 +3556,7 @@ def mean_internal_temperature_section_from_cert( ), monthly_total_gains_w=monthly_total_gains_w, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=_control_type(main), responsiveness=_responsiveness(main, tariff=tariff), @@ -3349,7 +3655,7 @@ def space_cooling_section_from_cert( monthly_external_temperature_c=monthly_external_temp_c, monthly_total_gains_w=(0.0,) * 12, total_floor_area_m2=dim.total_floor_area_m2, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), cooled_area_fraction=0.0, intermittency_factor=0.25, ) @@ -5817,15 +6123,36 @@ def cert_to_inputs( internal_gains_result.total_internal_gains_monthly_w ) lighting_kwh = internal_gains_result.lighting_kwh_per_yr - # Watts → kWh via n_days_in_month × 24 hours / 1000 W per kWh. - # Appendix M1 §3a D_PV,m needs each of these monthly so the - # PV-eligible-demand assembly downstream can sum them in kWh. - lighting_monthly_kwh = tuple( + # SAP 10.2 Appendix M1 §3a (p.93): D_PV,m sums E_L,m — the lighting + # ELECTRICITY (Appendix L eq L10, = line (232)) — NOT the L12 + # internal heat gain G_L,m = E_L,m × 0.85 (spec: "assuming 15%" of + # lighting energy does not become internal heat). `lighting_ + # monthly_w` is the L12 gain, so converting it W→kWh yields + # 0.85 × E_L,m and understates D_PV by 15% of lighting — depressing + # the monthly β onsite split and under-crediting PV primary energy + # uniformly across the year (the residual left after S0380.187 on + # the gas+PV certs: cert 3136 onsite 790.3 → 792.1 vs worksheet + # 792.1). Recover E_L,m by scaling the (shape-identical) gain + # profile to the annual E_L `lighting_kwh_per_yr` — mirroring the + # (219)m hot-water scale-to-annual below. Same mismatch the cooking + # term hit in S0380.73 (L18 gain vs L20 electricity); appliances + # need no scaling (G_A = E_A, no 0.85 factor). Magnitude-only: the + # shape-weighted lighting CO2/PE factor (Σkwh×f/Σkwh) is unchanged. + lighting_gain_monthly_kwh = tuple( w * d * 24.0 / 1000.0 for w, d in zip( internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH ) ) + _lighting_gain_total = sum(lighting_gain_monthly_kwh) + lighting_monthly_kwh = ( + tuple( + g / _lighting_gain_total * lighting_kwh + for g in lighting_gain_monthly_kwh + ) + if _lighting_gain_total > 0.0 + else lighting_gain_monthly_kwh + ) appliances_monthly_kwh = tuple( w * d * 24.0 / 1000.0 for w, d in zip( @@ -5890,7 +6217,7 @@ def cert_to_inputs( ), monthly_total_gains_w=monthly_total_gains_w, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=control_type_value, responsiveness=responsiveness_value, @@ -5986,7 +6313,7 @@ def cert_to_inputs( monthly_external_temperature_c=monthly_external_temp_c, monthly_total_gains_w=(0.0,) * 12, total_floor_area_m2=dim.total_floor_area_m2, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), cooled_area_fraction=0.0, intermittency_factor=0.25, ) @@ -6062,11 +6389,13 @@ def cert_to_inputs( pumps_fans_kwh, _DAYS_IN_MONTH, ), main_1_fuel_monthly_kwh=energy_requirements_result.main_1_fuel_monthly_kwh, + 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) 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, @@ -6125,6 +6454,16 @@ def cert_to_inputs( tariff=_rdsap_tariff(epc), ) + _hw_extra_standing + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity (worksheet (313)/(372)/(472)). None for + # individually-heated certs. + heat_network_distribution = _heat_network_distribution_electricity( + main, + space_heating_result.total_space_heating_monthly_kwh, + hw_monthly_kwh_for_factors, + eff, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -6158,7 +6497,7 @@ def cert_to_inputs( responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=_control_temperature_adjustment_c(main), - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), main_heating_efficiency=eff, hot_water_kwh_per_yr=hw_kwh, pumps_fans_kwh_per_yr=pumps_fans_kwh, @@ -6214,6 +6553,21 @@ def cert_to_inputs( epc, energy_requirements_result.secondary_fuel_monthly_kwh, ), hot_water_co2_factor_kg_per_kwh=hw_co2_factor, + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity (worksheet (313)/(372)/(472)). 0.0 / None + # on individually-heated certs. + heat_network_distribution_kwh_per_yr=( + heat_network_distribution[0] + if heat_network_distribution is not None else 0.0 + ), + heat_network_distribution_co2_factor_kg_per_kwh=( + heat_network_distribution[1] + if heat_network_distribution is not None else None + ), + heat_network_distribution_primary_factor=( + heat_network_distribution[2] + if heat_network_distribution is not None else None + ), # SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps, # lighting, and the electric-shower end-use all bill via the # "All other uses" row → on off-peak tariffs blend the high / diff --git a/domain/sap10_calculator/tables/table_12.py b/domain/sap10_calculator/tables/table_12.py index b6248317..2c884128 100644 --- a/domain/sap10_calculator/tables/table_12.py +++ b/domain/sap10_calculator/tables/table_12.py @@ -192,8 +192,14 @@ CO2_KG_PER_KWH: Final[dict[int, float]] = { 30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136, 38: 0.136, 40: 0.136, 39: 0.136, 60: 0.136, 36: 0.136, # Heat networks - 51: 0.210, 52: 0.241, 53: 0.298, 54: 0.375, 55: 0.269, - 56: 0.298, 57: 0.036, 58: 0.018, + # Heat-network oil (code 53 "assumes 'gas oil'") and mineral-oil/ + # biodiesel boilers (code 56) carry 0.335 kg CO2/kWh per SAP 10.2 + # Table 12 (p.189) — NOT the individual-appliance heating-oil factor + # (code 4 = 0.298). (Fixed in S0380.182 when the code-302 CHP CO2 + # cascade first exercised heat-network oil; PE 1.180 was already + # correct.) + 51: 0.210, 52: 0.241, 53: 0.335, 54: 0.375, 55: 0.269, + 56: 0.335, 57: 0.036, 58: 0.018, 41: 0.136, 42: 0.015, 43: 0.029, 44: 0.024, 45: 0.015, 46: 0.011, 47: 0.011, 48: 0.136, 49: 0.136, 50: 0.0, diff --git a/sap worksheets/golden fixture debugging/simulated case 1/P960-0001-001431 - 2026-06-02T221203.958.pdf b/sap worksheets/golden fixture debugging/simulated case 1/P960-0001-001431 - 2026-06-02T221203.958.pdf new file mode 100644 index 00000000..04de6151 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 1/P960-0001-001431 - 2026-06-02T221203.958.pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 1/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 1/Summary_001431 (1).pdf new file mode 100644 index 00000000..1a15e3da Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 1/Summary_001431 (1).pdf differ 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 f086cd36..40ba7aff 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -41,8 +41,11 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] + _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] + _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] + _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] @@ -56,6 +59,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] _space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] + _thermal_mass_parameter_kj_per_m2_k, # pyright: ignore[reportPrivateUsage] _water_efficiency_with_category_inherit, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] cert_to_demand_inputs, @@ -119,6 +123,64 @@ 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. + (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 + # Masonry WITH internal insulation (ins 3 = internal, 7 = + # filled cavity + internal) → low-mass 100. + (3, 3, 100.0), # solid brick + internal + (3, 7, 100.0), # solid brick + filled cavity + internal + (4, 3, 100.0), # cavity + internal + (6, 3, 100.0), # system built + internal + (1, 3, 100.0), # stone granite + internal + # Masonry WITHOUT internal insulation → high-mass 250. External + # (1), filled cavity (2), cavity+external (6), as-built (4) all + # leave the structural mass coupled. + (3, 4, 250.0), # solid brick, as-built + (4, 2, 250.0), # cavity, filled + (4, 1, 250.0), # cavity, external insulation (NOT internal) + (4, 6, 250.0), # cavity + external + (1, 4, 250.0), # stone, as-built + (6, 4, 250.0), # system built, as-built (Table 22 lists it as masonry) + # Unmapped / curtain (9) / unknown (10) → masonry default 250 + # (pre-Table-22 behaviour; no fixture regresses on a missing class). + (9, 4, 250.0), # curtain wall + (10, 4, 250.0), # unknown + ], +) +def test_thermal_mass_parameter_follows_rdsap_table_22( + wall_construction: int, wall_insulation_type: int, expected_tmp: float +) -> None: + # Arrange — a single-part dwelling carrying the wall construction + + # insulation under test (RdSAP 10 §5.16 Table 22, PDF p.48). + 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( + wall_construction=wall_construction, + wall_insulation_type=wall_insulation_type, + ), + ], + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + ), + ) + + # Act + tmp: float = _thermal_mass_parameter_kj_per_m2_k(epc) + + # Assert + assert abs(tmp - expected_tmp) <= 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 @@ -153,6 +215,168 @@ 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_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 + # pumping electricity = 1% of the (space + water) heat generated, + # i.e. 0.01 × [(307) + (310)] where (307)m/(310)m = (space_demand + + # hw_output) / efficiency. Its CO2 (372) / PE (472) bill on the + # Table 12d / 12e monthly factors for fuel code 50 ("electricity for + # pumping in distribution network"), weighted by the monthly heat + # profile per worksheet footnote (a). + from domain.sap10_calculator.tables.table_12 import ( + co2_monthly_factors_kg_per_kwh, + pe_monthly_factors_kwh_per_kwh, + ) + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + space = (1000.0, 800.0, 600.0, 400.0, 200.0, 0.0, + 0.0, 0.0, 0.0, 300.0, 700.0, 1000.0) + hw = (200.0,) * 12 + efficiency = 1.0 / 1.45 # heat network models efficiency as 1/DLF + + # Act + result = _heat_network_distribution_electricity(main, space, hw, efficiency) + + # Assert — energy = 0.01 × Σ((space + hw) / eff) = 0.01 × 7400 × 1.45; + # factors = code-50 monthly weighted by the (space + hw) heat profile. + assert result is not None + energy_kwh, co2_factor, pe_factor = result + distribution_monthly = tuple( + 0.01 * (s + w) / efficiency for s, w in zip(space, hw) + ) + co2_monthly = co2_monthly_factors_kg_per_kwh(50) + pe_monthly = pe_monthly_factors_kwh_per_kwh(50) + assert co2_monthly is not None + assert pe_monthly is not None + expected_energy = 0.01 * (5000.0 + 2400.0) / efficiency + expected_co2_factor = sum( + d * f for d, f in zip(distribution_monthly, co2_monthly) + ) / expected_energy + expected_pe_factor = sum( + d * f for d, f in zip(distribution_monthly, pe_monthly) + ) / expected_energy + assert abs(energy_kwh - expected_energy) <= 1e-9 + assert abs(energy_kwh - 0.01 * 7400.0 * 1.45) <= 1e-9 + assert abs(co2_factor - expected_co2_factor) <= 1e-9 + assert abs(pe_factor - expected_pe_factor) <= 1e-9 + + +def test_heat_network_code_302_chp_effective_factor_per_sap_10_2_block_12b_13b() -> None: + # Arrange — community heating "CHP and boilers" (SAP code 302) on + # the RdSAP 10 §C (PDF p.58) defaults: CHP heat frac 0.35, heat eff + # 50% / electrical eff 25%, boiler eff 80%. CH2-style gas network + # (community_heating_boiler_fuel_type = 51 → Table 12 gas 0.210 CO2 + # / 1.130 PE). SAP 10.2 §12b/13b effective generation factor: + # chp×100/(362)×f − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f + # with f_disp = Table 12f flexible operation (0.420 CO2 / 2.369 PE). + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=302, + community_heating_chp_fraction=0.35, + community_heating_boiler_fuel_type=51, + ) + + # Act + co2 = _heat_network_code_302_effective_factor(main, primary_energy=False) + pe = _heat_network_code_302_effective_factor(main, primary_energy=True) + + # Assert — gas: 0.35×2.0×0.210 − 0.35×0.5×0.420 + 0.65×1.25×0.210 + # = 0.147 − 0.0735 + 0.170625 = 0.244125 (matches the + # CH2 worksheet (386) generation factor); PE mirror with 1.130 / + # 2.369 = 1.29455. + assert co2 is not None + assert pe is not None + assert abs(co2 - 0.244125) <= 1e-9 + assert abs(pe - 1.29455) <= 1e-9 + + +def test_is_heat_network_electric_main_true_only_for_electric_hp_network() -> None: + # Arrange — code 304 community heat pump (Table 12 fuel 41 = "heat + # from electric heat pump", which HAS monthly Table 12d/12e factors) + # vs code 301 community gas boilers (fuel 51, annual-only). SAP 10.2 + # Table 12 note (s)/(t): grid-electricity factors vary monthly, so + # the HP network must use Table 12d/12e; the gas-boiler network keeps + # the annual factor. + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=41, # Table 12 fuel 41 = heat from electric HP + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=304, + ) + gas_boiler_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=51, # Table 12 fuel 51 = heat from gas boilers + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act / Assert + assert _is_heat_network_electric_main(hp_main) is True + assert _is_heat_network_electric_main(gas_boiler_main) is False + assert _is_heat_network_electric_main(None) is False + + +def test_heat_network_code_302_effective_factor_none_for_non_302_main() -> None: + # Arrange — a code-301 heat-network boiler main (no CHP split). The + # §12b/13b CHP+boilers blend applies only to code 302; code 301 + # routes through the 1/heat-source-eff scaling path instead. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act / Assert + assert _heat_network_code_302_effective_factor(main, primary_energy=False) is None + assert _heat_network_code_302_effective_factor(main, primary_energy=True) is None + + +def test_heat_network_distribution_electricity_none_for_individual_main() -> None: + # Arrange — an individually-heated gas-boiler main (category 2, no + # heat-network SAP code). §C3.2 pumping electricity applies only to + # heat networks, so no distribution line should be emitted. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + space = (1000.0,) * 12 + hw = (200.0,) * 12 + + # Act + result = _heat_network_distribution_electricity(main, space, hw, 0.85) + + # Assert + assert result is None + + 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 @@ -1896,6 +2120,37 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 +def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None: + # Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas + # boilers (including mains gas, LPG and biogas)". The code identifies + # the boiler type/efficiency, NOT the carrier. The newer Elmhurst + # export leaves §14.0 "Fuel Type" empty and lodges only the SAP code + # (e.g. 104 condensing combi); the §15.0 "Water Heating Fuel Type" + # names the carrier because the same combi/boiler heats space + water. + from datatypes.epc.domain.mapper import ( + _elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert — combi (104) + §15.0 mains gas (26) → mains gas. + assert _elmhurst_gas_boiler_main_fuel(104, 26) == 26 + # Regular condensing (102) + §15.0 bulk LPG (27) → bulk LPG. + assert _elmhurst_gas_boiler_main_fuel(102, 27) == 27 + # Boundary codes of the 101-119 gas-boiler range resolve too. + assert _elmhurst_gas_boiler_main_fuel(101, 26) == 26 + assert _elmhurst_gas_boiler_main_fuel(119, 5) == 5 # bottled LPG + # §15.0 lodges a separate electric immersion's fuel (30), NOT the + # gas boiler's carrier → no derivation; caller strict-raises. + assert _elmhurst_gas_boiler_main_fuel(104, 30) is None + # Non-gas-boiler SAP code (224 = air-source heat pump) → None even + # when §15.0 names a gas fuel (the HP doesn't burn it). + assert _elmhurst_gas_boiler_main_fuel(224, 26) is None + # Liquid-fuel boiler range (120-141) is owned by the separate + # `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES` branch → None here. + assert _elmhurst_gas_boiler_main_fuel(120, 26) is None + # No SAP code lodged → None. + assert _elmhurst_gas_boiler_main_fuel(None, 26) is None + + def test_elmhurst_main_heating_ees_maps_no_system_code_to_electricity() -> None: # Arrange — SAP 10.2 §A.2.2 (PDF p.189 area) "When no main heating # system is identified, the calculation is for the assumed system diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 435df408..dc000956 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -54,6 +54,13 @@ _SAP_ABS_TOLERANCE = 0 _PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01 _CO2_ABS_TOLERANCE_TONNES = 0.001 +# Worksheet-pin tolerances (calc − Elmhurst dr87 worksheet, full precision). +# These are deterministic so the tolerances are tight; they lock the +# current residual against the worksheet's full-precision (286)/(272) +# rather than the integer-rounded lodged register values. +_WS_PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01 +_WS_CO2_ABS_TOLERANCE_KG = 0.01 + @dataclass(frozen=True) class _GoldenExpectation: @@ -186,11 +193,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+46.4156, - expected_co2_resid_tonnes_per_yr=+1.0677, + expected_sap_resid=-2, + expected_pe_resid_kwh_per_m2=+19.1566, + expected_co2_resid_tonnes_per_yr=+0.4211, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " + "S0380.189 fixed the dominant driver: walls are solid brick " + "WITH internal insulation (wall_insulation_type=3), so " + "RdSAP 10 §5.16 Table 22 sets TMP=100 (not the old hardcoded " + "250). Correct TMP → §7 time constant ~16h not ~40h → larger " + "temperature reduction → MIT down ~0.7C → space heating drops. " + "SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. " + "Validated 1e-4 against the user-simulated 001431 worksheet " + "(same archetype). Remaining +19 PE is other gaps + lodged " + "divergence (no worksheet for 6035 itself to pin further). " "Slice 59 per-bp window apportionment tightens all 3 " "residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → " "+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs " @@ -258,7 +274,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2130-1033-4050-5007-8395", actual_sap=82, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-7.4998, + expected_pe_resid_kwh_per_m2=-7.5579, expected_co2_resid_tonnes_per_yr=-0.0454, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " @@ -315,8 +331,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0380-2471-3250-2596-8761", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+0.5259, - expected_co2_resid_tonnes_per_yr=-0.0074, + expected_pe_resid_kwh_per_m2=+0.4872, + expected_co2_resid_tonnes_per_yr=-0.0075, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow " "TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP " @@ -335,7 +351,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0350-2968-2650-2796-5255", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.2812, + expected_pe_resid_kwh_per_m2=-0.2976, expected_co2_resid_tonnes_per_yr=-0.0292, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -347,7 +363,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2225-3062-8205-2856-7204", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.2978, + expected_pe_resid_kwh_per_m2=-0.3250, expected_co2_resid_tonnes_per_yr=-0.0101, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -359,7 +375,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2636-0525-2600-0401-2296", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.4127, + expected_pe_resid_kwh_per_m2=-0.4340, expected_co2_resid_tonnes_per_yr=-0.0045, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -372,7 +388,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="3800-8515-0922-3398-3563", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.2093, + expected_pe_resid_kwh_per_m2=-0.2288, expected_co2_resid_tonnes_per_yr=+0.0407, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -384,7 +400,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="9285-3062-0205-7766-7200", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.0736, + expected_pe_resid_kwh_per_m2=-0.0921, expected_co2_resid_tonnes_per_yr=-0.0452, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -396,7 +412,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="9418-3062-8205-3566-7200", actual_sap=85, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.4291, + expected_pe_resid_kwh_per_m2=-0.4492, expected_co2_resid_tonnes_per_yr=-0.0056, notes=( "Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration " @@ -422,18 +438,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( # `sap worksheets/Additional data with api/`. # ------------------------------------------------------------------ _GoldenExpectation(cert_number="0036-6325-1100-0063-1226", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4019, expected_co2_resid_tonnes_per_yr=+0.0255, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5174, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.6041, expected_co2_resid_tonnes_per_yr=-0.0096, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.1308, expected_co2_resid_tonnes_per_yr=+0.0443, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.2791, expected_co2_resid_tonnes_per_yr=+0.0150, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4939, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4660, expected_co2_resid_tonnes_per_yr=-0.0085, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0931, expected_co2_resid_tonnes_per_yr=+0.0453, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1548, expected_co2_resid_tonnes_per_yr=+0.0159, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0320-2126-2150-2326-6161", actual_sap=72, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=+0.0128, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2370, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2809, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.6646, expected_co2_resid_tonnes_per_yr=-0.0170, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2633, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2620, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0196, expected_co2_resid_tonnes_per_yr=-0.0162, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0380-2530-6150-2326-4161", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0893, expected_co2_resid_tonnes_per_yr=-0.0315, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0390-2066-4250-2026-4555", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2522, expected_co2_resid_tonnes_per_yr=+0.0005, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.1607, expected_co2_resid_tonnes_per_yr=+0.0451, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9954, expected_co2_resid_tonnes_per_yr=+0.0276, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2365, expected_co2_resid_tonnes_per_yr=+0.0459, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0214, expected_co2_resid_tonnes_per_yr=+0.0284, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="1536-9325-5100-0433-1226", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1568, expected_co2_resid_tonnes_per_yr=-0.0456, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2007-3011-9205-8136-3204", actual_sap=68, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3773, expected_co2_resid_tonnes_per_yr=-0.0325, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2031-3007-0205-1296-3204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4198, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."), @@ -450,26 +466,120 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( # / CO2 +0.005 (lodged values are integer-rounded; rounding noise). _GoldenExpectation(cert_number="2102-3018-0205-7886-5204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1961, expected_co2_resid_tonnes_per_yr=+0.0048, notes="Cohort-2 baseline pin. House coal secondary — S0380.70 routed CO2/PE through `secondary_fuel_type` per SAP 10.2 Table 12d/12e headers, closed PE +20.36 → +0.20 and CO2 -0.79 → +0.005 (lodged values integer-rounded)."), _GoldenExpectation(cert_number="2130-3018-4205-4686-5204", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4083, expected_co2_resid_tonnes_per_yr=-0.0357, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="2336-3124-3600-0517-1292", actual_sap=83, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1247, expected_co2_resid_tonnes_per_yr=-0.0414, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="2536-2525-0600-0788-2292", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4210, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="2336-3124-3600-0517-1292", actual_sap=83, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1077, expected_co2_resid_tonnes_per_yr=-0.0414, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="2536-2525-0600-0788-2292", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4317, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2590-3025-7205-9066-0200", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1309, expected_co2_resid_tonnes_per_yr=-0.0036, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2699-3025-5205-8066-0200", actual_sap=69, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4755, expected_co2_resid_tonnes_per_yr=-0.0016, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2868, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.0936, expected_co2_resid_tonnes_per_yr=-0.0485, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2727, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3624, expected_co2_resid_tonnes_per_yr=-0.0476, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="3336-2825-9400-0512-8292", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0660, expected_co2_resid_tonnes_per_yr=-0.0053, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0867, expected_co2_resid_tonnes_per_yr=-0.0054, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="4536-8325-3100-0409-1222", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2794, expected_co2_resid_tonnes_per_yr=+0.0093, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="4800-3992-0422-0599-3563", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5231, expected_co2_resid_tonnes_per_yr=-0.0406, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5284, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="4800-3992-0422-0599-3563", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4814, expected_co2_resid_tonnes_per_yr=-0.0406, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4924, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="7700-3362-0922-7022-3563", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4141, expected_co2_resid_tonnes_per_yr=+0.0216, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="7800-1501-0922-7127-3563", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0594, expected_co2_resid_tonnes_per_yr=+0.0440, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9583, expected_co2_resid_tonnes_per_yr=+0.0165, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2791, expected_co2_resid_tonnes_per_yr=+0.0337, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0131, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9173, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3253, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3101, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0766, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0794, expected_co2_resid_tonnes_per_yr=+0.0172, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2984, expected_co2_resid_tonnes_per_yr=+0.0336, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0111, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1976, expected_co2_resid_tonnes_per_yr=-0.0238, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3541, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3533, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1132, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), +) + + +@dataclass(frozen=True) +class _WorksheetPin: + """Full-precision PE / CO2 targets read from a cert's Elmhurst dr87 + worksheet (the "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY + ENERGY" block of the *current* dwelling), plus the recorded calc + residual against them. + + Unlike `_GoldenExpectation` — which compares against the integer- + rounded lodged register values (`energy_consumption_current` / + `co2_emissions_current`) — these pin against the worksheet's + unrounded `(286)` primary energy and `(272)` CO2. That makes the + residual a *calculator-vs-Elmhurst* signal, free of register + rounding: a non-zero `expected_pe_resid` here is a genuine calc gap, + not lodged noise. + + `ws_pe_kwh_per_m2` = worksheet (286) / worksheet (4) total floor area + (the worksheet's own decimal TFA, not the JSON's integer); the + calculator uses the same decimal TFA, so the comparison is + apples-to-apples. `ws_co2_kg_per_yr` = worksheet (272) total CO2. + """ + + cert_number: str + ws_pe_kwh_per_m2: float + ws_co2_kg_per_yr: float + expected_pe_resid: float + expected_co2_resid_kg: float + + +# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc ≡ +# worksheet on BOTH PE and CO2 at <1e-4 across the ENTIRE cohort +# (every expected_*_resid below is 0.0000) — the SAP 10.2 1e-4 +# convergence target, met. Closed over two slices: +# S0380.187 — Appendix M1 §3a D_PV,m was missing electric SECONDARY +# space heating (215)m, under-crediting PV in heating months on the +# 10 gas+PV certs (PE +0.5..+1.5 / CO2 −0.5..−1.1 → ~0.03). +# S0380.188 — D_PV,m used the Appendix L L12 lighting GAIN G_L,m +# (= E_L,m × 0.85, "15% not internal heat") instead of the L10 +# lighting ELECTRICITY E_L,m that §3a requires; the 15% shortfall +# depressed β uniformly across the year on every PV cert. Fixed by +# scaling the gain's seasonal shape to the annual E_L (232). Same +# L-gain-vs-L-electricity class as the cooking fix S0380.73. +# Values frozen from the dr87 PDFs (untracked, so not parsed at test +# time) per the worksheet_unrounded_sap convention. +_WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( + _WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0380-2530-6150-2326-4161", ws_pe_kwh_per_m2=174.9107, ws_co2_kg_per_yr=2368.5251, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2102-3018-0205-7886-5204", ws_pe_kwh_per_m2=228.1961, ws_co2_kg_per_yr=4104.7798, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2130-3018-4205-4686-5204", ws_pe_kwh_per_m2=181.4083, ws_co2_kg_per_yr=2364.3480, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2225-3062-8205-2856-7204", ws_pe_kwh_per_m2=52.6750, ws_co2_kg_per_yr=389.8819, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2336-3124-3600-0517-1292", ws_pe_kwh_per_m2=68.1077, ws_co2_kg_per_yr=458.6131, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2536-2525-0600-0788-2292", ws_pe_kwh_per_m2=87.5683, ws_co2_kg_per_yr=375.6003, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2590-3025-7205-9066-0200", ws_pe_kwh_per_m2=171.8691, ws_co2_kg_per_yr=2396.4327, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), ) @@ -530,6 +640,47 @@ def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> No ) +@pytest.mark.parametrize( + "pin", + _WORKSHEET_PE_CO2, + ids=lambda p: p.cert_number, +) +def test_golden_cert_pe_co2_matches_worksheet(pin: _WorksheetPin) -> None: + """Pin the demand cascade's PE / CO2 against the cert's Elmhurst dr87 + worksheet at full precision — the calculator-vs-Elmhurst signal that + the lodged-register residual (`test_golden_cert_residual_matches_pin`) + can't give, because lodged values are integer-rounded. + + The worksheet's published *Current* PE `(286)` and CO2 `(272)` come + from its postcode-climate "CALCULATION OF EPC COSTS, EMISSIONS AND + PRIMARY ENERGY" block — so we drive the same `cert_to_demand_inputs` + (postcode climate) cascade the EPC publishes, not the UK-average SAP + cascade. + """ + # Arrange + doc = _load_cert(pin.cert_number) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + demand = calculate_sap_from_inputs( + cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + pe_resid = demand.primary_energy_kwh_per_m2 - pin.ws_pe_kwh_per_m2 + co2_resid_kg = demand.co2_kg_per_yr - pin.ws_co2_kg_per_yr + + # Assert + assert abs(pe_resid - pin.expected_pe_resid) <= _WS_PE_ABS_TOLERANCE_KWH_PER_M2, ( + f"PE residual vs worksheet {pe_resid:+.4f} kWh/m² drifted from pin " + f"{pin.expected_pe_resid:+.4f} (tolerance " + f"±{_WS_PE_ABS_TOLERANCE_KWH_PER_M2})." + ) + assert abs(co2_resid_kg - pin.expected_co2_resid_kg) <= _WS_CO2_ABS_TOLERANCE_KG, ( + f"CO2 residual vs worksheet {co2_resid_kg:+.4f} kg/yr drifted from " + f"pin {pin.expected_co2_resid_kg:+.4f} (tolerance " + f"±{_WS_CO2_ABS_TOLERANCE_KG})." + ) + + # Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number # 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain # must surface that PCDB winter efficiency on `inputs.main_heating_efficiency` @@ -579,6 +730,4 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( main = epc.sap_heating.main_heating_details[0] assert main.main_heating_index_number == expected_pcdb_id if expected_winter_eff is not None: - assert inputs.main_heating_efficiency == pytest.approx( - expected_winter_eff, abs=1e-3 - ) + assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py new file mode 100644 index 00000000..96609e90 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py @@ -0,0 +1,126 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 1" worksheet (gas-combi archetype). + +Like 000565, this fixture does NOT hand-build the EpcPropertyData. It +routes the Summary PDF through ElmhurstSiteNotesExtractor + +EpcPropertyDataMapper.from_elmhurst_site_notes so the SAP-result pin +grid exercises the WHOLE extractor + mapper + calculator pipeline. + +This is the cert that motivated S0380.190 — the newer Elmhurst export +lodges the gas combi as §14.0 "Fuel Type" EMPTY + "Main Heating SAP +Code" 104 (condensing combi, EES "BGW"), with the carrier ("Mains +gas") only in §15.0 "Water Heating Fuel Type". Before S0380.190 the +mapper left `main_fuel_type=''` → `cert_to_inputs` raised +`MissingMainFuelType`; `_elmhurst_gas_boiler_main_fuel` now derives +mains gas (code 26) from §15.0 per SAP 10.2 Table 4b (rows 101-119 are +gas-family boilers; the §15.0 fuel disambiguates the carrier because +the combi heats space + water from one appliance). + +It is also the cert that motivated S0380.189 (thermal mass parameter +per RdSAP 10 §5.16 Table 22): solid brick WITH internal insulation → +TMP 100, not the previously-hardcoded 250. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 1/` (Summary_001431 (1).pdf input + +P960-0001-001431 - 2026-06-02T221203.958.pdf worksheet). The Summary +is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf` +(distinct name — the corpus reuses cert 001431 across every heating +variant) so the test runs without depending on the unstaged workspace. + +Cert shape (Summary §1-19): gas-combi mid-terrace, TFA 128 m², solid +brick WITH internal insulation (→ Table 22 TMP 100), no PV, no +secondary heating, no cylinder (combi instantaneous HW, WHC HWP / SAP +code 901). Condensing combi SAP code 104, EES "BGW". + +Worksheet pin targets (P960-0001-001431 …958.pdf, Block 1 — energy +rating, lines 115-410; the second "FOR IMPROVED DWELLING" block is the +potential rating and is NOT pinned): +- SAP rating 78 (line 258) +- Energy cost factor 1.6047 (line 257; cascade carries it unrounded as + (255)*(256)/((4)+45) = 660.9750*0.4200/173.0 — the continuous SAP + 100 - 13.95*ECF is reconstructed from the unrounded ECF, NOT the + display-rounded 1.6047, so sap_score_continuous = 77.6147) +- Total fuel cost £660.9750 (line 255) +- CO2 3000.1664 kg/year (line 272) +- Space heating 8987.7669 kWh/year (Σ monthly (98)) +- Main 1 fuel 10699.7225 kWh/year (line 211) — mains gas +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3327.1592 kWh/year (line 219) — combi +- Lighting 283.2229 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet PDF. Failing +pins are named extractor / mapper / calculator gaps to fix. +""" + +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 + + +# 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_gas_combi.pdf" +) + + +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\\nvalue sequences). + + Mirror of the helper in `backend/documents_parser/tests/ + test_summary_pdf_mapper_chain.py::_summary_pdf_to_textract_style_ + pages` (and `_elmhurst_worksheet_000565.py`). `pdftotext -layout` + preserves the spatial label/value pairing on each line; we split on + 2+ spaces to surface the tokens, then rejoin newline-delimited. + """ + 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 001431 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.190 gas-combi fuel derivation + (§14.0 Fuel Type empty + SAP code 104 → mains gas via §15.0). + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 69ad44ba..4f4653fd 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -37,6 +37,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000516 as _w000516, _elmhurst_worksheet_000565 as _w000565, + _elmhurst_worksheet_001431 as _w001431, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -147,6 +148,25 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=1384.8353, pumps_fans_kwh_per_yr=252.5159, ), + # Mapper-driven cohort entry — Summary_001431_gas_combi.pdf → + # extractor → mapper → calculator. Gas-combi mid-terrace, TFA 128, + # solid brick WITH internal insulation (Table 22 TMP 100), no PV / + # secondary / cylinder. The cert that motivated S0380.190 (gas-combi + # fuel from §15.0 when §14.0 Fuel Type is empty + SAP code 104) and + # S0380.189 (thermal mass parameter). Pins are worksheet Block 1 + # (energy rating) line refs. sap_score_continuous is reconstructed + # from the UNROUNDED ECF ((255)*(256)/((4)+45)), not the display- + # rounded (257)=1.6047 — see the fixture module docstring. + "001431": FixtureCascadePins( + sap_score=78, sap_score_continuous=77.6147, ecf=1.6047, + total_fuel_cost_gbp=660.9750, co2_kg_per_yr=3000.1664, + space_heating_kwh_per_yr=8987.7669, + main_heating_fuel_kwh_per_yr=10699.7225, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3327.1592, + lighting_kwh_per_yr=283.2229, + pumps_fans_kwh_per_yr=86.0, + ), } @@ -158,6 +178,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "000490": _w000490, "000516": _w000516, "000565": _w000565, + "001431": _w001431, }