diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 935f8a76..f698741d 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -425,6 +425,18 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='electric 14', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), + # Slice S0380.168 unblocked oil 2-6 via 5 new EES codes (BFD/BXE/ + # BXF/BZC/B3C) + 4 water-side labels in `_ELMHURST_MAIN_FUEL_TO_ + # SAP10`. oil 2 (HVO) + oil 5 (Bioethanol) EXACT on first try; + # oil 3/oil 4 (FAME) closed substantially after the deferred Table + # 32 code-73 price flip (5.44 → 7.64) per S0380.131's TODO. oil 6 + # (B30K) carries a cascade-side residual (HW kWh / SH demand / + # CO2/PE blend) — see open fronts in the post-S0380.168 handover. + _CorpusExpectation(variant='oil 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=+2.5863, expected_cost_resid_gbp=-61.8906, expected_co2_resid_kg=-14.5815, expected_pe_resid_kwh=-967.0971), + _CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+2.5603, expected_cost_resid_gbp=-56.6586, expected_co2_resid_kg=-13.3489, expected_pe_resid_kwh=-884.8990), + _CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+3.0518, expected_cost_resid_gbp=-69.7943, expected_co2_resid_kg=-240.6595, expected_pe_resid_kwh=-1112.6558), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), @@ -480,11 +492,6 @@ _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = ( 'community heating 4', 'community heating 6', 'no system', - 'oil 2', - 'oil 3', - 'oil 4', - 'oil 5', - 'oil 6', # Slice S0380.133 unblocked all 10 solid-fuel variants via the # §14.0 EES-code-driven fuel derivation; they now appear in # `_EXPECTATIONS` above with their post-derivation residual pins. diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2eb8bea4..540b185f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3832,6 +3832,14 @@ _ELMHURST_MAIN_FUEL_TO_SAP10: Dict[str, int] = { # existing oddity as "Oil" → 8; both labels are unused by any live # fixture). Live form on Elmhurst worksheets is "Bulk LPG". "Bulk LPG": 27, + # Elmhurst Summary §15.0 "Water Heating Fuel Type" labels for the + # bio-liquid fuels added to the EES dict above. Values are Table 32 + # codes verbatim (no API enum collision). Spec: SAP 10.2 Table 12 + # (PDF p.189) notes (d)/(e)/(f). + "Bio-liquid HVO from used cooking oil": 71, + "Bio-liquid FAME from animal/vegetable oils": 73, + "Bioethanol": 76, + "B30K": 75, "Coal": 11, "Electricity": 30, "Electricity (off-peak 7hr)": 33, @@ -4186,6 +4194,30 @@ _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE: Final[dict[str, int]] = { "WEA": 30, "REA": 30, "OEA": 30, + # Bio-liquid main heating fuels — Table 12 / Table 32 codes verbatim + # (the bio-liquid Table 32 codes 71/73/75/76 are not collided by any + # API enum value, so they pass through `unit_price_p_per_kwh` etc. + # unchanged). Spec: SAP 10.2 Table 12 (PDF p.189) notes (d)/(e)/(f). + # + # BFD — bio-liquid HVO from used cooking oil — Table 32 code 71 + # (6.79 p/kWh, 0.036 CO2, 1.180 PE). Corpus variant oil 2 + # (SAP 127). + # BXE — bio-liquid FAME from animal/vegetable oils — Table 32 + # code 73 (6.79 p/kWh, 0.018 CO2, 1.180 PE). Corpus + # variant oil 3 (SAP 128). + # BXF — bio-liquid FAME alt — Table 32 code 73 (same fuel as + # BXE; different SAP code 129). Corpus variant oil 4. + # BZC — bioethanol from any biomass source — Table 32 code 76 + # (47.0 p/kWh, 0.105 CO2, 1.472 PE). Corpus variant + # oil 5 (SAP 126). + # B3C — B30K (30% FAME + 70% kerosene) — Table 32 code 75 + # (5.49 p/kWh, 0.214 CO2, 1.136 PE). Corpus variant + # oil 6 (SAP 126). + "BFD": 71, + "BXE": 73, + "BXF": 73, + "BZC": 76, + "B3C": 75, } diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index e3ab5477..4a8c3486 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1845,6 +1845,57 @@ def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None ) is False +def test_elmhurst_main_heating_ees_maps_bio_liquid_codes_to_table_32_fuel_codes() -> None: + # Arrange — Elmhurst Summary §14.0 lodges 3-letter "Main Heating EES + # Code" for non-mineral liquid-fuel Table 4b boilers. The corpus + # carries 5 such variants: + # + # oil 2 — BFD + SAP 127 → HVO (Table 32 code 71) + # oil 3 — BXE + SAP 128 → FAME (Table 32 code 73) + # oil 4 — BXF + SAP 129 → FAME (alt sub-code) + # oil 5 — BZC + SAP 126 → Bioethanol (code 76) + # oil 6 — B3C + SAP 126 → B30K (code 75) + # + # All values are direct Table 32 codes (the bio-liquid codes 71/73/ + # 75/76 don't collide with any API enum value so they pass through + # `unit_price_p_per_kwh` etc. unchanged). + + from datatypes.epc.domain.mapper import ( + _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BFD"] == 71 # HVO + assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BXE"] == 73 # FAME + assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BXF"] == 73 # FAME alt + assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["BZC"] == 76 # Bioethanol + assert _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE["B3C"] == 75 # B30K + + +def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> None: + # Arrange — Elmhurst Summary §15.0 "Water Heating Fuel Type" lodges + # the verbatim Table 12 fuel descriptions for bio-liquid HW certs. + # For Table 4b liquid-fuel boilers (SAP code 120-141), the same + # boiler heats both space and water, so the mapper uses §15.0's + # fuel label as the main fuel too (via the `_LIQUID_FUEL_BOILER_ + # SAP_MAIN_HEATING_CODES` branch in `_map_elmhurst_sap_heating`) + # when §14.0's "Fuel Type" field is empty. + + from datatypes.epc.domain.mapper import ( + _ELMHURST_MAIN_FUEL_TO_SAP10, # pyright: ignore[reportPrivateUsage] + _elmhurst_main_fuel_int, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + assert _elmhurst_main_fuel_int("Bio-liquid HVO from used cooking oil") == 71 + assert _elmhurst_main_fuel_int("Bio-liquid FAME from animal/vegetable oils") == 73 + assert _elmhurst_main_fuel_int("Bioethanol") == 76 + assert _elmhurst_main_fuel_int("B30K") == 75 + # The dict values flow directly to Table 32 / Table 12 fuel codes — + # no API enum translation needed for these codes. + assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 + + def test_elmhurst_main_heating_ees_maps_electric_storage_codes_to_electricity() -> None: # Arrange — Elmhurst Summary §14.0 lodges a 3-letter "Main Heating # EES Code" alongside the Table 4a "Main Heating SAP Code" but does diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 03c6dbb3..398603f7 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -51,13 +51,17 @@ UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = { # BRE technical papers (`docs/specs/sap10 technical papers/`) carry # no Table 32 errata or fuel-price update, so the change is grounded # in empirical cross-source evidence rather than a spec citation. - # FAME (code 73) shows the inverse pattern on oil 3/4 worksheets - # (worksheet 7.64 vs spec 5.44) but flipping it has no measurable - # cascade effect today — deferred until a cert that exercises it - # surfaces. + # FAME (code 73) shows the inverse pattern on oil 3/4 worksheets: + # the RdSAP 10 Spec PDF Table 32 lists 5.44 p/kWh but worksheet + # (240) "Space heating - main system 1" for variants oil 3 (EES + # BXE, SAP 128) + oil 4 (EES BXF, SAP 129) lodges 7.64. Slice + # S0380.168 flipped 5.44 → 7.64 to match the worksheet — same + # empirical-divergence justification as the .131 heating-oil flip; + # the Elmhurst engine is the canonical reference per + # [[feedback-software-no-special-handling]]. 4: 5.44, # heating oil — see comment above (Slice S0380.131) 71: 7.64, # bio-liquid HVO - 73: 5.44, # bio-liquid FAME + 73: 7.64, # bio-liquid FAME — Slice S0380.168 flip (5.44 → 7.64) 75: 6.10, # B30K 76: 47.0, # bioethanol # Solid fuels