Merge pull request #1713 from Hestia-Homes/feat/boiler-efficiency-band

feat: honour landlord boiler SEDBUK efficiency band (5th Heating Companion) — #1704 / ADR-0068
This commit is contained in:
KhalimCK 2026-07-30 17:49:19 +01:00 committed by GitHub
commit 1a9ebacb59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2266 additions and 47 deletions

View file

@ -159,8 +159,12 @@ _Avoid_: roof warning (unspecific), re-roof recommendation (there is no engine m
Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041).
**Heating Companion Set**:
The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise.
_Avoid_: coherent companions (informal), dragged fields, heating defaults
The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise. A **fifth** archetype-forced companion — **boiler efficiency**, keyed by the **Boiler Efficiency Band** — is implemented per ADR-0068 (the override wins over the cert's efficiency, as a replacement makes the lodged value stale).
_Avoid_: coherent companions (informal), dragged fields, heating defaults; treating efficiency as a *deferring* companion (it is forced — the override is the surveyed current boiler)
**Boiler Efficiency Band**:
The SEDBUK **AG** rating a landlord carries in the **Landlord Description** for a boiler override (`Boiler: C rated Combi`, `Boiler: D rated Regular Boiler`) — an *attribute* of the override, **not** a **Recognised Internal Description** archetype. It names *how well the boiler burns*, orthogonal to the `MainHeatingSystemType` archetype that names the *system*; it must never mint per-band archetypes because that enum is **FE-owned** ([[main-heating-system-pgenum-is-fe-owned]]). It resolves to a **`(winter, summer)`** seasonal-efficiency pair — the fifth **Heating Companion** (ADR-0068) — that the calculator consumes ahead of the Table 4b code default (as a PCDB `winter_efficiency_pct` already does). C-and-below reuse accredited Table 4b code values (D→80 %, E→74 %, F→70 %, G→66 % winter); **A/B exceed Table 4b's 84 % gas ceiling** and take SEDBUK-derived values validated against an accredited Elmhurst build. Dropping the band (the pre-ADR-0068 overlay mapped every band to the flat condensing 84 %) over-credits DG and under-credits A/B. It applies to the **SEDBUK-rated wet boilers only — gas/LPG/biogas *and* oil** (both were inflated to their condensing code); **solid-fuel (Table 4a 75 %), electric (100 %) and CPSU** boilers carry an *intrinsic* fixed efficiency and take no band. Stored as its own `boiler_efficiency_band` override row (AG); absence is modelled as no value (no slot → the code's Table 4b default stands).
_Avoid_: "SEDBUK band" as a synonym for the **SAP main heating code** (the band picks the efficiency, not the code); conflating with the gov-EPC `mainheat_energy_eff` element rating (an AG *presentation* band on the cert, not this override attribute); "boiler rating" (ambiguous with the EPC band)
_Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output
**Lodgement Sentinels (ND / NI / AB)**:

View file

@ -15,6 +15,10 @@ from domain.epc.property_overrides.main_fuel_type import MainFuelType
from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard
from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType
from domain.epc.property_overrides.main_heating_guard import main_heating_guard
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.epc.property_overrides.boiler_efficiency_band_guard import (
boiler_efficiency_band_guard,
)
from domain.epc.property_overrides.property_type import PropertyType
from domain.epc.property_overrides.property_type_guard import property_type_guard
from domain.epc.property_overrides.roof_type import RoofType
@ -22,6 +26,9 @@ from domain.epc.property_overrides.roof_guard import roof_guard
from domain.data_transformation.guarded_column_classifier import (
GuardedColumnClassifier,
)
from domain.data_transformation.logging_unknown_column_classifier import (
LoggingUnknownColumnClassifier,
)
from domain.epc.property_overrides.water_heating_type import WaterHeatingType
from domain.epc.property_overrides.water_heating_guard import water_heating_guard
from domain.epc.property_overrides.wall_type import WallType
@ -50,6 +57,9 @@ from infrastructure.postgres.landlord_main_fuel_override_table import (
from infrastructure.postgres.landlord_main_heating_system_override_table import (
LandlordMainHeatingSystemOverrideRow,
)
from infrastructure.postgres.landlord_boiler_efficiency_band_override_table import (
LandlordBoilerEfficiencyBandOverrideRow,
)
from infrastructure.postgres.landlord_water_heating_override_table import (
LandlordWaterHeatingOverrideRow,
)
@ -222,6 +232,28 @@ def _build_columns(
session, LandlordMainHeatingSystemOverrideRow
),
),
"boiler_efficiency_band": lambda src: ClassifiableColumn(
name="boiler_efficiency_band",
source_column=src,
# The SEDBUK band rides the SAME "Heating" source column as
# main_heating_system (like Property Type feeds property_type +
# built_form_type). The band's `Boiler: <A-G> rated ...` format is
# fully deterministic, so the guard is authoritative and the column is
# **guard-only** — NO LLM fallback. An LLM guess could fabricate a band
# the landlord never stated, which here moves SAP + funding eligibility
# (reviewer feedback). Unrecognised descriptions (a plain boiler, a
# non-boiler heating system) become UNKNOWN — never stored — and are
# logged for review (ADR-0068).
classifier=GuardedColumnClassifier(
guard=boiler_efficiency_band_guard,
fallback=LoggingUnknownColumnClassifier(
BoilerEfficiencyBand.UNKNOWN, "boiler_efficiency_band"
),
),
repo=LandlordOverridesRepository[BoilerEfficiencyBand](
session, LandlordBoilerEfficiencyBandOverrideRow
),
),
}
columns: list[ClassifiableColumn[Any]] = []

View file

@ -0,0 +1,82 @@
# Elmhurst RdSAP inputs — UPRN 37020227 (cert 0560-2863-6214-9928-3741, RdSAP-Schema-18.0)
**Lodged SAP:** 70 **Our engine:** 68.5 ← compare Elmhurst against this
**Purpose:** ADR-0068 **B-band** validation. Landlord override = `Boiler: B rated Combi`.
> ## ⭐ SPECIAL — this build is to pin the B-band boiler efficiency
> Unlike the A-rated case, this cert **already lodges a real B-rated boiler**: PCDB index
> **9900 = Ideal *isar* combi**, winter **88.1 %** / summer **79.5 %**. So this is the tightest
> validation — reproduce the cert **as-is** (keep the Ideal isar / a B-rated 8690 % combi),
> then **report back**:
> 1. the **SAP score** Elmhurst gives (expect ≈ our 68.5 / lodged 70),
> 2. the boiler **winter** efficiency Elmhurst shows (expect ≈ 88 %), and
> 3. the **summer** efficiency (expect ≈ 7980 %).
>
> Candidate to confirm: **B-band (winter, summer) ≈ (88.0, 79.5)** — from the real lodged
> product and the PCDB 8690 % median (88.4, 80.0).
## Property Description
| Elmhurst field | Value | Notes |
|---|---|---|
| Property type | **Maisonette** | gov property_type=2 |
| Built form | **Semi-Detached** | gov built_form=2 |
| Age band | **D — 19501966** | construction_age_band D |
| Position | dwelling **above** (party ceiling) | roof heat loss = 0 (see Roofs) |
## Dimensions
| Elmhurst field | Value | Notes |
|---|---|---|
| Total floor area | **45.3 m²** | TFA, single storey |
| Storey height | **2.34 m** | RAW room height |
| No. of storeys | 1 | |
## Walls
| Elmhurst field | Value | Notes |
|---|---|---|
| Construction | **Solid** | wall_construction=8, 395 mm measured |
| Insulation | **None (as built)** | wall_insulation=NI; engine walls = 75.2 W/K (uninsulated solid) |
## Roofs
| Elmhurst field | Value | Notes |
|---|---|---|
| Roof | **Another dwelling above (party ceiling)** | roof heat loss = 0 W/K — do NOT enter a pitched/flat roof |
## Floors
| Elmhurst field | Value | Notes |
|---|---|---|
| Floor | (as lodged) | engine floor = 27.6 W/K |
## Openings
| Elmhurst field | Value | Notes |
|---|---|---|
| Windows | **Double glazing** | glazing code 2; 4 windows synthesised from glazed-area band |
| Doors | 1 (typical) | engine doors = 5.6 W/K |
## Ventilation & Lighting
| Elmhurst field | Value | Notes |
|---|---|---|
| Ventilation | Natural | mechanical_ventilation=0 |
| Low-energy lighting | **100 %** | |
## Space Heating — Main Heating 1 ⭐
| Elmhurst field | Value | Notes |
|---|---|---|
| Main heat | **Gas condensing combi — Ideal isar (PCDB 9900), B-rated** | winter 88.1 / summer 79.5 |
| Fuel | Mains gas | |
| Controls | **Programmer + room thermostat + TRVs** | control 2106 |
| Meter | Single-rate | |
## Water Heating
| Elmhurst field | Value | Notes |
|---|---|---|
| Water heating | **From main heating system** | WHC 901 |
| Cylinder | **None (combi)** | |
## Fields to clear in Elmhurst (do NOT map)
| Elmhurst field | Set to | Why absent |
|---|---|---|
| Roof insulation | n/a | party ceiling — no roof element |
| Extensions / Room-in-Roof | (blank) | single building part |
| Conservatory / PV / Wind / Hydro | none | not lodged |
| Cylinder / Solar Water / WWHRS / FGHRS | none | combi, none lodged |
| Secondary heating | none | not lodged |

View file

@ -0,0 +1,299 @@
{
"uprn": 37020227,
"roofs": [
{
"description": {
"value": "(another dwelling above)",
"language": "1"
},
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"walls": [
{
"description": {
"value": "System built, as built, no insulation (assumed)",
"language": "1"
},
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
}
],
"floors": [
{
"description": {
"value": "Solid, no insulation (assumed)",
"language": "1"
},
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 2,
"window": {
"description": {
"value": "Fully double glazed",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"addendum": {
"system_build": "true"
},
"lighting": {
"description": {
"value": "Low energy lighting in all fixed outlets",
"language": "1"
},
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "PO12 4TR",
"hot_water": {
"description": {
"value": "From main system",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "GOSPORT",
"built_form": 2,
"created_at": "2018-10-07 20:52:43.000000",
"door_count": 1,
"glazed_area": 1,
"region_code": 16,
"report_type": 2,
"sap_heating": {
"cylinder_size": 1,
"water_heating_code": 901,
"water_heating_fuel": 26,
"instantaneous_wwhrs": {
"rooms_with_bath_and_or_shower": 1,
"rooms_with_mixer_shower_no_bath": 0,
"rooms_with_bath_and_mixer_shower": 0
},
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 9900
}
],
"immersion_heating_type": "NA",
"has_fixed_air_conditioning": "false"
},
"sap_version": 9.93,
"schema_type": "RdSAP-Schema-18.0",
"uprn_source": "Energy Assessor",
"country_code": "EAW",
"main_heating": [
{
"description": {
"value": "Boiler and radiators, mains gas",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"dwelling_type": {
"value": "Ground-floor flat",
"language": "1"
},
"language_code": 1,
"property_type": 2,
"address_line_1": "Flat 3 Garland Court",
"address_line_2": "Forton Road",
"assessment_type": "RdSAP",
"completion_date": "2018-10-07",
"inspection_date": "2018-09-27",
"extensions_count": 0,
"measurement_type": 1,
"sap_flat_details": {
"level": 1,
"top_storey": "N",
"flat_location": 0,
"heat_loss_corridor": 2,
"unheated_corridor_length": {
"value": 4.4,
"quantity": "metres"
}
},
"total_floor_area": 45,
"transaction_type": 8,
"conservatory_type": 1,
"heated_room_count": 2,
"registration_date": "2018-10-07",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"photovoltaic_supply": {
"none_or_no_details": {
"pv_connection": 0,
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"wind_turbines_terrain_type": 2
},
"secondary_heating": {
"description": {
"value": "None",
"language": "1"
},
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 395,
"floor_heat_loss": 7,
"roof_construction": 3,
"wall_construction": 8,
"building_part_number": 1,
"sap_alternative_wall": {
"wall_area": 10.296,
"sheltered_wall": "Y",
"wall_dry_lined": "N",
"wall_construction": 8,
"wall_insulation_type": 4,
"wall_thickness_measured": "N",
"wall_insulation_thickness": "NI"
},
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.34,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 45.26,
"quantity": "square metres"
},
"party_wall_length": {
"value": 7.3,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 19.7,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "D",
"party_wall_construction": 1,
"wall_thickness_measured": "Y",
"roof_insulation_location": "ND",
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
}
],
"low_energy_lighting": 100,
"solar_water_heating": "N",
"habitable_room_count": 2,
"heating_cost_current": {
"value": 320,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 1.7,
"energy_rating_average": 60,
"energy_rating_current": 70,
"lighting_cost_current": {
"value": 35,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": {
"value": "Programmer, room thermostat and TRVs",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"multiple_glazing_type": 2,
"open_fireplaces_count": 0,
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 291,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 75,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 29,
"currency": "GBP"
},
"indicative_cost": "\u00a34,000 - \u00a36,000",
"improvement_type": "W2",
"improvement_details": {
"improvement_number": 58
},
"improvement_category": 5,
"energy_performance_rating": 72,
"environmental_impact_rating": 73
}
],
"co2_emissions_potential": 1.6,
"energy_rating_potential": 72,
"lighting_cost_potential": {
"value": 35,
"currency": "GBP"
},
"schema_version_original": "LIG-18.0",
"hot_water_cost_potential": {
"value": 76,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 1626,
"space_heating_existing_dwelling": 4686
},
"energy_consumption_current": 218,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "2.1.0.0",
"energy_consumption_potential": 198,
"environmental_impact_current": 70,
"fixed_lighting_outlets_count": 7,
"windows_transmission_details": {
"u_value": 2,
"data_source": 2,
"solar_transmittance": 0.72
},
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 73,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 38,
"low_energy_fixed_lighting_outlets_count": 7
}

View file

@ -0,0 +1,87 @@
# Elmhurst RdSAP inputs — UPRN 15017550 (cert 0010-2504-7060-2529-3175, RdSAP-Schema-20.0.0)
**Lodged SAP:** 66 **Our engine:** 66.5 ← compare Elmhurst against this
**Purpose:** ADR-0068 **A-band** validation. Landlord override = `Boiler: A rated Combi`.
> ## ⭐ SPECIAL — this build is to pin the A-band boiler efficiency
> The lodged cert models this gas combi at the generic **condensing code (84 %)** — the
> under-credit ADR-0068 fixes. Build the dwelling faithfully **but set the main boiler to a
> representative A-rated (SEDBUK ≥ 90 %) condensing combi**, then **report back three numbers**:
> 1. the **SAP score** Elmhurst gives,
> 2. the **winter** seasonal efficiency Elmhurst applies to the boiler (worksheet (206)), and
> 3. the **summer** seasonal efficiency (Appendix D / water-heating line).
>
> Enter the boiler via the **SEDBUK/PCDB database** picking any A-rated combi (winter ≥ 90 %),
> **or** manually as SEDBUK winter **90.3 %** / summer **81.5 %** (the PCDB A-rated combi median).
> Candidate to confirm: **A-band (winter, summer) ≈ (90.0, 80.0)**.
## Property Description
| Elmhurst field | Value | Notes |
|---|---|---|
| Property type | **Flat** | gov property_type=1 |
| Built form | **Mid-Terrace** | gov built_form=3 |
| Age band | **H — 19831990** | construction_age_band H |
| Position in block / floor level | mid-floor flat | single storey, no roof/ground exposure beyond party |
## Dimensions
| Elmhurst field | Value | Notes |
|---|---|---|
| Total floor area | **57.9 m²** | TFA, single storey (part 0, floor 0) |
| Storey height (ground/only) | **2.68 m** | RAW room height — do NOT add +0.25 (single storey) |
| No. of storeys | 1 | |
## Walls
| Elmhurst field | Value | Notes |
|---|---|---|
| Construction | **Cavity** | wall_construction=4, 300 mm measured |
| Insulation | **As built / no insulation (assumed)** | wall_insulation_thickness=NI |
| Wall U-value | (let Elmhurst derive) | engine walls = 21.9 W/K |
## Roof
| Elmhurst field | Value | Notes |
|---|---|---|
| Type | **Pitched, access to loft** | roof_construction_type |
| Insulation | **100 mm at joists** | roof_insulation_thickness=100 mm, location=2 (joists) |
## Floors
| Elmhurst field | Value | Notes |
|---|---|---|
| Ground floor | **Suspended, not timber** | floor_construction_type; engine floor = 41.1 W/K |
## Openings
| Elmhurst field | Value | Notes |
|---|---|---|
| Windows | **Triple / double glazing** | glazing code 3; 4 windows synthesised from glazed-area band × TFA (not real geometry) |
| Glazed area | typical | engine windows = 23.7 W/K |
| Doors | 1 (typical) | engine doors = 11.1 W/K |
## Ventilation & Lighting
| Elmhurst field | Value | Notes |
|---|---|---|
| Ventilation | Natural (no mech vent) | mechanical_ventilation=0 |
| Low-energy lighting | **67 %** | |
## Space Heating — Main Heating 1 ⭐
| Elmhurst field | Value | Notes |
|---|---|---|
| Main heat | **Gas condensing combi, A-rated** | ⭐ set to SEDBUK ≥ 90 % (lodged code 104 = generic 84 %; we are pinning the A-band) |
| Fuel | Mains gas | main_fuel 26 |
| Controls | **Programmer + room thermostat + TRVs** | control 2106 |
| Meter | Single-rate | |
## Water Heating
| Elmhurst field | Value | Notes |
|---|---|---|
| Water heating | **From main heating system** | WHC 901 |
| Cylinder | **None (combi)** | has_hot_water_cylinder=False |
## Fields to clear in Elmhurst (do NOT map)
| Elmhurst field | Set to | Why absent |
|---|---|---|
| Extensions (1st4th) age/area | (blank) | single building part |
| Room-in-Roof | (blank) | none lodged |
| Conservatory | none | not lodged |
| Water Heating · cylinder | none | combi — no cylinder |
| Solar Water / WWHRS / FGHRS | none | not lodged |
| PV / Wind / Hydro | none | photo_supply none |
| Secondary heating | none | not lodged |

View file

@ -0,0 +1,340 @@
{
"uprn": 15017550,
"roofs": [
{
"description": {
"value": "Pitched, 100 mm loft insulation",
"language": "1"
},
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"walls": [
{
"description": {
"value": "Cavity wall, filled cavity",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": {
"value": "Suspended, no insulation (assumed)",
"language": "1"
},
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 2,
"window": {
"description": {
"value": "Fully double glazed",
"language": "1"
},
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
"lighting": {
"description": {
"value": "Low energy lighting in 67% of fixed outlets",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"postcode": "NN3 2BE",
"hot_water": {
"description": {
"value": "From main system",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "NORTHAMPTON",
"built_form": 3,
"created_at": "2021-08-02 11:49:18.043921",
"door_count": 2,
"glazed_area": 1,
"glazing_gap": "16+",
"region_code": 6,
"report_type": 2,
"sap_heating": {
"cylinder_size": 1,
"water_heating_code": 901,
"water_heating_fuel": 26,
"instantaneous_wwhrs": {
"rooms_with_bath_and_or_shower": 1,
"rooms_with_mixer_shower_no_bath": 0,
"rooms_with_bath_and_mixer_shower": 0
},
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "N",
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"sap_main_heating_code": 104,
"central_heating_pump_age": 0,
"main_heating_data_source": 2
}
],
"immersion_heating_type": "NA",
"has_fixed_air_conditioning": "false"
},
"sap_version": 9.94,
"schema_type": "RdSAP-Schema-20.0.0",
"uprn_source": "Energy Assessor",
"country_code": "EAW",
"main_heating": [
{
"description": {
"value": "Boiler and radiators, mains gas",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"dwelling_type": {
"value": "end-terrace bungalow",
"language": "1"
},
"language_code": 1,
"property_type": 1,
"address_line_1": "1 HOWARD BILEY GARDENS",
"assessment_type": "RdSAP",
"completion_date": "2021-08-02",
"inspection_date": "2021-06-24",
"extensions_count": 0,
"measurement_type": 1,
"total_floor_area": 58,
"transaction_type": 13,
"conservatory_type": 1,
"heated_room_count": 2,
"pvc_window_frames": "true",
"registration_date": "2021-08-02",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"photovoltaic_supply": {
"none_or_no_details": {
"pv_connection": 0,
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"wind_turbines_terrain_type": 2
},
"secondary_heating": {
"description": {
"value": "None",
"language": "1"
},
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 300,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.68,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 57.93,
"quantity": "square metres"
},
"party_wall_length": {
"value": 4.51,
"quantity": "metres"
},
"floor_construction": 3,
"heat_loss_perimeter": {
"value": 27.93,
"quantity": "metres"
}
}
],
"wall_insulation_type": 2,
"construction_age_band": "H",
"party_wall_construction": 2,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "100mm",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
}
],
"low_energy_lighting": 67,
"solar_water_heating": "N",
"habitable_room_count": 2,
"heating_cost_current": {
"value": 482,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.8,
"energy_rating_average": 60,
"energy_rating_current": 66,
"lighting_cost_current": {
"value": 67,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": {
"value": "Programmer, room thermostat and TRVs",
"language": "1"
},
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"multiple_glazing_type": 3,
"open_fireplaces_count": 0,
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 395,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 88,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 70,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 30,
"currency": "GBP"
},
"indicative_cost": "\u00a3100 - \u00a3350",
"improvement_type": "A",
"improvement_details": {
"improvement_number": 5
},
"improvement_category": 5,
"energy_performance_rating": 67,
"environmental_impact_rating": 65
},
{
"sequence": 2,
"typical_saving": {
"value": 59,
"currency": "GBP"
},
"indicative_cost": "\u00a3800 - \u00a31,200",
"improvement_type": "W1",
"improvement_details": {
"improvement_number": 57
},
"improvement_category": 5,
"energy_performance_rating": 71,
"environmental_impact_rating": 70
},
{
"sequence": 3,
"typical_saving": {
"value": 15,
"currency": "GBP"
},
"indicative_cost": "\u00a310",
"improvement_type": "E",
"improvement_details": {
"improvement_number": 35
},
"improvement_category": 5,
"energy_performance_rating": 71,
"environmental_impact_rating": 70
},
{
"sequence": 4,
"typical_saving": {
"value": 29,
"currency": "GBP"
},
"indicative_cost": "\u00a34,000 - \u00a36,000",
"improvement_type": "N",
"improvement_details": {
"improvement_number": 19
},
"improvement_category": 5,
"energy_performance_rating": 73,
"environmental_impact_rating": 73
},
{
"sequence": 5,
"typical_saving": {
"value": 346,
"currency": "GBP"
},
"indicative_cost": "\u00a33,500 - \u00a35,500",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 86,
"environmental_impact_rating": 84
}
],
"co2_emissions_potential": 1.1,
"energy_rating_potential": 86,
"lighting_cost_potential": {
"value": 50,
"currency": "GBP"
},
"schema_version_original": "LIG-19.0",
"hot_water_cost_potential": {
"value": 59,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 1827,
"impact_of_loft_insulation": -645,
"space_heating_existing_dwelling": 7947
},
"energy_consumption_current": 272,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "2.1.0.1",
"energy_consumption_potential": 105,
"environmental_impact_current": 63,
"fixed_lighting_outlets_count": 6,
"windows_transmission_details": {
"u_value": 2.6,
"data_source": 2,
"solar_transmittance": 0.76
},
"current_energy_efficiency_band": "D",
"environmental_impact_potential": 84,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "B",
"co2_emissions_current_per_floor_area": 48,
"low_energy_fixed_lighting_outlets_count": 4
}

View file

@ -0,0 +1,177 @@
# Boiler efficiency band is the fifth Heating Companion
## Status
accepted — implemented & live. Band → **cert-native efficiency anchor** (C-and-
below: the band's Table 4b `sap_main_heating_code`; A/B: a representative PCDB
`main_heating_index_number`) — no synthesised field on the cert datatype (see
Decision 4). **A/B live for gas/LPG/biogas and oil**: gas from accredited Elmhurst
RdSAP10 builds (PCDB 18790 reg / 15029 combi; 18964 gas-A-combi, 8106 gas-B-reg),
oil from accredited PCDB (17292, 10498, 15959) after the gas builds proved
Elmhurst's worksheet (206)/(217) == the PCDB winter/summer verbatim (SAP 10.2 App
D2.1). Oil A-combi has no >90 % product (≈0 homes) → keeps its code-130 default.
Each A/B id CI-guarded. Whole-dwelling: A-combi cert 15017550 +2.06 SAP (via PCDB
idx), D-regular 0.98 (via code); gov-API RdSAP corpus unmoved.
## Context
A `main_heating_system` **Landlord Override** maps a **Recognised Internal
Description** archetype to a **SAP main heating code** and drags a **Heating
Companion Set** (ADR-0048): category and control are *archetype-forced*, fuel
and meter *defer*. The overlay
(`domain/epc/property_overlays/main_heating_system_overlay.py`) maps every gas
boiler to a single modern/condensing Table 4b code — `Gas boiler, regular` → 102,
`Gas boiler, combi` → 104, both **84 % winter / 7475 % summer**.
The Landlord spreadsheet's **SEDBUK AG efficiency band** — carried in the
**Landlord Description** as `Boiler: C rated Combi`, `Boiler: D rated Regular
Boiler` — is **dropped at classification**: the `override_value` collapses to the
bare archetype (`Gas boiler, combi`) and the band survives only in
`property_overrides.original_spreadsheet_description`. So the companion set has
**no efficiency dimension** (the overlay docstring already flags "no efficiency
slot on the overlay/`MainHeatingDetail`"), and *every* overridden gas boiler is
modelled at the condensing efficiency regardless of its real band.
Portfolio-796 audit evidence (2026-07-28), of 24 555 overridden gas boilers:
| SEDBUK band | count | SEDBUK eff | modelled at 84 % is… |
|---|---:|---|---|
| A | 14 590 | ≥ 90 % | **under**-credited |
| B | 260 | 8690 % | slightly under |
| C | 6 251 | 8286 % | ~right |
| D | 1 691 | 7882 % | over |
| E | 1 196 | 7478 % | over |
| F | 206 | 7074 % | over |
| G | 360 | < 70 % | over-credited |
This is a **code-fidelity** gap (the overlay throws away information the landlord
gave us), distinct from the sibling **fuel-override data-quality** question
(#1701 — whether a landlord's `main_fuel` assertion is itself correct).
## Decision
1. **Efficiency becomes the fifth Heating Companion, in the *archetype-forced*
tier** (alongside category and control). When the override's band-derived
efficiency disagrees with the lodged cert, **the override wins** — a
system-replacing override is the landlord telling us the current truth, so the
cert efficiency is treated as stale.
2. **The SEDBUK band is a new *attribute*, not a new archetype.** The
`MainHeatingSystemType` value stays `Gas boiler, combi`; a separate
**Boiler Efficiency Band** field rides alongside it. The archetype describes
the *system*, the band describes *how well it burns* — orthogonal. Crucially,
the `MainHeatingSystemType` PG enum is **FE-owned**
([[main-heating-system-pgenum-is-fe-owned]]); minting `Gas-boiler-combi-D-rated`
archetypes would explode a shared cross-team contract 7×.
3. **Full fidelity (option a), symmetric — not a downward-only cap.** The
companion carries every band's efficiency: A/B rise **above** 84 %, DG fall.
"Override wins" is symmetric by construction; a one-sided clamp would encode a
policy ("we only correct downward") that a later reader would silently "fix"
into (a) anyway.
4. **Mechanism: `(archetype + fuel) → base code`, then `band → a cert-native
efficiency anchor`.** The **archetype and its resolved fuel** pick the *base*
`sap_main_heating_code` — ADR-0067's "resolve fuel at resolution" (`Gas boiler,
regular` + an oil `main_fuel` → `Oil boiler, regular` → an oil code). The
**band** then picks the boiler's efficiency using the **same fields a real
cert carries** — deliberately *not* a synthesised efficiency field on the cert
datatype (an earlier draft added a `(winter, summer)` slot on
`MainHeatingDetail`; rejected in review as it had no lodged-cert analogue and
polluted the API-cert type):
- **C-and-below** → the band's **Table 4b `sap_main_heating_code`** (regular
102→106/101/105/115; combi 104→108/103/107/118). The code is itself
combi/regular-specific, so system-type behaviour is preserved by the code
alone; the existing Table 4b path resolves the efficiency.
- **A/B** (exceed Table 4b's 84 % ceiling, so no code exists) → a
**representative PCDB `main_heating_index_number`**, type-matched to the
archetype (combi vs regular, because the combi-loss cascade reads the
record). `_fold_heating` clears the base code when an index is present, so
the **Effective EPC reads as a real PCDB-lodged cert**, resolved by the
existing PCDB Appendix D2.1 path (winter + summer + combi-loss). This is also
the natural on-ramp to a **future user-supplied product → PCDB id**.
So efficiency is looked up by **(fuel family, boiler type, band)** and carried
by cert-native fields only — never a synthesised number on the cert type. Each
A/B product id is CI-guarded to its accredited `(winter, summer)`.
5. **The band mechanism covers exactly the SEDBUK-rated wet boilers — gas and
oil — and both are currently inflated.** Mains gas, LPG and biogas **share** the
gas codes **101119**; oil is the separate liquid-fuel range **124132**. The
overlay today defaults **both** to their condensing/modern variant — gas 102/104
(84 %) *and* oil 127/130 (84 %/82 %) — so the DG over-credit exists for oil as
well as gas. Every other boiler archetype we hold has **no SEDBUK band** and is
already modelled at a sensible, non-inflated fixed efficiency, so it needs no
band and is recorded here as *intrinsic-efficiency*:
| Boiler archetype | Base code | Efficiency | Treatment |
|---|---|---|---|
| Gas boiler, regular / combi | 102 / 104 | Table 4b, condensing | **SEDBUK band** (below) |
| Oil boiler, regular / combi | 127 / 130 | Table 4b, condensing | **SEDBUK band** (below) |
| Solid fuel boiler | 151 | Table 4a **75 %** (`cert_to_inputs.py:2309`) | intrinsic — SEDBUK doesn't rate solid fuel; a PCDB Table-122 product index refines it |
| Electric boiler | 191 | Table 4a **100 %** (direct-acting) | intrinsic — electric |
| Electric CPSU | 192 | Table 4a, electric | intrinsic — electric |
| Gas CPSU | 120 | Table 4b 74 % (non-condensing default) | intrinsic — not inflated; CPSU isn't SEDBUK-banded |
CPSU (120123) and range cookers (133141) are distinct archetypes, not the
regular/combi boiler a band describes. **Solid-fuel efficiency *granularity*
(manual vs auto-feed / pellet) is an archetype question, not a band one** — the
single `Solid fuel boiler` member (all coal/wood/dual collapse to it, ADR-0067)
is a separate follow-up if finer solid-fuel efficiency is wanted.
The per-fuel band → efficiency lookup for the two banded fuels, `(winter, summer)`:
| Band | Gas regular / combi | Oil regular / combi | Source |
|---|---|---|---|
| A | **slot** ~(90, 80) | **slot** ~(88, 78) | SEDBUK ≥ 90 %, **needs oracle** |
| B | **slot** ~(88, 78) | **slot** ~(87, 77) | SEDBUK 8690 %, **needs oracle** |
| C | 102 (84,74) / 104 (84,75) | 127 (84,72) / 130 (82,73) | Table 4b (accredited) |
| D | 106 (80,70) / 108 (80,71) | 126 (80,68) / 129 (77,68) | Table 4b |
| E | 101 (74,64) / 103 (74,65) | 125 (71,59) / 129 (77,68) ⚠ | Table 4b |
| F | 105 (70,60) / 107 (70,61) | 125 (71,59) / 128 (71,62) | Table 4b |
| G | 115 (66,56) / 118 (66,57) | 124 (66,54) / 128 (71,62) ⚠ | Table 4b |
**C-and-below reuse accredited Table 4b code values** — no oracle needed.
**A/B exceed Table 4b's 84 % ceiling** (gas) / 8284 % (oil), so they *must*
use the slot with SEDBUK-derived values — the only judgement calls, carrying
the validation obligation below. ⚠ **Oil's code set is sparser** than gas and
does not line up cleanly with SEDBUK bands (no oil code in the 7478 % E range
for regular; nothing below ~66/71 % for G-combi), so oil DG are *approximate*
nearest-fit — a follow-up may extend the slot to more oil bands rather than
borrow a mismatched code.
6. **Data / migration.** Add a `boiler_efficiency_band` to the override
representation; **backfill** it for already-ingested overrides by re-parsing
`original_spreadsheet_description`, and extend the **Landlord Description →
Recognised Internal Description** classifier to capture the band going forward.
Then **re-model** the affected overridden-boiler population.
## Considered options
- **Code-only (reuse existing Table 4b codes per band), no slot.** Rejected as
the *sole* mechanism: Table 4b's non-PCDB gas codes cap at 84 %, so A/B cannot
rise — that silently delivers the downward-only cap, not full fidelity. Retained
for C-and-below, where accredited codes exist and no slot is required.
- **Downward-only clamp (ship "cap the over-credit" first).** Rejected: "override
wins" is symmetric; a clamp encodes a temporary policy that reads as a bug later.
- **A new archetype per band.** Rejected: explodes the FE-owned
`MainHeatingSystemType` enum.
- **Defer to the cert efficiency when it "looks fine".** Rejected: the divergent
case *is* the entire bug, and a replacement override makes the cert efficiency
stale — so the check only spares the cases that were never wrong.
## Consequences
- **Positive.** Over-credited DG baselines corrected down to published Table 4b
values; A/B corrected up; the override finally encodes the efficiency the
landlord surveyed.
- **Cost / risk — the A/B upside.** It moves ~14 850 accredited baselines **up**
(~+13 SAP each, small per home but **systematic**); some cross a band boundary,
rippling into **Recommendations** and **funding eligibility**. Therefore the
A/B `(winter, summer)` numbers (rows A, B only) **must be validated against an
accredited Elmhurst SAP 10.2 build** before the upside ships. The C-and-below
downside uses published Table 4b values and needs no oracle.
- **Migration + re-model** of the overridden-boiler population; the stored
baselines change, so this is an operational re-base, not a silent code swap.
- Sibling data-quality reconciliation: the cheaper-direction `main_fuel`
overrides (#1701).

View file

@ -0,0 +1,50 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import TypeVar
from domain.data_transformation.column_classifier import ColumnClassifier
logger = logging.getLogger(__name__)
E = TypeVar("E", bound=Enum)
# Cap the sample of unresolved descriptions logged, so a large batch surfaces the
# surprise without flooding the log.
_SAMPLE_SIZE = 10
class LoggingUnknownColumnClassifier(ColumnClassifier[E]):
"""A ``ColumnClassifier`` that resolves nothing — it maps every description to
the enum's ``unknown`` member and logs a warning naming the column and a sample
of what it saw.
Its purpose is to be a **deterministic, non-fabricating fallback** for a
``GuardedColumnClassifier`` whose guard is authoritative for the column's
format. Where the LLM fallback would *guess* a category (and could invent a
value the landlord never stated high-stakes for the Boiler Efficiency Band,
which moves SAP + funding eligibility), this fallback records ``unknown`` and
surfaces the misses for review instead. Guard hits never reach it, so only the
genuinely unrecognised descriptions are logged.
"""
def __init__(self, unknown: E, column_name: str) -> None:
self._unknown = unknown
self._column_name = column_name
def classify(self, descriptions: set[str]) -> dict[str, E]:
if not descriptions:
return {}
sample = sorted(descriptions)[:_SAMPLE_SIZE]
more = "" if len(descriptions) <= _SAMPLE_SIZE else f" (+{len(descriptions) - _SAMPLE_SIZE} more)"
logger.warning(
"%s: %d description(s) not recognised by the guard -> recorded %r "
"(never fabricated, not persisted); sample: %s%s",
self._column_name,
len(descriptions),
self._unknown.value,
sample,
more,
)
return {description: self._unknown for description in descriptions}

View file

@ -18,18 +18,28 @@ manual charge control for storage heaters. So adding a heating archetype is just
adding its code coherent companions fall out. Synthesis owns coherence; the
calculator never normalises a lodged cert.
The SEDBUK A-G efficiency band the Hyde "Heating" column carries is NOT honoured
yet (no efficiency slot on the overlay/MainHeatingDetail) -- archetypes map to
their modern/condensing Table 4b code, so an old low-rated boiler is currently
modelled at the condensing efficiency. Heat pumps and community heating (which
resolve via main_heating_index_number / community codes, not a Table 4b code)
are left UNKNOWN until modelled. Unresolvable values produce no overlay.
The SEDBUK A-G efficiency band the "Heating" column carries is honoured as the
fifth Heating Companion (ADR-0068), via the boiler's **cert-native efficiency
anchor** no synthesised efficiency field. The `band` argument (see
`band_efficiency_anchor`) picks:
- **C-and-below** the band's Table 4b `sap_main_heating_code` (the code is
itself combi/regular-specific, so hot-water behaviour is preserved), so an
old low-rated boiler is modelled at its band efficiency, not the condensing
84 %;
- **A/B** (exceed Table 4b's 84 % ceiling) → a representative PCDB
`main_heating_index_number`; `_fold_heating` clears the base code, so the
effective cert reads as a real PCDB-lodged cert.
Solid-fuel / electric / CPSU boilers are intrinsic-efficiency and take no band
(the anchor is a no-op). Heat pumps and community heating (which resolve via
main_heating_index_number / community codes, not a Table 4b code) are left
UNKNOWN until modelled. Unresolvable values produce no overlay.
"""
from __future__ import annotations
from typing import Optional
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from domain.sap10_calculator.tables.table_12a import (
OFF_PEAK_IMPLYING_HEATING_CODES,
@ -238,6 +248,97 @@ _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 → 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 AG
# 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 → 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)
(102, BoilerEfficiencyBand.C): 102, # (84, 74)
(102, BoilerEfficiencyBand.D): 106, # (80, 70)
(102, BoilerEfficiencyBand.E): 101, # (74, 64)
(102, BoilerEfficiencyBand.F): 105, # (70, 60)
(102, BoilerEfficiencyBand.G): 115, # (66, 56)
# Gas boiler, combi (base 104)
(104, BoilerEfficiencyBand.C): 104, # (84, 75)
(104, BoilerEfficiencyBand.D): 108, # (80, 71)
(104, BoilerEfficiencyBand.E): 103, # (74, 65)
(104, BoilerEfficiencyBand.F): 107, # (70, 61)
(104, BoilerEfficiencyBand.G): 118, # (66, 57)
# Oil boiler, regular (base 127) — ⚠ E/F nearest-fit to code 125
(127, BoilerEfficiencyBand.C): 127, # (84, 72)
(127, BoilerEfficiencyBand.D): 126, # (80, 68)
(127, BoilerEfficiencyBand.E): 125, # (71, 59) ⚠
(127, BoilerEfficiencyBand.F): 125, # (71, 59)
(127, BoilerEfficiencyBand.G): 124, # (66, 54)
# Oil boiler, combi (base 130) — ⚠ D/E to 129, F/G to 128
(130, BoilerEfficiencyBand.C): 130, # (82, 73)
(130, BoilerEfficiencyBand.D): 129, # (77, 68)
(130, BoilerEfficiencyBand.E): 129, # (77, 68) ⚠
(130, BoilerEfficiencyBand.F): 128, # (71, 62)
(130, BoilerEfficiencyBand.G): 128, # (71, 62) ⚠
}
# A/B exceed Table 4b's 84 % gas / 8284 % 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.
#
# 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_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);
- **CG** 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
# condensing, 104 condensing combi, 120 CPSU, 401-404 storage heaters, 191
@ -408,13 +509,23 @@ def _natural_fuel_for(code: int) -> Optional[int]:
return None
def _gas_boiler_overlay(code: int) -> HeatingOverlay:
def _gas_boiler_overlay(
code: int, band: Optional[BoilerEfficiencyBand] = None
) -> HeatingOverlay:
"""The coherent gas-boiler companion set: a mains-gas connection + gas main
fuel, the gas-boiler heating category, a fanned room-sealed flue, full modern
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)."""
main system (a combi has no cylinder; a regular boiler / CPSU keeps one).
The SEDBUK band (when supplied and applicable) picks the boiler's cert-native
efficiency anchor the band's Table 4b `sap_main_heating_code` (CG) 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,
@ -427,7 +538,9 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay:
)
def _fuel_boiler_overlay(code: int) -> HeatingOverlay:
def _fuel_boiler_overlay(
code: int, band: Optional[BoilerEfficiencyBand] = None
) -> HeatingOverlay:
"""The coherent companion set for a non-gas wet boiler (oil / solid fuel),
modelled on the gas-boiler pattern (ADR-0067): SAP Table 4a category 2, full
modern controls (Table 4e 2106), a single-rate meter, hot water from the main
@ -438,10 +551,16 @@ def _fuel_boiler_overlay(code: int) -> HeatingOverlay:
carbon); `gas_connection_available` is left UNSET (None) so it inherits the
lodged EPC an oil/solid boiler needs no gas main, unlike a gas boiler which
forces it True; and a solid-fuel boiler vents through a conventional
(non-fanned) flue."""
(non-fanned) flue.
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,
@ -464,15 +583,17 @@ def natural_fuel_for(main_heating_value: str) -> Optional[int]:
def main_heating_overlay_for(
main_heating_value: str, building_part: int
main_heating_value: str,
building_part: int,
band: Optional[BoilerEfficiencyBand] = None,
) -> Optional[EpcSimulation]:
code = _MAIN_HEATING_CODES.get(main_heating_value)
if code is None:
return None
if code in _GAS_BOILER_CODES:
return EpcSimulation(heating=_gas_boiler_overlay(code))
return EpcSimulation(heating=_gas_boiler_overlay(code, band))
if code in _FUEL_BOILER_CODES:
return EpcSimulation(heating=_fuel_boiler_overlay(code))
return EpcSimulation(heating=_fuel_boiler_overlay(code, band))
category = _category_for(code)
control = _control_for(code)
if category is None or control is None:

View file

@ -0,0 +1,35 @@
from enum import Enum
class BoilerEfficiencyBand(Enum):
"""The SEDBUK A-G efficiency band a landlord carries in the Boiler Landlord
Description (``Boiler: C rated Combi``) the fifth Heating Companion
(ADR-0068).
An *attribute* of a ``main_heating_system`` override, orthogonal to the
``MainHeatingSystemType`` archetype: the archetype names the *system*, the
band names *how well it burns*. It is deliberately NOT a per-band archetype
that enum is FE-owned ([[main-heating-system-pgenum-is-fe-owned]]), and 7
bands x N boiler types would explode a cross-team contract.
Each band resolves (via the main-heating overlay) to a ``(winter, summer)``
seasonal-efficiency pair the calculator consumes ahead of the Table 4b code
default applied to the SEDBUK-rated wet boilers only (gas/LPG/biogas and
oil); solid-fuel / electric / CPSU boilers carry an intrinsic fixed
efficiency and take no band.
``UNKNOWN`` is the classifier's "no band recognised" sentinel (a plain
boiler, or a non-boiler heating system). It is never persisted as an
override absence of a band is modelled as *no value*, so the boiler keeps
its Table 4b code default mirroring how the finaliser drops the other
``UNKNOWN`` classifier outputs.
"""
A = "A"
B = "B"
C = "C"
D = "D"
E = "E"
F = "F"
G = "G"
UNKNOWN = "Unknown"

View file

@ -0,0 +1,39 @@
"""Parse the SEDBUK A-G efficiency band out of a Landlord "Heating" description.
The single source of truth for the band, shared by three call sites so they
cannot drift (mirroring how ``main_heating_guard`` backs both the live
classifier path and ``reclassify_main_heating``):
* the modelling read path (``overlays_from`` the main-heating overlay slot),
* the ``boiler_efficiency_band`` backfill,
* the live classifier column (as its deterministic guard).
The Landlord "Heating" column carries the band as ``Boiler: <A-G> rated
<Combi|Regular Boiler|NA>``. A cell can name a second system after ``System 2:``;
the primary (system 1) band leads the cell, so the first ``Boiler: <letter>
rated`` occurrence is the one that scores the dwelling's main system. A
description with no band phrasing (a plain boiler, or a non-boiler heating
system) returns ``None`` absence of a band, not a guess.
"""
from __future__ import annotations
import re
from typing import Optional
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
# `Boiler: <A-G> rated` — the SEDBUK band phrasing. Case-insensitive (the
# backfill lowercases the stored description). `\b` after the letter rejects a
# stray "H rated" or a multi-letter token; the leading "Boiler:" anchors it so a
# bare "A rated" elsewhere in free text cannot match.
_BAND_RE = re.compile(r"boiler:\s*([a-g])\s+rated\b", re.IGNORECASE)
def boiler_efficiency_band_guard(description: str) -> Optional[BoilerEfficiencyBand]:
"""The SEDBUK band of the primary (system 1) boiler in a Landlord "Heating"
description, or ``None`` when the description carries no band phrasing."""
match = _BAND_RE.search(description)
if match is None:
return None
return BoilerEfficiencyBand(match.group(1).upper())

View file

@ -37,6 +37,9 @@ from infrastructure.postgres.landlord_main_fuel_override_table import (
from infrastructure.postgres.landlord_main_heating_system_override_table import (
LandlordMainHeatingSystemOverrideRow,
)
from infrastructure.postgres.landlord_boiler_efficiency_band_override_table import (
LandlordBoilerEfficiencyBandOverrideRow,
)
from infrastructure.postgres.landlord_wall_type_override_table import (
LandlordWallTypeOverrideRow,
)
@ -58,6 +61,10 @@ _ROW_TYPES: dict[str, type] = {
"construction_age_band": LandlordConstructionAgeBandOverrideRow,
"water_heating": LandlordWaterHeatingOverrideRow,
"main_heating_system": LandlordMainHeatingSystemOverrideRow,
# An attribute of the main_heating_system override (consumed by its overlay,
# not a standalone one) — but still classified + cached + stored, so it needs
# a reader entry for the finaliser to load its vocab (ADR-0068).
"boiler_efficiency_band": LandlordBoilerEfficiencyBandOverrideRow,
}

View file

@ -0,0 +1,71 @@
"""SQLModel mirror of the ``landlord_boiler_efficiency_band_overrides`` table.
The classifier cache for the SEDBUK Boiler Efficiency Band (ADR-0068): one
``(portfolio_id, description) -> AG`` row per distinct Landlord "Heating"
description that carries a band, written ``source=classifier`` as a reviewed
cache exactly like ``landlord_main_heating_system_overrides``.
The schema source of truth lives in the ``assessment-model`` TS repo
(`src/app/db/schema/landlord_overrides.ts`); the migrations are owned there (the
``boiler_efficiency_band`` pgEnum is FE-owned, cf.
[[main-heating-system-pgenum-is-fe-owned]]). This class only mirrors the columns
so the Python lambda can read/write once that migration lands (deferred / the
Class-A/B pattern no deploy until the FE type exists). Shape mirrors
``LandlordMainHeatingSystemOverrideRow``.
"""
from datetime import datetime, timezone
from typing import ClassVar
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, Column, UniqueConstraint
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, SQLModel
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from infrastructure.postgres.landlord_override_enums import override_source_sa_enum
class LandlordBoilerEfficiencyBandOverrideRow(SQLModel, table=True):
__tablename__: ClassVar[str] = "landlord_boiler_efficiency_band_overrides" # pyright: ignore[reportIncompatibleVariableOverride]
__table_args__: ClassVar[tuple[UniqueConstraint, ...]] = ( # pyright: ignore[reportIncompatibleVariableOverride]
# Shortened to stay within PostgreSQL's 63-char identifier limit; mirrors
# the Drizzle name.
UniqueConstraint(
"portfolio_id",
"description",
name="landlord_boiler_efficiency_band_portfolio_description_unique",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
portfolio_id: int = Field(
sa_column=Column(BigInteger, nullable=False, index=True),
)
description: str = Field(nullable=False)
value: BoilerEfficiencyBand = Field(
sa_column=Column(
SAEnum(
BoilerEfficiencyBand,
name="boiler_efficiency_band",
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
),
nullable=False,
),
)
source: str = Field(
sa_column=Column(override_source_sa_enum, nullable=False),
)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
nullable=False,
)

View file

@ -32,6 +32,10 @@ override_component_sa_enum = SAEnum(
"construction_age_band",
"water_heating",
"main_heating_system",
# The SEDBUK Boiler Efficiency Band (ADR-0068) — a boiler *attribute* riding
# alongside main_heating_system, its value one of AG. FE-owned pgEnum value
# (deferred / Class-A/B: no deploy until the Drizzle migration adds it).
"boiler_efficiency_band",
name="override_component",
)

View file

@ -14,6 +14,7 @@ from typing import Any, Optional
from uuid import UUID
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.epc.property_overrides.built_form_type import BuiltFormType
from domain.epc.property_overrides.property_type import PropertyType
from domain.epc.property_overrides.roof_type import RoofType
@ -61,6 +62,17 @@ UNKNOWN_VALUES = frozenset(
}
)
# Override components whose unresolved/UNKNOWN value is recorded EXPLICITLY as
# Unknown rather than failing the finalise the way a mandatory component does.
# The landlord declared the column, so we always write a row — an undeterminable
# Boiler Efficiency Band (ADR-0068; a non-boiler heating cell, or a plain boiler
# with no SEDBUK letter) is stored as Unknown, keeping "assessed, no band"
# distinct from "never assessed". The modelling reads Unknown as no band (no
# efficiency anchor), so it is inert to SAP; it is not persisted downstream of the
# override read.
_UNKNOWN_MAPPED_COMPONENTS = frozenset({"boiler_efficiency_band"})
_BOILER_EFFICIENCY_BAND_UNKNOWN = BoilerEfficiencyBand.UNKNOWN.value
def _split_entries(cell: Any) -> list[str]:
"""Split a multi-valued cell into per-building-part entries — mirrors the
@ -310,12 +322,23 @@ class BulkUploadFinaliserOrchestrator:
raw = entries[file_pos]
value = component_vocab.get(raw.lower())
if value is None or value in UNKNOWN_VALUES:
raise ValueError(
f"Unresolved {component} description {raw!r} "
f"(row {row_id}, portfolio {portfolio_id}): no resolved "
f"value{' (UNKNOWN)' if value else ''}. The verify gate "
f"should have mapped it; failing the finalise (ADR-0006)."
)
if component in _UNKNOWN_MAPPED_COMPONENTS:
# Record an EXPLICIT Unknown (ADR-0068; kimjunte): the
# landlord declared the column, so we always write a
# row — an undeterminable band (non-boiler cell, or a
# plain boiler with no SEDBUK letter) is Unknown, not
# skipped and not a hard failure. Modelling reads it as
# no band. Distinguishes "assessed, no band" from
# "never assessed".
value = _BOILER_EFFICIENCY_BAND_UNKNOWN
else:
raise ValueError(
f"Unresolved {component} description {raw!r} "
f"(row {row_id}, portfolio {portfolio_id}): no "
f"resolved value{' (UNKNOWN)' if value else ''}. The "
f"verify gate should have mapped it; failing the "
f"finalise (ADR-0006)."
)
inserts.append(
PropertyOverrideInsert(
property_id=property_id,

View file

@ -44,13 +44,20 @@ from domain.epc.property_overlays.main_heating_system_overlay import (
main_heating_overlay_for,
natural_fuel_for,
)
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.epc.property_overrides.boiler_efficiency_band_guard import (
boiler_efficiency_band_guard,
)
from domain.epc.property_overlays.water_heating_overlay import (
water_heating_overlay_for,
)
from domain.epc.property_overlays.roof_type_overlay import roof_overlay_for
from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for
from domain.modelling.simulation import EpcSimulation
from repositories.property.property_overrides_reader import ResolvedPropertyOverrides
from repositories.property.property_overrides_reader import (
ResolvedPropertyOverride,
ResolvedPropertyOverrides,
)
logger = logging.getLogger(__name__)
@ -71,6 +78,15 @@ _COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] =
}
# Components that are classified + stored but have **no standalone overlay** — they
# are *attributes* consumed by another component's overlay, so they are absent from
# `_COMPONENT_OVERLAYS` on purpose. The Boiler Efficiency Band rides the
# main_heating_system overlay (ADR-0068), read at its call site (see
# `_boiler_efficiency_band_for`). The override-component consistency guard treats
# these as the reader/pgEnum-minus-overlays difference.
_ATTRIBUTE_COMPONENTS: frozenset[str] = frozenset({"boiler_efficiency_band"})
# Components whose overlay must be applied LAST so an explicit value wins a
# default another overlay dragged. `apply_simulations` is last-wins and override
# rows arrive in arbitrary order, so a `main_fuel` override must be applied after
@ -87,23 +103,70 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]:
overrides.rows, key=lambda row: row.override_component in _APPLY_LAST
)
for row in ordered_rows:
mapper = _COMPONENT_OVERLAYS.get(row.override_component)
if mapper is None:
continue
value = row.override_value
if row.override_component == "main_heating_system":
# A fuel-agnostic gas-boiler archetype re-points to the oil / solid-fuel
# member matching this property's `main_fuel`, so an oil/solid boiler
# scores its own code — not the gas default the LLM assigned (ADR-0067).
value = resolve_boiler_archetype(
value, _override_value(overrides, "main_fuel")
)
overlay = mapper(value, row.building_part)
# The main-heating overlay carries two joins the generic mapper can't:
# the fuel re-point (ADR-0067) and the Boiler Efficiency Band slot
# (ADR-0068), so it is built directly rather than via _COMPONENT_OVERLAYS.
overlay = _main_heating_overlay(overrides, row)
else:
mapper = _COMPONENT_OVERLAYS.get(row.override_component)
if mapper is None:
# `boiler_efficiency_band` rows carry no standalone overlay — they
# are consumed by the main_heating_system row above — so they, and
# any other unmapped component, produce nothing here.
continue
overlay = mapper(row.override_value, row.building_part)
if overlay is not None:
overlays.append(overlay)
return overlays
def _main_heating_overlay(
overrides: ResolvedPropertyOverrides, row: ResolvedPropertyOverride
) -> Optional[EpcSimulation]:
"""The main-heating overlay for one row, with its two resolution-time joins:
the fuel re-point (a fuel-agnostic gas-boiler archetype the oil/solid-fuel
member matching the property's `main_fuel`, ADR-0067) and the SEDBUK Boiler
Efficiency Band `(winter, summer)` efficiency slot (ADR-0068)."""
value = resolve_boiler_archetype(
row.override_value, _override_value(overrides, "main_fuel")
)
band = _boiler_efficiency_band_for(overrides, row)
return main_heating_overlay_for(value, row.building_part, band)
def _boiler_efficiency_band_for(
overrides: ResolvedPropertyOverrides, row: ResolvedPropertyOverride
) -> Optional[BoilerEfficiencyBand]:
"""The SEDBUK band for a main-heating row: an explicit `boiler_efficiency_band`
override row for the same building part (the FE classifier's structured output)
wins; absent one, the band is parsed on-read from the row's
`original_spreadsheet_description` (`Boiler: C rated Combi`), so the band ships
before the FE-owned band override row exists. `None` when no band is present."""
for other in overrides.rows:
if (
other.override_component == "boiler_efficiency_band"
and other.building_part == row.building_part
):
band = _band_from_override_value(other.override_value)
if band is not None:
return band
if row.original_spreadsheet_description is not None:
return boiler_efficiency_band_guard(row.original_spreadsheet_description)
return None
def _band_from_override_value(value: str) -> Optional[BoilerEfficiencyBand]:
"""A stored `boiler_efficiency_band` override value → the enum member, or
`None` for an unrecognised value or the `UNKNOWN` sentinel (never persisted,
but treated as absent if it ever appears)."""
try:
band = BoilerEfficiencyBand(value)
except ValueError:
return None
return None if band is BoilerEfficiencyBand.UNKNOWN else band
# Coarse fuel family per RdSAP `main_fuel` code (main_fuel_overlay._FUEL_CODES),
# for the plausibility check. The natural fuel a solid-fuel archetype drags
# (house coal) is a *default* across the ambiguous solid family, so a same-family

View file

@ -40,6 +40,7 @@ def _resolve_overrides(session: Session, property_id: int) -> ResolvedPropertyOv
override_component=row.override_component,
building_part=row.building_part,
override_value=row.override_value,
original_spreadsheet_description=row.original_spreadsheet_description,
)
for row in rows
)
@ -65,6 +66,7 @@ def _resolve_overrides_many(
override_component=row.override_component,
building_part=row.building_part,
override_value=row.override_value,
original_spreadsheet_description=row.original_spreadsheet_description,
)
)
return {

View file

@ -24,6 +24,11 @@ class ResolvedPropertyOverride:
override_component: str
building_part: int
override_value: str
# The raw landlord cell the value resolved from. Carried so the modelling can
# parse the SEDBUK Boiler Efficiency Band (`Boiler: C rated Combi`) on-read
# (ADR-0068) — the band ships before the FE-owned boiler_efficiency_band
# override row exists. Optional so in-memory/test snapshots may omit it.
original_spreadsheet_description: Optional[str] = None
@dataclass(frozen=True)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,195 @@
"""Backfill the ``boiler_efficiency_band`` override for already-ingested boilers.
The SEDBUK band (ADR-0068) is losslessly present in every ``main_heating_system``
override's ``original_spreadsheet_description`` (``Boiler: C rated Combi``) but was
dropped at classification. This one-off populates the new ``boiler_efficiency_band``
override rows for the existing stock by re-parsing that text with the SAME guard the
live classifier uses (``boiler_efficiency_band_guard``), so the backfill and the
forward path cannot drift.
One ``boiler_efficiency_band`` row per ``main_heating_system`` row, keyed to the
same ``(property_id, building_part)`` the parseable band (``A````G``) where the
description carries one, else an explicit ``Unknown``. This mirrors the finaliser
(which records Unknown rather than skipping), so backfilled historical data matches
go-forward data. The modelling gate (gas/oil boilers only, ADR-0068) is applied at
overlay time, not here so a band parsed off an electric ``Boiler: A rated NA`` is
stored for fidelity but ignored by the calculator, and ``Unknown`` (incl. on a
non-boiler system) is inert to the calculator, exactly as the live path stores them.
GATED: the ``boiler_efficiency_band`` value of the FE-owned ``override_component``
pgEnum must exist first (the Drizzle migration in the assessment-model repo). Until
then this writes nothing in prod the Class-A/B deferred pattern.
DRY-RUN BY DEFAULT: prints the counts it would write and writes nothing. Pass
``--apply`` to execute inside a transaction. Idempotent a row already carrying the
target band is left untouched, so re-running is a no-op.
"""
from __future__ import annotations
import argparse
from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import Connection, text
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.epc.property_overrides.boiler_efficiency_band_guard import (
boiler_efficiency_band_guard,
)
from scripts.e2e_common import build_engine, load_env
_OVERRIDE_COMPONENT = "boiler_efficiency_band"
@dataclass(frozen=True)
class MainHeatingRow:
"""The fields of a ``main_heating_system`` ``property_overrides`` row the
backfill reads."""
property_id: int
portfolio_id: int
building_part: int
original_spreadsheet_description: str
@dataclass(frozen=True)
class BandBackfillRow:
"""A ``boiler_efficiency_band`` override row to upsert for a boiler."""
property_id: int
portfolio_id: int
building_part: int
override_value: str
original_spreadsheet_description: str
def band_backfill_rows(rows: Iterable[MainHeatingRow]) -> list[BandBackfillRow]:
"""The ``boiler_efficiency_band`` row to write for **every**
``main_heating_system`` row, keyed to the same property + building part the
parseable SEDBUK band (``A````G``) where the description carries one, else an
explicit ``Unknown``. This mirrors the finaliser (which records Unknown rather
than skipping), so backfilled historical data matches go-forward data;
``Unknown`` is inert to modelling (no efficiency anchor) and fine on a
non-boiler system.
Pure and DB-free, so the parse/decide logic is unit-tested without a
database; the connection wrapper below just feeds it rows and upserts."""
backfill: list[BandBackfillRow] = []
for row in rows:
band = boiler_efficiency_band_guard(row.original_spreadsheet_description)
value = (
band.value
if band is not None and band is not BoilerEfficiencyBand.UNKNOWN
else BoilerEfficiencyBand.UNKNOWN.value
)
backfill.append(
BandBackfillRow(
property_id=row.property_id,
portfolio_id=row.portfolio_id,
building_part=row.building_part,
override_value=value,
original_spreadsheet_description=row.original_spreadsheet_description,
)
)
return backfill
_SELECT_MAIN_HEATING = text(
"""
SELECT property_id, portfolio_id, building_part, original_spreadsheet_description
FROM property_overrides
WHERE override_component = 'main_heating_system'
"""
)
# Idempotent upsert on the (property, component, part) unique constraint — a row
# already carrying the target band is left untouched (updated_at unchanged).
_UPSERT_BAND = text(
"""
INSERT INTO property_overrides
(id, property_id, portfolio_id, building_part, override_component,
override_value, original_spreadsheet_description, created_at, updated_at)
VALUES
(gen_random_uuid(), :property_id, :portfolio_id, :building_part,
'boiler_efficiency_band', :override_value, :original_spreadsheet_description,
now(), now())
ON CONFLICT (property_id, override_component, building_part)
DO UPDATE SET override_value = EXCLUDED.override_value,
original_spreadsheet_description =
EXCLUDED.original_spreadsheet_description,
updated_at = now()
WHERE property_overrides.override_value <> EXCLUDED.override_value
"""
)
def backfill(conn: Connection, *, apply: bool) -> int:
"""Backfill the ``boiler_efficiency_band`` rows off the existing
``main_heating_system`` descriptions.
In ``--apply`` returns the number of rows **actually written** the upsert's
``WHERE override_value <> EXCLUDED.override_value`` no-ops rows already at the
target band, so an idempotent re-run reports 0, not the candidate count. In
dry-run returns the number of **candidate** rows (it cannot know how many
differ without writing)."""
rows = [
MainHeatingRow(
property_id=r.property_id,
portfolio_id=r.portfolio_id,
building_part=r.building_part,
original_spreadsheet_description=r.original_spreadsheet_description or "",
)
for r in conn.execute(_SELECT_MAIN_HEATING)
]
to_write = band_backfill_rows(rows)
if not apply:
return len(to_write)
written = 0
for band_row in to_write:
result = conn.execute(
_UPSERT_BAND,
{
"property_id": band_row.property_id,
"portfolio_id": band_row.portfolio_id,
"building_part": band_row.building_part,
"override_value": band_row.override_value,
"original_spreadsheet_description": (
band_row.original_spreadsheet_description
),
},
)
written += result.rowcount or 0
return written
def main() -> None:
load_env()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--apply",
action="store_true",
help="execute the writes (default: dry-run, writes nothing)",
)
args = parser.parse_args()
engine = build_engine()
with engine.begin() as conn:
conn.execute(text("SET statement_timeout = 120000"))
total = backfill(conn, apply=args.apply)
noun = "row(s) written" if args.apply else "candidate row(s)"
print(
f"{total} boiler_efficiency_band {noun} from the existing "
"main_heating_system descriptions (Boiler: <A-G> rated ...). The modelling "
"gate (gas/oil boilers only) is applied at overlay time (ADR-0068)."
)
if args.apply:
print(
"(Rows already at their target band are no-ops, so a re-run reports 0.)"
)
else:
print("\nDRY-RUN — nothing written. Re-run with --apply to execute.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import Optional
import pytest
from domain.data_transformation.guarded_column_classifier import (
GuardedColumnClassifier,
)
from domain.data_transformation.logging_unknown_column_classifier import (
LoggingUnknownColumnClassifier,
)
class _Band(Enum):
A = "A"
D = "D"
UNKNOWN = "Unknown"
def test_maps_every_description_to_unknown() -> None:
classifier = LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band")
assert classifier.classify({"x", "y"}) == {"x": _Band.UNKNOWN, "y": _Band.UNKNOWN}
def test_logs_a_warning_naming_the_column_and_a_sample(
caplog: pytest.LogCaptureFixture,
) -> None:
classifier = LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band")
with caplog.at_level(logging.WARNING):
classifier.classify({"Community heating"})
assert "boiler_efficiency_band" in caplog.text
assert "Community heating" in caplog.text
def test_empty_input_neither_logs_nor_returns_rows(
caplog: pytest.LogCaptureFixture,
) -> None:
classifier = LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band")
with caplog.at_level(logging.WARNING):
result = classifier.classify(set())
assert result == {}
assert caplog.records == []
def _band_guard(description: str) -> Optional[_Band]:
# A tiny stand-in guard: recognises "A rated" / "D rated", else None.
for member in (_Band.A, _Band.D):
if f"{member.value} rated" in description:
return member
return None
def test_guard_only_composition_never_reaches_an_llm(
caplog: pytest.LogCaptureFixture,
) -> None:
# The band column is guard-only: recognised bands resolve deterministically,
# and unrecognised descriptions become UNKNOWN (logged) — never an LLM guess
# that could fabricate a band (reviewer feedback).
classifier = GuardedColumnClassifier(
guard=_band_guard,
fallback=LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band"),
)
with caplog.at_level(logging.WARNING):
result = classifier.classify({"Boiler: D rated Combi", "Gas boiler"})
assert result == {"Boiler: D rated Combi": _Band.D, "Gas boiler": _Band.UNKNOWN}
# Only the miss is logged; the guarded hit is not.
assert "Gas boiler" in caplog.text
assert "D rated" not in caplog.text

View file

@ -0,0 +1,60 @@
from __future__ import annotations
import pytest
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.epc.property_overrides.boiler_efficiency_band_guard import (
boiler_efficiency_band_guard,
)
@pytest.mark.parametrize(
("description", "expected"),
[
# The standard Landlord "Heating" column phrasing (portfolio 796):
# `Boiler: <A-G> rated <Combi|Regular Boiler>`.
("Boiler: A rated Combi", BoilerEfficiencyBand.A),
("Boiler: B rated Combi", BoilerEfficiencyBand.B),
("Boiler: C rated Regular Boiler", BoilerEfficiencyBand.C),
("Boiler: D rated Regular Boiler", BoilerEfficiencyBand.D),
("Boiler: E rated Combi", BoilerEfficiencyBand.E),
("Boiler: F rated Regular Boiler", BoilerEfficiencyBand.F),
("Boiler: G rated Regular Boiler", BoilerEfficiencyBand.G),
# Band present even when the boiler *type* is "NA" (an electric boiler
# the fuel resolution reclassifies — the band letter is still parseable).
("Boiler: A rated NA", BoilerEfficiencyBand.A),
# Case-insensitive (the backfill lowercases the stored description).
("boiler: c rated combi", BoilerEfficiencyBand.C),
],
)
def test_guard_parses_the_sedbuk_band(
description: str, expected: BoilerEfficiencyBand
) -> None:
assert boiler_efficiency_band_guard(description) is expected
def test_guard_takes_the_primary_system_1_band_on_a_multi_system_cell() -> None:
# `System 2:` marks the SECOND system; the primary (system 1) band leads the
# cell, so a dual-system boiler is scored on its own band, not system 2's.
description = "Boiler: A rated Combi, System 2: Boiler: C rated Combi"
assert boiler_efficiency_band_guard(description) is BoilerEfficiencyBand.A
@pytest.mark.parametrize(
"description",
[
# No band letter — a plain boiler, or a non-boiler heating system.
"Gas boiler",
"Community Heating Systems: Community boilers only (RdSAP)",
"Solid fuel room heaters: Open fire with back boiler (no radiators)",
"",
# A stray letter that isn't a SEDBUK band phrasing must not match.
"Boiler: H rated Combi",
"A rated",
],
)
def test_guard_returns_none_when_no_band_is_present(description: str) -> None:
# None (not UNKNOWN): absence of a band is modelled as no value, so the
# boiler keeps its Table 4b code default and no band override row is stored.
assert boiler_efficiency_band_guard(description) is None

View file

@ -0,0 +1,183 @@
from __future__ import annotations
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_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 CG, PCDB index for A/B) ---
@pytest.mark.parametrize(
("base_code", "band", "expected"),
[
# 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_band_resolves_to_the_cert_native_anchor(
base_code: int, band: BoilerEfficiencyBand, expected: tuple[int, Optional[int]]
) -> None:
assert band_efficiency_anchor(base_code, band) == expected
@pytest.mark.parametrize(
("base_code", "band"),
[
# 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_no_applicable_band_keeps_the_base_code_with_no_index(
base_code: int, band: Optional[BoilerEfficiencyBand]
) -> None:
assert band_efficiency_anchor(base_code, band) == (base_code, None)
@pytest.mark.parametrize(
("pcdb_id", "expected_winter_summer"),
[
(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
],
)
def test_each_ab_pcdb_index_still_resolves_to_its_accredited_efficiency(
pcdb_id: int, expected_winter_summer: tuple[float, float]
) -> 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", "code", "index"),
[
# CG → 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_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
assert simulation.heating.sap_main_heating_code == code
assert simulation.heating.main_heating_index_number == index
@pytest.mark.parametrize(
("archetype", "band"),
[
# No band → base code, no index (today's behaviour).
("Gas boiler, combi", None),
# 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),
("Solid fuel boiler", BoilerEfficiencyBand.G),
("Gas CPSU", BoilerEfficiencyBand.D),
],
)
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.main_heating_index_number is None
def test_overlay_defaults_to_no_band_when_not_supplied() -> None:
# Back-compat: the band arg is optional; existing callers pass none.
simulation = main_heating_overlay_for("Gas boiler, combi", 0)
assert simulation is not None
assert simulation.heating is not 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; CG 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

View file

@ -232,6 +232,78 @@ def test_finalise_writes_overrides_for_uprn_rows_splitting_by_part() -> None:
assert (prop_type.building_part, prop_type.override_value) == (0, "Semi-detached house")
def test_finalise_writes_the_boiler_efficiency_band_alongside_the_heating_system() -> None:
# The band rides the SAME "Heating" source column as main_heating_system
# (like Property Type feeds property_type + built_form), so a banded boiler
# cell yields two override rows: the archetype and its SEDBUK band (ADR-0068).
combiner = [{"address2uprn_uprn": "100023", "source_row_id": "row-a"}]
classifier = [{"Heating": "Boiler: D rated Regular Boiler", "source_row_id": "row-a"}]
vocab = {
"main_heating_system": {"boiler: d rated regular boiler": "Gas boiler, regular"},
"boiler_efficiency_band": {"boiler: d rated regular boiler": "D"},
}
orchestrator, overrides = _overrides_orchestrator({100023: 555}, vocab)
orchestrator.finalise(
combiner,
portfolio_id=7,
task_id=uuid4(),
classifier_rows=classifier,
multi_entry_ordering={},
column_mapping={
"main_heating_system": "Heating",
"boiler_efficiency_band": "Heating",
},
)
(band,) = [
o for o in overrides.upserted if o.override_component == "boiler_efficiency_band"
]
assert (band.building_part, band.override_value) == (0, "D")
def test_finalise_records_unknown_when_the_band_cannot_be_mapped() -> None:
# A description with no determinable band (a non-boiler heating system, or a
# plain boiler) is recorded as an EXPLICIT Unknown band, not skipped: if the
# landlord declared the band column we always write a row, so "assessed, no
# band" is distinguishable from "never assessed" (ADR-0068; kimjunte). Unlike
# the mandatory components it does NOT fail the finalise.
combiner = [{"address2uprn_uprn": "100023", "source_row_id": "row-a"}]
classifier = [
{"Heating": "Community Heating Systems: Community boilers only (RdSAP)",
"source_row_id": "row-a"}
]
vocab = {
"main_heating_system": {
"community heating systems: community boilers only (rdsap)":
"Community heating, boilers"
},
"boiler_efficiency_band": {
"community heating systems: community boilers only (rdsap)": "Unknown"
},
}
orchestrator, overrides = _overrides_orchestrator({100023: 555}, vocab)
orchestrator.finalise(
combiner,
portfolio_id=7,
task_id=uuid4(),
classifier_rows=classifier,
multi_entry_ordering={},
column_mapping={
"main_heating_system": "Heating",
"boiler_efficiency_band": "Heating",
},
)
# The heating row is written, AND an explicit Unknown band row alongside it.
assert any(o.override_component == "main_heating_system" for o in overrides.upserted)
(band,) = [
o for o in overrides.upserted if o.override_component == "boiler_efficiency_band"
]
assert band.override_value == "Unknown"
def test_finalise_fails_loudly_on_unresolved_description() -> None:
combiner = [
{"address2uprn_uprn": "100023", "source_row_id": "row-a"},

View file

@ -227,6 +227,117 @@ 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_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.
overrides = ResolvedPropertyOverrides(
rows=(
ResolvedPropertyOverride(
"main_heating_system",
0,
"Gas boiler, regular",
original_spreadsheet_description="Boiler: G rated Regular Boiler",
),
)
)
overlays = overlays_from(overrides)
assert len(overlays) == 1
assert overlays[0].heating is not None
# 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:
# Once the FE classifier writes a structured boiler_efficiency_band row, it is
# the authority — it wins over the on-read parse of the raw description.
overrides = ResolvedPropertyOverrides(
rows=(
ResolvedPropertyOverride(
"main_heating_system",
0,
"Gas boiler, regular",
original_spreadsheet_description="Boiler: G rated Regular Boiler",
),
ResolvedPropertyOverride("boiler_efficiency_band", 0, "D"),
)
)
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 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:
# The band is applied AFTER fuel resolution (ADR-0067): a gas archetype on an
# oil main_fuel resolves to the oil boiler, and the band keys off the oil code.
overrides = ResolvedPropertyOverrides(
rows=(
ResolvedPropertyOverride(
"main_heating_system",
0,
"Gas boiler, regular",
original_spreadsheet_description="Boiler: D rated Regular Boiler",
),
ResolvedPropertyOverride("main_fuel", 0, "oil"),
)
)
overlays = overlays_from(overrides)
heating = next(o.heating for o in overlays if o.heating is not None)
# 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_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(
"main_heating_system",
0,
"Gas boiler, combi",
original_spreadsheet_description="Gas boiler",
),
)
)
overlays = overlays_from(overrides)
heating = next(o.heating for o in overlays if o.heating is not None)
assert heating.sap_main_heating_code == 104
assert heating.main_heating_index_number is None
def test_unresolvable_rows_are_skipped() -> None:
# Arrange — an "Unknown" property type and an unmapped wall material.
overrides = ResolvedPropertyOverrides(

View file

@ -1,12 +1,20 @@
"""Every override component must be wired through the WHOLE chain.
The finaliser reader (`_ROW_TYPES`, component -> landlord table) and the overlay
registry (`_COMPONENT_OVERLAYS`, component -> overlay mapper) must cover exactly
the same set of components. If a component is classified + stored but has no
reader entry, the finaliser silently never writes its `property_overrides` rows;
if it has no overlay entry, the row never reaches the calculator. This guard
keeps the two registries in lock-step (it would have caught the missing
main_fuel / glazing / construction_age_band reader entries).
The finaliser reader (`_ROW_TYPES`, component -> landlord table), the overlay
registry (`_COMPONENT_OVERLAYS`, component -> overlay mapper), and the
`override_component` pgEnum mirror must stay in lock-step. If a component is
classified + stored but has no reader entry, the finaliser silently never writes
its `property_overrides` rows; if it has no overlay entry, the row never reaches
the calculator; if it is missing from the pgEnum, writing/reading it throws a
LookupError against Postgres (caught live on the Hyde portfolio-796 run). This
guard would have caught the missing main_fuel / glazing / construction_age_band
reader entries.
The one exception is an **attribute component** (`_ATTRIBUTE_COMPONENTS`): it is
classified + stored + read (so it *is* in `_ROW_TYPES` and the pgEnum) but is
consumed by another component's overlay rather than having its own, so it is
deliberately absent from `_COMPONENT_OVERLAYS` the Boiler Efficiency Band rides
the main_heating_system overlay (ADR-0068).
"""
from __future__ import annotations
@ -18,18 +26,27 @@ from infrastructure.landlord_overrides.landlord_override_reader_postgres_reposit
)
from infrastructure.postgres.property_override_table import override_component_sa_enum
from repositories.property.landlord_override_overlays import (
_ATTRIBUTE_COMPONENTS, # pyright: ignore[reportPrivateUsage]
_COMPONENT_OVERLAYS, # pyright: ignore[reportPrivateUsage]
)
def test_reader_and_overlay_registries_cover_the_same_components() -> None:
# Assert
assert set(_ROW_TYPES) == set(_COMPONENT_OVERLAYS)
# Every classified/stored component (`_ROW_TYPES`) has a standalone overlay,
# except the attribute components consumed by another overlay.
assert set(_ROW_TYPES) - _ATTRIBUTE_COMPONENTS == set(_COMPONENT_OVERLAYS)
def test_attribute_components_are_read_and_stored_but_have_no_overlay() -> None:
# An attribute component must still be classified/read (in `_ROW_TYPES`) and
# stored (in the pgEnum), but must NOT appear in `_COMPONENT_OVERLAYS`.
assert _ATTRIBUTE_COMPONENTS <= set(_ROW_TYPES)
assert _ATTRIBUTE_COMPONENTS.isdisjoint(_COMPONENT_OVERLAYS)
def test_override_component_pgenum_covers_every_component() -> None:
# The property_overrides.override_component pgEnum mirror must list every
# component, or writing/reading a new-component row through it throws a
# LookupError against Postgres (caught live on the Hyde portfolio-796 run).
# classified/stored component (overlay-backed + attribute), or writing/reading
# a new-component row through it throws a LookupError against Postgres.
pgenum_values = cast(list[str], getattr(override_component_sa_enum, "enums"))
assert set(pgenum_values) == set(_COMPONENT_OVERLAYS)
assert set(pgenum_values) == set(_ROW_TYPES)

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from scripts.backfill_boiler_efficiency_band import (
BandBackfillRow,
MainHeatingRow,
band_backfill_rows,
)
def _row(desc: str, *, property_id: int = 1, building_part: int = 0) -> MainHeatingRow:
return MainHeatingRow(
property_id=property_id,
portfolio_id=796,
building_part=building_part,
original_spreadsheet_description=desc,
)
def test_backfills_a_band_row_per_boiler_with_a_parseable_band() -> None:
rows = [
_row("Boiler: D rated Regular Boiler", property_id=10),
_row("Boiler: G rated Combi", property_id=11, building_part=1),
]
result = band_backfill_rows(rows)
assert result == [
BandBackfillRow(10, 796, 0, "D", "Boiler: D rated Regular Boiler"),
BandBackfillRow(11, 796, 1, "G", "Boiler: G rated Combi"),
]
def test_records_unknown_for_descriptions_without_a_band() -> None:
# Aligned with the finaliser: every main_heating_system row gets a band row —
# a description with no determinable band (plain boiler, non-boiler system) is
# recorded as an explicit Unknown, so historical + go-forward data match.
# Unknown is fine for non-boilers (inert to modelling).
rows = [
_row("Gas boiler", property_id=1),
_row("Community Heating Systems: Community boilers only (RdSAP)", property_id=2),
]
result = band_backfill_rows(rows)
assert [(r.property_id, r.override_value) for r in result] == [
(1, "Unknown"),
(2, "Unknown"),
]
def test_uses_the_same_guard_as_the_live_path_for_multi_system_and_electric() -> None:
rows = [
# Multi-system: primary (system 1) band wins — matches the guard.
_row("Boiler: A rated Combi, System 2: Boiler: C rated Combi", property_id=20),
# Band letter on an electric "NA" boiler is stored for fidelity; the
# gas/oil modelling gate ignores it at overlay time (ADR-0068).
_row("Boiler: A rated NA", property_id=21),
]
result = band_backfill_rows(rows)
assert [(r.property_id, r.override_value) for r in result] == [(20, "A"), (21, "A")]