mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-02 21:08:24 +00:00
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:
commit
1a9ebacb59
30 changed files with 2266 additions and 47 deletions
|
|
@ -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 **A–G** 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 D–G 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 (A–G); 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 A–G *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)**:
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
|
|
|||
|
|
@ -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 86–90 % 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 ≈ 79–80 %).
|
||||
>
|
||||
> Candidate to confirm: **B-band (winter, summer) ≈ (88.0, 79.5)** — from the real lodged
|
||||
> product and the PCDB 86–90 % 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 — 1950–1966** | 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 |
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 — 1983–1990** | 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 (1st–4th) 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 |
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 / 74–75 % summer**.
|
||||
|
||||
The Landlord spreadsheet's **SEDBUK A–G 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 | 86–90 % | slightly under |
|
||||
| C | 6 251 | 82–86 % | ~right |
|
||||
| D | 1 691 | 78–82 % | over |
|
||||
| E | 1 196 | 74–78 % | over |
|
||||
| F | 206 | 70–74 % | 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 %, D–G 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 **101–119**; oil is the separate liquid-fuel range **124–132**. The
|
||||
overlay today defaults **both** to their condensing/modern variant — gas 102/104
|
||||
(84 %) *and* oil 127/130 (84 %/82 %) — so the D–G 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 (120–123) and range cookers (133–141) 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 86–90 %, **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) / 82–84 % (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 74–78 % E range
|
||||
for regular; nothing below ~66/71 % for G-combi), so oil D–G 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 D–G 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**
|
||||
(~+1–3 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).
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 A–G
|
||||
# band. There is NO synthesised efficiency field: C-and-below emit the band's
|
||||
# Table 4b `sap_main_heating_code`; A/B emit a representative PCDB
|
||||
# `main_heating_index_number`. Both are real cert fields the calculator already
|
||||
# resolves (Table 4b / PCDB Appendix D2.1), and the base code still drives the
|
||||
# companion set (combi vs regular hot water).
|
||||
#
|
||||
# C-and-below → 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 / 82–84 % oil ceiling, so there is no Table 4b
|
||||
# code for them — the cert-native anchor is a representative PCDB product
|
||||
# `main_heating_index_number`, the same mechanism a real cert (or a future
|
||||
# user-supplied product) uses. The calculator's existing PCDB path resolves
|
||||
# winter + summer + combi-loss from the record; `_fold_heating` clears the base
|
||||
# code when an index is present, so the effective cert reads exactly like a real
|
||||
# PCDB-lodged cert. Products are **type-matched** to the archetype (combi vs
|
||||
# regular) because the combi-loss cascade reads the record's keep-hot/DHW profile.
|
||||
#
|
||||
# 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);
|
||||
- **C–G** → the band's Table 4b code, no index (Table 4b path);
|
||||
- no band / no applicable mapping (non-banded boiler, `UNKNOWN`, or the oil
|
||||
A-combi gap) → the base code unchanged, no index.
|
||||
"""
|
||||
if band is not None:
|
||||
index = _BAND_TO_PCDB_INDEX.get((base_code, band))
|
||||
if index is not None:
|
||||
return base_code, index
|
||||
table_4b_code = _BAND_TO_TABLE_4B_CODE.get((base_code, band))
|
||||
if table_4b_code is not None:
|
||||
return table_4b_code, None
|
||||
return base_code, None
|
||||
|
||||
# Canonical system archetype → representative SAP `sap_main_heating_code`. Codes
|
||||
# map to the modern/condensing variant (A-G efficiency deferred): 102 regular
|
||||
# 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` (C–G) or a
|
||||
representative PCDB `main_heating_index_number` (A/B) — the fifth Heating
|
||||
Companion (ADR-0068). The base `code` still drives the combi/regular hot-water
|
||||
companion; `_fold_heating` clears the code when an index is emitted."""
|
||||
emitted_code, index = band_efficiency_anchor(code, band)
|
||||
return HeatingOverlay(
|
||||
sap_main_heating_code=code,
|
||||
sap_main_heating_code=emitted_code,
|
||||
main_heating_index_number=index,
|
||||
main_heating_category=_GAS_BOILER_CATEGORY,
|
||||
main_fuel_type=_MAINS_GAS_FUEL,
|
||||
gas_connection_available=True,
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
35
domain/epc/property_overrides/boiler_efficiency_band.py
Normal file
35
domain/epc/property_overrides/boiler_efficiency_band.py
Normal 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"
|
||||
|
|
@ -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())
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) -> A–G`` 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,
|
||||
)
|
||||
|
|
@ -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 A–G. FE-owned pgEnum value
|
||||
# (deferred / Class-A/B: no deploy until the Drizzle migration adds it).
|
||||
"boiler_efficiency_band",
|
||||
name="override_component",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
sap worksheets/A rated boiler/Summary_000567.pdf
Normal file
BIN
sap worksheets/A rated boiler/Summary_000567.pdf
Normal file
Binary file not shown.
BIN
sap worksheets/A rated boiler/U985-0001-000567 (1).pdf
Normal file
BIN
sap worksheets/A rated boiler/U985-0001-000567 (1).pdf
Normal file
Binary file not shown.
BIN
sap worksheets/B rated boiler/Summary_000567.pdf
Normal file
BIN
sap worksheets/B rated boiler/Summary_000567.pdf
Normal file
Binary file not shown.
BIN
sap worksheets/B rated boiler/U985-0001-000567 (1).pdf
Normal file
BIN
sap worksheets/B rated boiler/U985-0001-000567 (1).pdf
Normal file
Binary file not shown.
195
scripts/backfill_boiler_efficiency_band.py
Normal file
195
scripts/backfill_boiler_efficiency_band.py
Normal 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()
|
||||
|
|
@ -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
|
||||
60
tests/domain/epc/test_boiler_efficiency_band_guard.py
Normal file
60
tests/domain/epc/test_boiler_efficiency_band_guard.py
Normal 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
|
||||
183
tests/domain/epc/test_boiler_efficiency_band_overlay.py
Normal file
183
tests/domain/epc/test_boiler_efficiency_band_overlay.py
Normal 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 C–G, 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"),
|
||||
[
|
||||
# C–G → emit the band Table 4b code, no index.
|
||||
("Gas boiler, regular", BoilerEfficiencyBand.D, 106, None),
|
||||
("Gas boiler, combi", BoilerEfficiencyBand.G, 118, None),
|
||||
("Oil boiler, regular", BoilerEfficiencyBand.E, 125, None),
|
||||
("Oil boiler, combi", BoilerEfficiencyBand.D, 129, None),
|
||||
# A/B → keep the base code + set the representative PCDB index.
|
||||
("Gas boiler, regular", BoilerEfficiencyBand.A, 102, 18790),
|
||||
("Gas boiler, combi", BoilerEfficiencyBand.A, 104, 18964),
|
||||
("Gas boiler, combi", BoilerEfficiencyBand.B, 104, 15029),
|
||||
("Oil boiler, regular", BoilerEfficiencyBand.A, 127, 17292),
|
||||
("Oil boiler, combi", BoilerEfficiencyBand.B, 130, 15959),
|
||||
],
|
||||
)
|
||||
def test_overlay_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; C–G keep the code ---
|
||||
|
||||
|
||||
def _apply_gas_combi(band: Optional[BoilerEfficiencyBand]) -> EpcPropertyData:
|
||||
overlay = main_heating_overlay_for("Gas boiler, combi", 0, band)
|
||||
assert overlay is not None
|
||||
return apply_simulations(build_epc(), [overlay])
|
||||
|
||||
|
||||
def test_ab_effective_cert_is_a_pcdb_cert_with_the_code_cleared() -> None:
|
||||
# `_fold_heating`'s index/code mutual-exclusion: an A/B override yields exactly
|
||||
# a PCDB-lodged cert — index set, code cleared — which the calculator's PCDB
|
||||
# Appendix D2.1 path resolves (winter+summer+combi-loss).
|
||||
eff = _apply_gas_combi(BoilerEfficiencyBand.A)
|
||||
main = eff.sap_heating.main_heating_details[0]
|
||||
assert main.main_heating_index_number == 18964
|
||||
assert main.sap_main_heating_code is None
|
||||
|
||||
|
||||
def test_c_to_g_effective_cert_keeps_the_band_table_4b_code() -> None:
|
||||
eff = _apply_gas_combi(BoilerEfficiencyBand.G)
|
||||
main = eff.sap_heating.main_heating_details[0]
|
||||
assert main.sap_main_heating_code == 118 # gas combi G
|
||||
assert main.main_heating_index_number is None
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
62
tests/scripts/test_backfill_boiler_efficiency_band.py
Normal file
62
tests/scripts/test_backfill_boiler_efficiency_band.py
Normal 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")]
|
||||
Loading…
Add table
Reference in a new issue