mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-02 21:08:24 +00:00
refactor(overlay): band uses cert-native anchors, not a synthesis field
Reviewer feedback: seasonal_efficiency_override_pct had no lodged-cert analogue and polluted the EpcPropertyData/MainHeatingDetail cert datatype. Replace it with the cert-native efficiency anchors a real cert already uses: - C–G -> sap_main_heating_code = the band's Table 4b code (combi/regular preserved by the code itself); resolved by the existing Table 4b path. - A/B -> main_heating_index_number = a type-matched representative PCDB product; resolved by the existing PCDB Appendix D2.1 path (winter+summer+combi-loss). _fold_heating clears the base code, so the effective cert reads as a real PCDB cert. Ready for future user-supplied product -> PCDB id. Deletes seasonal_efficiency_override_pct from MainHeatingDetail, HeatingOverlay and _MAIN_HEATING_FIELDS, and REVERTS both cert_to_inputs branches — those four files are now byte-identical to main (no synthesis on the cert type, no new calculator branch). Each A/B PCDB id is CI-guarded to its accredited (winter, summer) so a PCDB refresh fails loudly. Oil A-combi (no >90% product, ≈0 homes) keeps its code-130 default. Real certs: A-combi +2.06 SAP (idx 18964), D-regular -0.98 (code 106); corpus unmoved; 810 tests green (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df9073d80a
commit
307fdfb270
8 changed files with 222 additions and 256 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue