diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index e2d2906b0..a51a843cf 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -125,12 +125,6 @@ class MainHeatingDetail: ) main_heating_index_number: Optional[int] = None sap_main_heating_code: Optional[int] = None # TODO: make enum? - # Boiler SEDBUK-band `(winter, summer)` seasonal efficiency (percent), set by - # the main-heating Landlord-Override overlay (ADR-0068 — the fifth Heating - # Companion). When present it overrides the Table 4b code default in the §206 - # / Appendix D Eq D1 efficiency cascade; None keeps the code's Table 4b value. - # Override-supplied only — the lodged-cert path never sets it. - seasonal_efficiency_override_pct: Optional[tuple[float, float]] = None main_heating_number: Optional[int] = None main_heating_category: Optional[int] = None main_heating_fraction: Optional[int] = None diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 6ff3f1719..5626cfc02 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -40,9 +40,6 @@ from domain.modelling.simulation import EpcSimulation, HeatingOverlay from domain.sap10_calculator.tables.table_12a import ( OFF_PEAK_IMPLYING_HEATING_CODES, ) -from domain.sap10_calculator.tables.table_4b import ( - table_4b_seasonal_efficiencies_pct, -) # Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at # the low rate and cannot run economically on a single-rate meter; "Dual" lets @@ -247,17 +244,20 @@ _FAN_FLUE_BOILER_CODES = _OIL_BOILER_CODES # mains gas (clearing a storage dwelling's old electric-immersion arrangement). _FROM_MAIN_WATER_HEATING_CODE = 901 -# Boiler Efficiency Band → seasonal efficiency, the fifth Heating Companion -# (ADR-0068). Keyed by the resolved boiler's base `sap_main_heating_code` -# (102 gas regular, 104 gas combi, 127 oil regular, 130 oil combi — the four -# SEDBUK-rated wet boilers) + its SEDBUK A–G band. The base code is unchanged -# (it still drives combi-vs-regular hot-water behaviour); only the efficiency -# slot changes. +# Boiler Efficiency Band → the boiler's **cert-native efficiency anchor** the +# overlay emits — the fifth Heating Companion (ADR-0068). Keyed by the resolved +# boiler's base `sap_main_heating_code` (102 gas regular, 104 gas combi, 127 oil +# regular, 130 oil combi — the four SEDBUK-rated wet boilers) + its SEDBUK A–G +# band. There is NO synthesised efficiency field: C-and-below emit the band's +# Table 4b `sap_main_heating_code`; A/B emit a representative PCDB +# `main_heating_index_number`. Both are real cert fields the calculator already +# resolves (Table 4b / PCDB Appendix D2.1), and the base code still drives the +# companion set (combi vs regular hot water). # -# C-and-below reuse the *accredited Table 4b* `(winter, summer)` of the code the -# PRD/ADR maps each band to — recorded here as that code so the numbers stay in -# one place (`table_4b.py`). Oil's Table 4b codes are sparser than gas and do not -# line up cleanly with SEDBUK bands, so oil E/F/G are approximate nearest-fit +# C-and-below → the band's Table 4b code. The code is itself combi/regular- +# specific (regular 102→106/101/105/115; combi 104→108/103/107/118), so system- +# type behaviour is preserved by the code alone. Oil's Table 4b codes are sparser +# and don't line up cleanly with SEDBUK bands, so oil E/F/G are nearest-fit # (⚠ ADR-0068 open question): E/F→125, D/E→129, F/G→128. _BAND_TO_TABLE_4B_CODE: dict[tuple[int, BoilerEfficiencyBand], int] = { # Gas boiler, regular (base 102) @@ -286,52 +286,54 @@ _BAND_TO_TABLE_4B_CODE: dict[tuple[int, BoilerEfficiencyBand], int] = { (130, BoilerEfficiencyBand.G): 128, # (71, 62) ⚠ } -# A/B exceed Table 4b's 84 % gas / 82–84 % oil ceiling, so they cannot borrow a -# Table 4b code — they take SEDBUK-derived `(winter, summer)` slot values -# validated against an **accredited Elmhurst build** (ADR-0068 gated pre-req). +# A/B exceed Table 4b's 84 % gas / 82–84 % oil ceiling, so there is no Table 4b +# code for them — the cert-native anchor is a representative PCDB product +# `main_heating_index_number`, the same mechanism a real cert (or a future +# user-supplied product) uses. The calculator's existing PCDB path resolves +# winter + summer + combi-loss from the record; `_fold_heating` clears the base +# code when an index is present, so the effective cert reads exactly like a real +# PCDB-lodged cert. Products are **type-matched** to the archetype (combi vs +# regular) because the combi-loss cascade reads the record's keep-hot/DHW profile. # -# GAS (validated 2026-07-29 on Elmhurst RdSAP10, dwelling ref 000567): -# A → PCDB 18790 (Worcester Greenstar 8000 Life): worksheet (206)=90.6 / (217)=81.6 -# B → PCDB 15029 (Baxi Duo-tec Combi 24 HE class): worksheet (206)=88.0 / (217)=79.4 -# Gas/LPG/biogas share codes 101–119, so one pair per band covers all three; applied -# to both gas regular (102) and combi (104). (Enabling A moves ~14.6k A-rated homes -# up ~+1–3 SAP — flagged to stakeholders; some cross a band → Recs/eligibility.) -# -# OIL (124–132): pinned from the accredited PCDB records we hold, applied to oil -# regular (127) and combi (130): -# A → PCDB 17292 (Firebird Enviromax Blue Supreme): (90.6, 82.8) -# B → PCDB 10498 (Worcester Greenstar Danesmoor): (88.0, 80.2) -# No separate Elmhurst build was needed: the gas builds above proved Elmhurst's -# worksheet (206)/(217) for a database boiler are its PCDB winter/summer read back -# verbatim (18790 → 90.6/81.6, 15029 → 88.0/79.4, both exact), per SAP 10.2 App -# D2.1 (PCDB overrides Table 4b). So the PCDB record IS the accredited value. -_BAND_SLOT_EFFICIENCY_PCT: dict[tuple[int, BoilerEfficiencyBand], tuple[float, float]] = { - (102, BoilerEfficiencyBand.A): (90.6, 81.6), - (104, BoilerEfficiencyBand.A): (90.6, 81.6), - (102, BoilerEfficiencyBand.B): (88.0, 79.4), - (104, BoilerEfficiencyBand.B): (88.0, 79.4), - (127, BoilerEfficiencyBand.A): (90.6, 82.8), - (130, BoilerEfficiencyBand.A): (90.6, 82.8), - (127, BoilerEfficiencyBand.B): (88.0, 80.2), - (130, BoilerEfficiencyBand.B): (88.0, 80.2), +# Provenance — gas A/B were built in accredited Elmhurst RdSAP10 (worksheet +# (206)/(217) == the PCDB record verbatim, SAP 10.2 App D2.1); that proved +# Elmhurst reads the PCDB winter/summer, which licensed the oil picks straight +# from the PCDB. Each id is CI-guarded (see test_boiler_efficiency_band_overlay) +# so a PCDB refresh that renumbers a product fails loudly, never silently +# mis-scores. NOTE: oil A combi (130, A) has no PCDB product above 90 % (only an +# "Illustrative" placeholder), so it is intentionally absent — an oil A-combi +# (≈0 dwellings) keeps its code-130 condensing default (82 %). +_BAND_TO_PCDB_INDEX: dict[tuple[int, BoilerEfficiencyBand], int] = { + (102, BoilerEfficiencyBand.A): 18790, # Worcester GS 8000 Life (gas/LPG reg) 90.6/81.6 + (104, BoilerEfficiencyBand.A): 18964, # SIME EDEA (gas combi) 90.1/86.6 + (102, BoilerEfficiencyBand.B): 8106, # Vaillant Ecomax (gas reg) 88.0/79.0 + (104, BoilerEfficiencyBand.B): 15029, # Baxi Duo-tec Combi (gas combi) 88.0/79.4 + (127, BoilerEfficiencyBand.A): 17292, # Firebird Enviromax (oil reg) 90.6/82.8 + (127, BoilerEfficiencyBand.B): 10498, # Worcester Danesmoor (oil reg) 88.0/80.2 + (130, BoilerEfficiencyBand.B): 15959, # Firebird Enviromax Combi (oil combi) 89.0/82.9 } -def band_seasonal_efficiency_pct( - base_code: int, band: BoilerEfficiencyBand -) -> Optional[tuple[float, float]]: - """The SEDBUK-band `(winter, summer)` seasonal efficiency (percent) for a - resolved boiler `base_code`, or ``None`` when the band does not apply — a - non-banded boiler (solid fuel / electric / CPSU), an A/B band still pending - Elmhurst validation, or `UNKNOWN`. C-and-below resolve to the accredited - Table 4b pair of the code the band maps to (ADR-0068).""" - slot = _BAND_SLOT_EFFICIENCY_PCT.get((base_code, band)) - if slot is not None: - return slot - table_4b_code = _BAND_TO_TABLE_4B_CODE.get((base_code, band)) - if table_4b_code is None: - return None - return table_4b_seasonal_efficiencies_pct(table_4b_code) +def band_efficiency_anchor( + base_code: int, band: Optional[BoilerEfficiencyBand] +) -> tuple[int, Optional[int]]: + """The `(sap_main_heating_code, main_heating_index_number)` a boiler `base_code` + emits under a SEDBUK `band` (ADR-0068): + + - **A/B** → the base code kept + a representative PCDB index (the calculator's + PCDB path wins; `_fold_heating` then clears the code, giving a PCDB cert); + - **C–G** → the band's Table 4b code, no index (Table 4b path); + - no band / no applicable mapping (non-banded boiler, `UNKNOWN`, or the oil + A-combi gap) → the base code unchanged, no index. + """ + if band is not None: + index = _BAND_TO_PCDB_INDEX.get((base_code, band)) + if index is not None: + return base_code, index + table_4b_code = _BAND_TO_TABLE_4B_CODE.get((base_code, band)) + if table_4b_code is not None: + return table_4b_code, None + return base_code, None # Canonical system archetype → representative SAP `sap_main_heating_code`. Codes # map to the modern/condensing variant (A-G efficiency deferred): 102 regular @@ -503,16 +505,6 @@ def _natural_fuel_for(code: int) -> Optional[int]: return None -def _band_slot( - code: int, band: Optional[BoilerEfficiencyBand] -) -> Optional[tuple[float, float]]: - """The `(winter, summer)` efficiency slot the Boiler Efficiency Band forces - on a resolved boiler `code`, or ``None`` when no band applies (ADR-0068).""" - if band is None: - return None - return band_seasonal_efficiency_pct(code, band) - - def _gas_boiler_overlay( code: int, band: Optional[BoilerEfficiencyBand] = None ) -> HeatingOverlay: @@ -521,11 +513,15 @@ def _gas_boiler_overlay( controls, a single-rate meter, and a hot-water arrangement drawn from the main system (a combi has no cylinder; a regular boiler / CPSU keeps one). - The SEDBUK band (when supplied and applicable) sets the `(winter, summer)` - efficiency slot the calculator consumes ahead of the code's Table 4b default - — the fifth Heating Companion (ADR-0068).""" + The SEDBUK band (when supplied and applicable) picks the boiler's cert-native + efficiency anchor — the band's Table 4b `sap_main_heating_code` (C–G) or a + representative PCDB `main_heating_index_number` (A/B) — the fifth Heating + Companion (ADR-0068). The base `code` still drives the combi/regular hot-water + companion; `_fold_heating` clears the code when an index is emitted.""" + emitted_code, index = band_efficiency_anchor(code, band) return HeatingOverlay( - sap_main_heating_code=code, + sap_main_heating_code=emitted_code, + main_heating_index_number=index, main_heating_category=_GAS_BOILER_CATEGORY, main_fuel_type=_MAINS_GAS_FUEL, gas_connection_available=True, @@ -535,7 +531,6 @@ def _gas_boiler_overlay( water_heating_code=_FROM_MAIN_WATER_HEATING_CODE, water_heating_fuel=_MAINS_GAS_FUEL, has_hot_water_cylinder=code not in _COMBI_CODES, - seasonal_efficiency_override_pct=_band_slot(code, band), ) @@ -554,12 +549,14 @@ def _fuel_boiler_overlay( forces it True; and a solid-fuel boiler vents through a conventional (non-fanned) flue. - The SEDBUK band applies only to the oil boilers here (`band_seasonal_ - efficiency_pct` returns None for the solid-fuel code 151, which is intrinsic- - efficiency) — the fifth Heating Companion (ADR-0068).""" + The SEDBUK band applies only to the oil boilers here — `band_efficiency_anchor` + is a no-op for the solid-fuel code 151 (intrinsic-efficiency), returning the + code unchanged with no index — the fifth Heating Companion (ADR-0068).""" fuel = _natural_fuel_for(code) + emitted_code, index = band_efficiency_anchor(code, band) return HeatingOverlay( - sap_main_heating_code=code, + sap_main_heating_code=emitted_code, + main_heating_index_number=index, main_heating_category=_FUEL_BOILER_CATEGORY, main_fuel_type=fuel, main_heating_control=_FULL_BOILER_CONTROL, @@ -568,7 +565,6 @@ def _fuel_boiler_overlay( water_heating_code=_FROM_MAIN_WATER_HEATING_CODE, water_heating_fuel=fuel, has_hot_water_cylinder=code not in _OIL_BOILER_COMBI_CODES, - seasonal_efficiency_override_pct=_band_slot(code, band), ) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 5885d0eac..1a9357033 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -137,7 +137,6 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = ( "sap_main_heating_code", "main_heating_index_number", "main_heating_category", - "seasonal_efficiency_override_pct", "fan_flue_present", "boiler_flue_type", ) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index cf0d33aa7..55c52855e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -168,12 +168,6 @@ class HeatingOverlay: sap_main_heating_code: Optional[int] = None main_heating_index_number: Optional[int] = None main_heating_category: Optional[int] = None - # The boiler's SEDBUK-band `(winter, summer)` seasonal efficiency (percent), - # the fifth Heating Companion (ADR-0068). Set by the main-heating overlay from - # the Boiler Efficiency Band for a gas/oil boiler; the calculator consumes it - # ahead of the Table 4b code default (the slot the PCDB `winter_efficiency_pct` - # already occupies). `None` leaves the code's Table 4b efficiency in force. - seasonal_efficiency_override_pct: Optional[tuple[float, float]] = None # A modern condensing boiler has a fanned (room-sealed) flue; the boiler # upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the # Table 4b condensing-boiler seasonal-efficiency basis depend on it). diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 03919fc8d..3ec43e86b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2112,18 +2112,7 @@ def _main_heating_detail_efficiency( if main is not None and main.main_heating_index_number is not None else None ) - override_pct = ( - main.seasonal_efficiency_override_pct if main is not None else None - ) - if override_pct is not None: - # Landlord-Override SEDBUK-band efficiency — the fifth Heating Companion - # (ADR-0068). "Override wins": a system-replacing override makes the cert - # efficiency stale, so its `(winter, summer)` slot takes precedence over - # the Table 4b code default, occupying the same slot the PCDB - # `winter_efficiency_pct` does. Override-supplied only (lodged certs never - # set it), so the corpus is unmoved. - eff = override_pct[0] / 100.0 - elif pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: + if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: eff = pcdb_main.winter_efficiency_pct / 100.0 else: eff = seasonal_efficiency(main_code, main_category, main_fuel) @@ -8165,21 +8154,8 @@ def cert_to_inputs( # primary loss but ARE still eligible for Eq D1 — the spec's # §D2.1 (2) test is "summer < winter" + "boiler provides both", # not the primary-loss test. - override_pct = main.seasonal_efficiency_override_pct if main is not None else None eq_d1_winter_summer_pct: Optional[tuple[float, float]] = None if ( - override_pct is not None - and epc.sap_heating.water_heating_code == _WHC_FROM_MAIN_HEATING - # Not when DHW is a separate electric immersion (WHC 903): the boiler - # does not provide water heating, so its efficiency must not reach Eq D1. - and not dhw_is_electric_immersion - ): - # Landlord-Override SEDBUK-band `(winter, summer)` — the fifth Heating - # Companion (ADR-0068). It wins the Eq D1 water slot the same way it wins - # the §206 winter efficiency, ahead of the PCDB/Table 4b defaults, so a - # banded boiler's summer efficiency drives its hot-water fuel too. - eq_d1_winter_summer_pct = override_pct - elif ( pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None and pcdb_main.summer_efficiency_pct is not None diff --git a/tests/domain/epc/test_boiler_efficiency_band_overlay.py b/tests/domain/epc/test_boiler_efficiency_band_overlay.py index d710fea80..0e644bbd3 100644 --- a/tests/domain/epc/test_boiler_efficiency_band_overlay.py +++ b/tests/domain/epc/test_boiler_efficiency_band_overlay.py @@ -4,133 +4,147 @@ from typing import Optional import pytest +from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand from domain.epc.property_overlays.main_heating_system_overlay import ( - band_seasonal_efficiency_pct, + band_efficiency_anchor, main_heating_overlay_for, ) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +# --- band → cert-native efficiency anchor (code for C–G, PCDB index for A/B) --- @pytest.mark.parametrize( ("base_code", "band", "expected"), [ - # Gas regular (base 102) — C-and-below reuse the accredited Table 4b - # (winter, summer) of the code the band maps to (ADR-0068). - (102, BoilerEfficiencyBand.C, (84.0, 74.0)), # code 102 - (102, BoilerEfficiencyBand.D, (80.0, 70.0)), # code 106 - (102, BoilerEfficiencyBand.E, (74.0, 64.0)), # code 101 - (102, BoilerEfficiencyBand.F, (70.0, 60.0)), # code 105 - (102, BoilerEfficiencyBand.G, (66.0, 56.0)), # code 115 - # Gas combi (base 104). - (104, BoilerEfficiencyBand.C, (84.0, 75.0)), # code 104 - (104, BoilerEfficiencyBand.D, (80.0, 71.0)), # code 108 - (104, BoilerEfficiencyBand.G, (66.0, 57.0)), # code 118 - # Oil regular (base 127) — E/F nearest-fit to code 125 (⚠ ADR-0068). - (127, BoilerEfficiencyBand.C, (84.0, 72.0)), # code 127 - (127, BoilerEfficiencyBand.D, (80.0, 68.0)), # code 126 - (127, BoilerEfficiencyBand.E, (71.0, 59.0)), # code 125 - (127, BoilerEfficiencyBand.G, (66.0, 54.0)), # code 124 - # Oil combi (base 130) — D/E to code 129, F/G to code 128 (⚠ ADR-0068). - (130, BoilerEfficiencyBand.C, (82.0, 73.0)), # code 130 - (130, BoilerEfficiencyBand.D, (77.0, 68.0)), # code 129 - (130, BoilerEfficiencyBand.F, (71.0, 62.0)), # code 128 + # C-and-below → the band's Table 4b sap_main_heating_code, no index. The + # code is itself regular/combi-specific, so system type is preserved. + (102, BoilerEfficiencyBand.C, (102, None)), + (102, BoilerEfficiencyBand.D, (106, None)), + (102, BoilerEfficiencyBand.G, (115, None)), + (104, BoilerEfficiencyBand.D, (108, None)), + (104, BoilerEfficiencyBand.G, (118, None)), + (127, BoilerEfficiencyBand.E, (125, None)), + (130, BoilerEfficiencyBand.D, (129, None)), + # A/B → base code kept + a representative PCDB index (>84%, no Table 4b code). + (102, BoilerEfficiencyBand.A, (102, 18790)), + (104, BoilerEfficiencyBand.A, (104, 18964)), + (102, BoilerEfficiencyBand.B, (102, 8106)), + (104, BoilerEfficiencyBand.B, (104, 15029)), + (127, BoilerEfficiencyBand.A, (127, 17292)), + (127, BoilerEfficiencyBand.B, (127, 10498)), + (130, BoilerEfficiencyBand.B, (130, 15959)), ], ) -def test_c_and_below_bands_resolve_to_the_table_4b_pair( - base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] +def test_band_resolves_to_the_cert_native_anchor( + base_code: int, band: BoilerEfficiencyBand, expected: tuple[int, Optional[int]] ) -> None: - assert band_seasonal_efficiency_pct(base_code, band) == expected + assert band_efficiency_anchor(base_code, band) == expected @pytest.mark.parametrize( - ("base_code", "band", "expected"), + ("base_code", "band"), [ - # Gas A/B validated against accredited Elmhurst (ADR-0068): A → PCDB 18790 - # (Worcester Greenstar 8000 Life) 90.6/81.6; B → PCDB 15029 (Baxi Duo-tec - # Combi 24 HE) 88.0/79.4. Gas/LPG/biogas share codes — one pair per band on - # both regular (102) and combi (104). - # Gas A/B — validated on accredited Elmhurst (PCDB 18790 / 15029). - (102, BoilerEfficiencyBand.A, (90.6, 81.6)), - (104, BoilerEfficiencyBand.A, (90.6, 81.6)), - (102, BoilerEfficiencyBand.B, (88.0, 79.4)), - (104, BoilerEfficiencyBand.B, (88.0, 79.4)), - # Oil A/B — pinned from the accredited PCDB records (17292 / 10498). The - # gas builds proved Elmhurst's (206)/(217) == the PCDB winter/summer, so - # the PCDB record is the accredited value (no separate oil build needed). - (127, BoilerEfficiencyBand.A, (90.6, 82.8)), - (130, BoilerEfficiencyBand.A, (90.6, 82.8)), - (127, BoilerEfficiencyBand.B, (88.0, 80.2)), - (130, BoilerEfficiencyBand.B, (88.0, 80.2)), + # No band → base code unchanged, no index. + (102, None), + (104, None), + # Oil A-combi gap: no PCDB product above 90% exists → keep the code-130 + # condensing default (82%). ≈0 dwellings (ADR-0068). + (130, BoilerEfficiencyBand.A), + # Non-banded boilers ignore the band letter entirely. + (151, BoilerEfficiencyBand.G), # solid fuel + (191, BoilerEfficiencyBand.A), # electric boiler + (192, BoilerEfficiencyBand.D), # electric CPSU + (120, BoilerEfficiencyBand.D), # gas CPSU ], ) -def test_a_and_b_bands_use_the_accredited_slot( - base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] +def test_no_applicable_band_keeps_the_base_code_with_no_index( + base_code: int, band: Optional[BoilerEfficiencyBand] ) -> None: - # A/B exceed Table 4b's 84% ceiling (the under-credit the fix removes). - assert band_seasonal_efficiency_pct(base_code, band) == expected + assert band_efficiency_anchor(base_code, band) == (base_code, None) @pytest.mark.parametrize( - "base_code", + ("pcdb_id", "expected_winter_summer"), [ - 151, # Solid fuel boiler — Table 4a 75%, intrinsic (SEDBUK doesn't rate it) - 191, # Electric boiler — Table 4a 100%, intrinsic - 192, # Electric CPSU — intrinsic electric - 120, # Gas CPSU — non-condensing, not SEDBUK-banded + (18790, (90.6, 81.6)), # gas A regular — Worcester GS 8000 Life + (18964, (90.1, 86.6)), # gas A combi — SIME EDEA + (8106, (88.0, 79.0)), # gas B regular — Vaillant Ecomax + (15029, (88.0, 79.4)), # gas B combi — Baxi Duo-tec Combi + (17292, (90.6, 82.8)), # oil A regular — Firebird Enviromax + (10498, (88.0, 80.2)), # oil B regular — Worcester Danesmoor + (15959, (89.0, 82.9)), # oil B combi — Firebird Enviromax Combi ], ) -@pytest.mark.parametrize("band", list(BoilerEfficiencyBand)) -def test_non_banded_boilers_never_take_a_slot( - base_code: int, band: BoilerEfficiencyBand +def test_each_ab_pcdb_index_still_resolves_to_its_accredited_efficiency( + pcdb_id: int, expected_winter_summer: tuple[float, float] ) -> None: - assert band_seasonal_efficiency_pct(base_code, band) is None + # CI guard: a PCDB refresh that renumbers/removes a product must fail here + # loudly, never silently mis-score an A/B boiler (ADR-0068). + record = gas_oil_boiler_record(pcdb_id) + assert record is not None + assert (record.winter_efficiency_pct, record.summer_efficiency_pct) == ( + expected_winter_summer + ) + + +# --- overlay wiring (the HeatingOverlay a band produces) --- @pytest.mark.parametrize( - ("archetype", "band", "expected"), + ("archetype", "band", "code", "index"), [ - ("Gas boiler, regular", BoilerEfficiencyBand.D, (80.0, 70.0)), - ("Gas boiler, combi", BoilerEfficiencyBand.G, (66.0, 57.0)), - ("Oil boiler, regular", BoilerEfficiencyBand.E, (71.0, 59.0)), - ("Oil boiler, combi", BoilerEfficiencyBand.D, (77.0, 68.0)), - # Elmhurst-validated A/B upside on gas (the A under-credit fix). - ("Gas boiler, regular", BoilerEfficiencyBand.A, (90.6, 81.6)), - ("Gas boiler, combi", BoilerEfficiencyBand.A, (90.6, 81.6)), - ("Gas boiler, combi", BoilerEfficiencyBand.B, (88.0, 79.4)), + # C–G → emit the band Table 4b code, no index. + ("Gas boiler, regular", BoilerEfficiencyBand.D, 106, None), + ("Gas boiler, combi", BoilerEfficiencyBand.G, 118, None), + ("Oil boiler, regular", BoilerEfficiencyBand.E, 125, None), + ("Oil boiler, combi", BoilerEfficiencyBand.D, 129, None), + # A/B → keep the base code + set the representative PCDB index. + ("Gas boiler, regular", BoilerEfficiencyBand.A, 102, 18790), + ("Gas boiler, combi", BoilerEfficiencyBand.A, 104, 18964), + ("Gas boiler, combi", BoilerEfficiencyBand.B, 104, 15029), + ("Oil boiler, regular", BoilerEfficiencyBand.A, 127, 17292), + ("Oil boiler, combi", BoilerEfficiencyBand.B, 130, 15959), ], ) -def test_overlay_sets_the_band_efficiency_slot_on_gas_and_oil_boilers( - archetype: str, band: BoilerEfficiencyBand, expected: tuple[float, float] +def test_overlay_emits_the_band_anchor_on_gas_and_oil_boilers( + archetype: str, band: BoilerEfficiencyBand, code: int, index: Optional[int] ) -> None: simulation = main_heating_overlay_for(archetype, 0, band) assert simulation is not None assert simulation.heating is not None - # The base code is unchanged (it still drives combi/regular HW behaviour); - # only the efficiency slot is set from the band. - assert simulation.heating.seasonal_efficiency_override_pct == expected + assert simulation.heating.sap_main_heating_code == code + assert simulation.heating.main_heating_index_number == index @pytest.mark.parametrize( ("archetype", "band"), [ - # No band → no slot (today's behaviour: keep the condensing code default). + # No band → base code, no index (today's behaviour). ("Gas boiler, combi", None), - # Non-banded boilers ignore the band letter entirely. + # Oil A-combi gap → base code 130, no index. + ("Oil boiler, combi", BoilerEfficiencyBand.A), + # Non-banded boilers ignore the band entirely. ("Electric boiler", BoilerEfficiencyBand.D), - ("Electric boiler", BoilerEfficiencyBand.A), ("Solid fuel boiler", BoilerEfficiencyBand.G), ("Gas CPSU", BoilerEfficiencyBand.D), ], ) -def test_overlay_sets_no_slot_when_band_absent_or_not_applicable( +def test_overlay_sets_no_index_when_band_absent_or_not_applicable( archetype: str, band: Optional[BoilerEfficiencyBand] ) -> None: simulation = main_heating_overlay_for(archetype, 0, band) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.seasonal_efficiency_override_pct is None + assert simulation.heating.main_heating_index_number is None def test_overlay_defaults_to_no_band_when_not_supplied() -> None: @@ -139,4 +153,31 @@ def test_overlay_defaults_to_no_band_when_not_supplied() -> None: assert simulation is not None assert simulation.heating is not None - assert simulation.heating.seasonal_efficiency_override_pct is None + assert simulation.heating.sap_main_heating_code == 104 + assert simulation.heating.main_heating_index_number is None + + +# --- effective cert (post-fold): A/B become a PCDB cert; C–G keep the code --- + + +def _apply_gas_combi(band: Optional[BoilerEfficiencyBand]) -> EpcPropertyData: + overlay = main_heating_overlay_for("Gas boiler, combi", 0, band) + assert overlay is not None + return apply_simulations(build_epc(), [overlay]) + + +def test_ab_effective_cert_is_a_pcdb_cert_with_the_code_cleared() -> None: + # `_fold_heating`'s index/code mutual-exclusion: an A/B override yields exactly + # a PCDB-lodged cert — index set, code cleared — which the calculator's PCDB + # Appendix D2.1 path resolves (winter+summer+combi-loss). + eff = _apply_gas_combi(BoilerEfficiencyBand.A) + main = eff.sap_heating.main_heating_details[0] + assert main.main_heating_index_number == 18964 + assert main.sap_main_heating_code is None + + +def test_c_to_g_effective_cert_keeps_the_band_table_4b_code() -> None: + eff = _apply_gas_combi(BoilerEfficiencyBand.G) + main = eff.sap_heating.main_heating_details[0] + assert main.sap_main_heating_code == 118 # gas combi G + assert main.main_heating_index_number is None 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 adf75734d..d20230c18 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2885,65 +2885,6 @@ def test_living_area_rounds_half_up_at_2_dp_decimal_boundary_per_rdsap_15() -> N assert abs(fla_off_boundary - (14.06 / 46.87)) <= 1e-12 -def _epc_with_override_boiler( - base: object, seasonal_efficiency_override_pct: Optional[tuple[float, float]] -): - """A copy of the typical semi-detached cert whose primary gas boiler (Table 4b - code 102) carries a Landlord-Override `(winter, summer)` efficiency slot — the - fifth Heating Companion (ADR-0068).""" - import dataclasses - - typical = _typical_semi_detached_epc() - detail = dataclasses.replace( - _gas_boiler_detail(sap_main_heating_code=102), - seasonal_efficiency_override_pct=seasonal_efficiency_override_pct, - ) - return make_minimal_sap10_epc( - total_floor_area_m2=_TYPICAL_TFA_M2, - habitable_rooms_count=4, - region_code="1", - sap_building_parts=typical.sap_building_parts, - sap_windows=typical.sap_windows, - sap_heating=make_sap_heating(main_heating_details=[detail]), - ) - - -def test_seasonal_efficiency_override_wins_over_the_table_4b_code_default() -> None: - # A gas boiler override forces the SEDBUK-band efficiency ahead of the Table - # 4b code default: a G-rated boiler on code 102 (84%) is modelled at 66%, not - # the condensing 84% — the fix at the heart of #1704 / ADR-0068. "Override - # wins": the band-derived efficiency replaces the code's, symmetric up or down. - epc = _epc_with_override_boiler(None, (66.0, 56.0)) # G band (winter, summer) - - inputs = cert_to_inputs(epc) - - assert inputs.main_heating_efficiency == 0.66 - - -def test_no_override_keeps_the_table_4b_code_efficiency() -> None: - # Absent the slot, the code default stands (today's behaviour) — the override - # is the only thing that moves it. - epc = _epc_with_override_boiler(None, None) - - inputs = cert_to_inputs(epc) - - assert inputs.main_heating_efficiency == 0.84 - - -def test_seasonal_efficiency_override_feeds_the_eq_d1_water_summer_efficiency() -> None: - # The slot is a `(winter, summer)` pair; the summer leg drives SAP Appendix D - # §D2.1 Eq D1 water-heating efficiency (WHC 901, from main). A lower-band - # boiler burns less efficiently in summer, so it needs MORE hot-water fuel — - # a direction check that the override reaches the Eq D1 branch, not just §206. - default_code = _epc_with_override_boiler(None, None) # code 102 → summer 74% - g_band = _epc_with_override_boiler(None, (66.0, 56.0)) # summer 56% - - hw_default = cert_to_inputs(default_code).hot_water_kwh_per_yr - hw_g_band = cert_to_inputs(g_band).hot_water_kwh_per_yr - - assert hw_g_band > hw_default - - def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: # Arrange — Direction check: a gas combi (Table 4b code 102, 84% eff) # vs a non-condensing gas boiler (code 105, 70% eff) must show through diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index 37bf4bc19..30ea272f1 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -227,7 +227,7 @@ def test_main_heating_system_row_produces_a_heating_overlay() -> None: assert overlays[0].heating.sap_main_heating_code == 104 -def test_band_parsed_from_the_heating_description_sets_the_efficiency_slot() -> None: +def test_band_parsed_from_the_heating_description_sets_the_code() -> None: # Ship-before-FE (parse-on-read): the SEDBUK band is already carried in the # main_heating_system row's original_spreadsheet_description, so the modelling # honours it without waiting for the new boiler_efficiency_band override row. @@ -246,8 +246,10 @@ def test_band_parsed_from_the_heating_description_sets_the_efficiency_slot() -> assert len(overlays) == 1 assert overlays[0].heating is not None - # G-rated gas regular → Table 4b (66, 56), not the condensing 84% default. - assert overlays[0].heating.seasonal_efficiency_override_pct == (66.0, 56.0) + # G-rated gas regular → the band's Table 4b code 115 (66/56), not the + # condensing 84% default (102). + assert overlays[0].heating.sap_main_heating_code == 115 + assert overlays[0].heating.main_heating_index_number is None def test_explicit_band_override_row_wins_over_the_parsed_description() -> None: @@ -268,8 +270,8 @@ def test_explicit_band_override_row_wins_over_the_parsed_description() -> None: overlays = overlays_from(overrides) heating = next(o.heating for o in overlays if o.heating is not None) - # D wins over the description's G → Table 4b (80, 70). - assert heating.seasonal_efficiency_override_pct == (80.0, 70.0) + # D wins over the description's G → Table 4b code 106 (80/70), not 115. + assert heating.sap_main_heating_code == 106 def test_band_repoints_with_the_resolved_fuel_for_an_oil_boiler() -> None: @@ -290,12 +292,34 @@ def test_band_repoints_with_the_resolved_fuel_for_an_oil_boiler() -> None: overlays = overlays_from(overrides) heating = next(o.heating for o in overlays if o.heating is not None) - assert heating.sap_main_heating_code == 127 # oil regular - assert heating.seasonal_efficiency_override_pct == (80.0, 68.0) # oil D → code 126 + # Oil regular D → the oil Table 4b code 126 (80/68), off the base oil code 127. + assert heating.sap_main_heating_code == 126 + assert heating.main_heating_index_number is None -def test_no_band_information_leaves_the_efficiency_slot_unset() -> None: - # A plain boiler description carries no band → no slot → condensing default. +def test_a_band_sets_a_pcdb_index_not_a_table_4b_code() -> None: + # A/B exceed Table 4b's ceiling → the overlay emits a representative PCDB + # index (base code kept; _fold clears it into a PCDB cert). + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", + 0, + "Gas boiler, combi", + original_spreadsheet_description="Boiler: A rated Combi", + ), + ) + ) + + overlays = overlays_from(overrides) + + heating = next(o.heating for o in overlays if o.heating is not None) + assert heating.main_heating_index_number == 18964 # gas A combi (SIME EDEA) + assert heating.sap_main_heating_code == 104 # base kept; _fold clears it + + +def test_no_band_information_leaves_the_base_code_unchanged() -> None: + # A plain boiler description carries no band → base condensing code, no index. overrides = ResolvedPropertyOverrides( rows=( ResolvedPropertyOverride( @@ -310,7 +334,8 @@ def test_no_band_information_leaves_the_efficiency_slot_unset() -> None: overlays = overlays_from(overrides) heating = next(o.heating for o in overlays if o.heating is not None) - assert heating.seasonal_efficiency_override_pct is None + assert heating.sap_main_heating_code == 104 + assert heating.main_heating_index_number is None def test_unresolvable_rows_are_skipped() -> None: