From ae7e6a0c422920447b012157b483565c049521ec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 10 Jun 2026 19:41:06 +0000 Subject: [PATCH 1/8] feat(modelling): composite per-dwelling boiler + tune-up costing (ADR-0027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat placeholder scalars (boiler £3000; tune-up £500/£900) with a per-dwelling composite cost, mirroring the ASHP architecture (ADR-0025): a `HeatingRates` table (data, `heating_rates.json`), typed `BoilerCostInputs` / `TuneUpCostInputs`, pure `Products.boiler_bundle_cost` / `tune_up_cost`, and modelling-layer interpreters that read the dwelling into those inputs. The cost mirrors the Simulation Overlay component-for-component, sharing the controls + cylinder pricing across both options: - tune-up (standard) = standard controls + cylinder fixes - tune-up (zone) = zone controls + cylinder fixes - boiler upgrade = £3200 all-in + standard controls (only when the upgrade fired a controls change) + cylinder fixes Standard controls are priced INCREMENTALLY — only the parts missing to reach SAP 2106 (programmer £120 / room thermostat £150 / TRV £35×radiators), read from a Table 4e Group-1 feature map so a dwelling that already has a room thermostat + TRVs is only charged the programmer. Zone controls are a full smart kit (hub £205 + smart TRV £50×radiators) — the smart TRV is itself the room sensor, so there is no separate per-room sensor line. Cylinder fixes: jacket £50 (when under-insulated) + thermostat £150 (when absent). The boiler is a like-for-like wet swap (no radiators/flue/pipework — eligibility already requires an existing wet boiler), so those dead-code extras are not modelled. Figures are research-validated 2025/26 UK installed costs (legacy Costs.py lineage); fully-loaded totals with one contingency on top (Model B, not the legacy VAT/preliminaries engine). Contingency: boiler 0.26; tune-ups 0.10 (was a 0.15 placeholder). ADR-0027 records the design; CONTEXT.md's Heating Eligibility entry updated to cover the partial boiler/tune-up family + composed cost. Products cost pins (delta<=1e-9) + interpreter tests + generator composite-cost assertions. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 4 +- docs/adr/0027-boiler-and-tune-up-costing.md | 56 ++++++ domain/modelling/contingencies.py | 4 +- .../generators/heating_recommendation.py | 111 +++++++++++- domain/modelling/heating_rates.json | 10 ++ domain/modelling/products.py | 165 ++++++++++++++++++ .../modelling/test_heating_cost_inputs.py | 97 ++++++++++ .../modelling/test_heating_recommendation.py | 41 +++++ tests/domain/modelling/test_products.py | 119 +++++++++++++ 9 files changed, 595 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0027-boiler-and-tune-up-costing.md create mode 100644 domain/modelling/heating_rates.json create mode 100644 tests/domain/modelling/test_heating_cost_inputs.py diff --git a/CONTEXT.md b/CONTEXT.md index d16f7336..029640b0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -271,8 +271,8 @@ The rule fixing the single lighting Measure the **Lighting** Recommendation offe _Avoid_: "low energy lighting" as the upgrade target (we go to **LED**); treating it as a forced dependency (it is a free candidate); pricing by floor area (it's per-bulb count × average) **Heating Eligibility**: -The rule fixing which whole-system **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024). The competing bundles — `high_heat_retention_storage_heaters`, `air_source_heat_pump`, and `boiler_upgrade` (deferred) — are **mutually-exclusive system replacements**; the Optimiser picks at most one. Each bundle is a **whole system change at once** — main heating + **controls + fuel + meter + the implied hot water** folded in, never a separate or complementary HW measure (the legacy heating-vs-HW split double-counted). Each is a **fixed, real, contractor-installable end-state** (a representative product Domna installs — ASHP via a fixed PCDB heat-pump index, HHR storage via `sap_main_heating_code=409`), not a derived ideal; **Product** stays cost-only. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR. Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**. -_Avoid_: separate "heating" and "hot water" recommendations (HW folds into the bundle); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); "heating controls" as a standalone competing measure (folded into the bundle) +The rule fixing which **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024, expanded). The competing Options are **mutually-exclusive** (the Optimiser picks at most one) and fall in two families: **whole-system replacements** — `high_heat_retention_storage_heaters`, `air_source_heat_pump` — which change main heating + **controls + fuel + meter + the implied hot water** at once (never a separate HW measure; the legacy heating-vs-HW split double-counted); and, for a dwelling keeping a serviceable wet boiler, **partial upgrades** — `gas_boiler_upgrade` (a like-for-like condensing **gas** boiler: gas→gas, or non-gas→gas only where mains gas is present; combi or regular-plus-cylinder, shaped by the dwelling) and the **system tune-up** (keep the boiler; install better **controls** + fix the **cylinder**), the tune-up offered at two competing control levels: `system_tune_up` (standard, SAP code 2106) and `system_tune_up_zoned` (time-and-temperature zone control, 2110 — more SAP uplift, more cost). Each Option is a **fixed, real, contractor-installable end-state** (ASHP via a fixed PCDB heat-pump index; HHR storage via `sap_main_heating_code=409`; the gas boiler via Table 4b code 102/104; controls via 2106/2110), not a derived ideal; **Product** stays cost-only, but a partial/bundle cost is **composed per dwelling** from the components the overlay installs (ADR-0025/0027), not a flat scalar. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR; **boiler upgrade / tune-up** → an existing (non-electric) wet boiler, the gas end-state gated on a mains-gas connection, a partial control upgrade offered only when it genuinely improves the existing control (never a downgrade or no-op). Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**. +_Avoid_: separate "heating" and "hot water" recommendations (HW folds into each Option); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); treating the whole-system replacements and the partial boiler/tune-up upgrades as **separate** Recommendations (they are mutually-exclusive Options within the one heating rec — separate recs would let the Optimiser co-select and double-charge); a standalone hot-water-only or controls-only Recommendation (controls + cylinder fold into the boiler/tune-up Option) ### Valuation diff --git a/docs/adr/0027-boiler-and-tune-up-costing.md b/docs/adr/0027-boiler-and-tune-up-costing.md new file mode 100644 index 00000000..21fae1a4 --- /dev/null +++ b/docs/adr/0027-boiler-and-tune-up-costing.md @@ -0,0 +1,56 @@ +# Boiler-Upgrade & System-Tune-Up Costing — Composite, Component-Mirrored + +The boiler-upgrade and system-tune-up Options (the Heating & Hot Water expansion to ADR-0024) currently carry flat placeholder catalogue scalars (boiler £3000; tune-up £500/£900). Like the ASHP bundle (ADR-0025), their real cost varies per dwelling — a tune-up that only fits TRVs on a dwelling that already has a programmer + room thermostat costs a fraction of a full controls-from-scratch job, and the Optimiser picks least-cost-to-band, so a flat number mis-ranks them against each other and against ASHP/HHRSH. We therefore **compose each cost per dwelling**, mirroring ADR-0025's architecture and reusing the (research-validated) legacy `recommendations/Costs.py` figures as the rate source. + +## Decision + +**The cost mirrors the Simulation Overlay component-for-component.** The overlay is the source of truth for what is installed; the cost prices exactly those components, using the *same conditional predicates* the overlay uses to decide what to write. This guarantees cost↔overlay consistency — we never charge for a thermostat the overlay didn't add, nor omit controls the boiler did upgrade — and keeps "one Option = one composite Plan line" honest. + +``` +tune-up (standard) = standard_controls + cylinder_fixes +tune-up (zone) = zone_controls + cylinder_fixes +boiler upgrade = boiler(£3200 all-in) + + standard_controls (only when the boiler fired a controls upgrade) + + cylinder_fixes (only when a cylinder is present) +``` + +`standard_controls` and `cylinder_fixes` are **shared** between boiler and tune-up. + +- **standard_controls = incremental** — price only the parts missing to reach SAP code 2106, read from a Table 4e Group-1 → `(has_programmer, has_room_thermostat, has_TRVs)` feature map: `programmer £120` + `room_thermostat £150` + `TRV £35 × radiators`, each charged only when absent. (A 7-radiator dwelling from "no controls" = £515, matching the Energy Saving Trust's ~£550 quoted figure; the £35 TRV is the *marginal* in-bundle rate, since the drain-down labour is shared once.) +- **zone_controls = full smart-zone kit, not incremental** — `smart_thermostat hub £205` + `smart_TRV £50 × radiators`. A smart zone system replaces whatever's there, and **the smart TRV is itself the per-room sensor** — so there is **no** separate per-room temperature-sensor line (the legacy double-counted it; corrected after research). +- **cylinder_fixes** — `jacket £50` (when under-insulated) + `cylinder_thermostat £150` (when absent), each conditional, and only when a cylinder exists. +- **boiler = £3200 all-in** (condensing gas boiler, flue + labour included). **No system-change extras** (radiators / separate flue / pipework): boiler-upgrade eligibility already requires an *existing wet boiler* (SAP code 101-141 / 151-161, electric 191-196 excluded, mains gas present), so every upgrade is a like-for-like swap that reuses the existing wet distribution — the dry→wet conversion lines can never fire under the gate, so they are not modelled. + +**`Products` owns the catalogue math; the modelling layer owns the dwelling interpretation** (ADR-0025 unchanged). New `Products.boiler_bundle_cost(BoilerCostInputs)` and `tune_up_cost(TuneUpCostInputs)` sum the applicable lines from a `HeatingRates` table (data, loaded from `heating_rates.json`) into a `Cost`, staying free of `EpcPropertyData` / `Sap10Calculator`. The modelling-layer interpreters read the dwelling (radiator count via the existing `_radiator_count` proxy; existing control features from the SAP control code; cylinder insulation/thermostat state) into those typed inputs. Per-radiator items (TRVs, smart TRVs) scale on `_radiator_count`; everything else is fixed per dwelling. + +**Fully-loaded totals, separate contingency** (Model B — the ADR-0025 shape, *not* the legacy VAT/preliminaries engine). The legacy per-item £ figures are reused as fully-loaded rates and summed; one `contingency_rate` is applied on top (boiler 0.26; both tune-ups 0.10, per legacy `Costs.CONTINGENCIES`). The legacy's separate VAT-on-labour / preliminaries arithmetic is *not* reproduced — the cost exists for Optimiser *ranking*, where those scale near-uniformly and don't change the order, and the per-item figures are themselves estimates, so sub-£100 tax precision is false fidelity. + +**Rate table (8 lines, research-validated 2025/26 UK installed figures):** + +| line | £ | driver | +|---|---|---| +| programmer | 120 | fixed | +| room_thermostat | 150 | fixed | +| trv_per_radiator | 35 | per radiator | +| zone_hub (smart thermostat) | 205 | fixed | +| smart_trv_per_radiator | 50 | per radiator | +| cylinder_thermostat | 150 | fixed | +| cylinder_jacket | 50 | fixed | +| boiler | 3200 | fixed | + +## Considered options + +- **Replicate the legacy VAT/labour/preliminaries arithmetic exactly.** Rejected: re-introduces a tax engine ADR-0025 deliberately avoided; false precision over rough estimates; ranking is insensitive to near-uniform tax/preliminaries. +- **Flat catalogue scalar (the placeholder).** Rejected: a tune-up's cost varies ~£200–£900 with what's already fitted and the radiator count; a flat number mis-ranks it against the boiler upgrade and ASHP. +- **Price controls as a flat job (no per-radiator term).** Rejected after research: TRVs and smart TRVs are genuinely per-radiator; a flat job over- or under-charges with dwelling size, and the per-radiator marginal rate is what makes the bundle sum match the EST reference. +- **Keep the legacy zone-control build-up (per-room sensor + per-radiator smart TRV).** Rejected after research: the smart TRV *is* the room sensor in real multi-zone systems (Tado/Wiser/evohome); the separate sensor line double-counts. +- **Keep the dry→wet system-change extras for robustness.** Rejected: dead code under the eligibility gate (existing wet boiler required); ADR-0025 likewise declined to price extras the data path can't reach. +- **Boiler cost stays boiler-only; controls/cylinder priced as separate measures.** Rejected: they're folded into the one Option's overlay, so pricing them separately would split one Plan line and risk double-charging against a tune-up. + +## Consequences + +- New `HeatingRates` + `heating_rates.json`, `BoilerCostInputs` / `TuneUpCostInputs`, and `Products.boiler_bundle_cost` / `tune_up_cost`; the boiler/tune-up Options swap their flat scalar for the composite (the catalogue row is still read for its `id`, as ASHP does). Contingencies for the two tune-up types drop 0.15 → 0.10 to match the legacy reference. +- A new **Table 4e Group-1 control-feature map** lives in the modelling interpreter — the single place that reads "what controls does this dwelling already have" from a SAP code. An unrecognised/absent control code defaults to "no parts present" (charge the full standard kit) — conservative, and the standard option is only offered when the control is improvable anyway. +- The figures are research-validated installed UK estimates, not a contractor rate sheet (unlike the ASHP Southern Housing lines). When a real boiler/controls rate sheet arrives it replaces `heating_rates.json` with no code change — the rates are data. +- Cost↔overlay consistency is structural: both read the same cylinder/control predicates, so they cannot drift (e.g. the overlay adding a thermostat the cost forgot). +- All dwelling reads (radiator count, existing control parts, cylinder state) are whole-dwelling proxies standing in for a survey, documented at each call site. diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 464d72bf..0483ebe2 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -21,8 +21,8 @@ _CONTINGENCY_RATES: dict[str, float] = { "high_heat_retention_storage_heaters": 0.10, "air_source_heat_pump": 0.25, "gas_boiler_upgrade": 0.26, - "system_tune_up": 0.15, - "system_tune_up_zoned": 0.15, + "system_tune_up": 0.10, + "system_tune_up_zoned": 0.10, "solar_pv": 0.15, } diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 2a6e908a..a7feaa96 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -17,7 +17,13 @@ from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from domain.geospatial.planning_restrictions import PlanningRestrictions -from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products +from domain.modelling.products import ( + AshpCostInputs, + AshpExistingSystem, + BoilerCostInputs, + Products, + TuneUpCostInputs, +) from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import EpcSimulation, HeatingOverlay @@ -341,15 +347,17 @@ def _tune_up_option( description: str, ) -> MeasureOption: """One tune-up Option: the existing boiler is kept; only the heating control - and the conditional cylinder fixes change.""" + and the conditional cylinder fixes change. Cost is composed per dwelling from + those components (ADR-0027); the catalogue row is read for its id.""" product = products.get(measure_type) + cost: Cost = Products().tune_up_cost( + tune_up_cost_inputs(epc, is_zoned=control == _ZONE_CONTROL) + ) return MeasureOption( measure_type=measure_type, description=description, overlay=EpcSimulation(heating=_tune_up_overlay(epc, control)), - cost=Cost( - total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate - ), + cost=cost, material_id=product.id, ) @@ -400,14 +408,16 @@ def _boiler_upgrade_option( if has_cylinder else "Replace the boiler with a gas condensing combi boiler" ) + # Cost is composed per dwelling from the boiler + the controls/cylinder + # fixes the overlay installs (ADR-0027), not the flat catalogue scalar; the + # catalogue row is still read for its id. product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE) + cost: Cost = Products().boiler_bundle_cost(boiler_cost_inputs(epc)) return MeasureOption( measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE, description=description, overlay=EpcSimulation(heating=overlay), - cost=Cost( - total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate - ), + cost=cost, material_id=product.id, ) @@ -500,6 +510,91 @@ def _upgraded_boiler_control(main: MainHeatingDetail) -> Optional[int]: return None +# --- Boiler / tune-up cost interpretation (ADR-0027): read the dwelling into the +# typed inputs the catalogue math needs. The pricing itself lives on `Products`; +# this is the modelling-layer half that the catalogue stays free of. --- + +# SAP 10.2 Table 4e Group 1 (PDF p.172) — which standard-control parts each +# boiler control code already provides: (has_programmer, has_room_thermostat, +# has_TRVs). Lets the standard-controls cost charge only the missing parts to +# reach 2106 (programmer + room thermostat + TRVs). Zone codes (2110/2112) are +# omitted — a standard upgrade is never offered to them. +_CONTROL_FEATURES_BY_CODE: dict[int, tuple[bool, bool, bool]] = { + 2101: (False, False, False), # No time or thermostatic control + 2102: (True, False, False), # Programmer, no room thermostat + 2103: (False, True, False), # Room thermostat only + 2104: (True, True, False), # Programmer and room thermostat + 2105: (True, True, False), # Programmer and at least two room thermostats + 2106: (True, True, True), # Programmer, room thermostat and TRVs + 2107: (True, False, True), # Programmer, TRVs and bypass + 2108: (True, False, True), # Programmer, TRVs and flow switch + 2109: (True, False, True), # Programmer, TRVs and boiler energy manager + 2111: (False, False, True), # TRVs and bypass + 2113: (False, True, True), # Room thermostat and TRVs +} + + +def _control_features(main: MainHeatingDetail) -> tuple[bool, bool, bool]: + """The standard-control parts a dwelling already has, from its SAP control + code. An unrecognised/absent code defaults to none present (charge the full + standard kit) — conservative, and the standard option is only offered when + the control is improvable anyway.""" + control = main.main_heating_control + code: Optional[int] = control if isinstance(control, int) else None + return _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) if ( + code is not None + ) else (False, False, False) + + +def _cylinder_fix_needs(epc: EpcPropertyData) -> tuple[bool, bool]: + """Whether the dwelling needs a cylinder jacket and/or a thermostat — the + same predicates the overlay uses (only when a cylinder exists).""" + if not epc.has_hot_water_cylinder: + return (False, False) + sap_heating = epc.sap_heating + needs_jacket: bool = _cylinder_under_insulated( + sap_heating.cylinder_insulation_thickness_mm + ) + needs_thermostat: bool = sap_heating.cylinder_thermostat != "Y" + return (needs_jacket, needs_thermostat) + + +def tune_up_cost_inputs(epc: EpcPropertyData, *, is_zoned: bool) -> TuneUpCostInputs: + """Read a dwelling into the inputs `Products.tune_up_cost` needs: the control + level, the radiator count (per-radiator items), the standard-control parts + already fitted, and the cylinder fixes that apply (ADR-0027).""" + main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] + has_programmer, has_room_thermostat, has_trvs = _control_features(main) + needs_jacket, needs_thermostat = _cylinder_fix_needs(epc) + return TuneUpCostInputs( + is_zoned=is_zoned, + radiator_count=_radiator_count(epc), + has_programmer=has_programmer, + has_room_thermostat=has_room_thermostat, + has_trvs=has_trvs, + needs_cylinder_jacket=needs_jacket, + needs_cylinder_thermostat=needs_thermostat, + ) + + +def boiler_cost_inputs(epc: EpcPropertyData) -> BoilerCostInputs: + """Read a dwelling into the inputs `Products.boiler_bundle_cost` needs: the + boiler is always priced; controls are added only when the upgrade fires a + controls change, and the cylinder fixes when applicable (ADR-0027).""" + main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] + has_programmer, has_room_thermostat, has_trvs = _control_features(main) + needs_jacket, needs_thermostat = _cylinder_fix_needs(epc) + return BoilerCostInputs( + upgrades_controls=_upgraded_boiler_control(main) is not None, + radiator_count=_radiator_count(epc), + has_programmer=has_programmer, + has_room_thermostat=has_room_thermostat, + has_trvs=has_trvs, + needs_cylinder_jacket=needs_jacket, + needs_cylinder_thermostat=needs_thermostat, + ) + + def _ashp_option( epc: EpcPropertyData, products: ProductRepository, diff --git a/domain/modelling/heating_rates.json b/domain/modelling/heating_rates.json new file mode 100644 index 00000000..f588e5aa --- /dev/null +++ b/domain/modelling/heating_rates.json @@ -0,0 +1,10 @@ +{ + "programmer": 120, + "room_thermostat": 150, + "trv_per_radiator": 35, + "zone_hub": 205, + "smart_trv_per_radiator": 50, + "cylinder_thermostat": 150, + "cylinder_jacket": 50, + "boiler": 3200 +} diff --git a/domain/modelling/products.py b/domain/modelling/products.py index 1e88fc95..ef6ed03b 100644 --- a/domain/modelling/products.py +++ b/domain/modelling/products.py @@ -26,6 +26,9 @@ from domain.modelling.recommendation import Cost _ASHP_MEASURE_TYPE = "air_source_heat_pump" _SOLAR_MEASURE_TYPE = "solar_pv" +_GAS_BOILER_UPGRADE_MEASURE_TYPE = "gas_boiler_upgrade" +_SYSTEM_TUNE_UP_MEASURE_TYPE = "system_tune_up" +_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = "system_tune_up_zoned" # The committed ASHP rate sheet (ADR-0025) — structured rate rows the flat # scalar catalogue cannot hold; loaded into `AshpRates`. @@ -33,6 +36,10 @@ _ASHP_RATES_PATH = Path(__file__).resolve().parent / "ashp_rates.json" # The committed Solar PV rate sheet (ADR-0026) — the Southern Housing "SOLAR PV # & BATTERY" EA-rate column; loaded into `SolarRates`. _SOLAR_RATES_PATH = Path(__file__).resolve().parent / "solar_rates.json" +# The committed boiler / tune-up rate table (ADR-0027) — research-validated +# fully-loaded UK installed figures (legacy `Costs.py` lineage); loaded into +# `HeatingRates`. +_HEATING_RATES_PATH = Path(__file__).resolve().parent / "heating_rates.json" _MIN_RADIATORS = 4 _MAX_RADIATORS = 12 @@ -159,6 +166,78 @@ class SolarCostInputs: elevations: int = 2 +@dataclass(frozen=True) +class HeatingRates: + """The boiler / tune-up rate table (ADR-0027) — research-validated, + fully-loaded UK installed figures (the legacy `Costs.py` lineage). Data, not + code: the committed default loads from `heating_rates.json`; a caller can + inject a variant (e.g. when a real contractor rate sheet arrives). Per- + radiator lines are priced × the dwelling's radiator count; the rest are fixed + per dwelling.""" + + programmer: float + room_thermostat: float + trv_per_radiator: float + zone_hub: float + smart_trv_per_radiator: float + cylinder_thermostat: float + cylinder_jacket: float + boiler: float + + @classmethod + def default(cls) -> "HeatingRates": + """Load the committed boiler / tune-up rate table.""" + return cls.from_json(_HEATING_RATES_PATH) + + @classmethod + def from_json(cls, path: Path) -> "HeatingRates": + with path.open(encoding="utf-8") as handle: + raw: dict[str, Any] = json.load(handle) + return cls( + programmer=float(raw["programmer"]), + room_thermostat=float(raw["room_thermostat"]), + trv_per_radiator=float(raw["trv_per_radiator"]), + zone_hub=float(raw["zone_hub"]), + smart_trv_per_radiator=float(raw["smart_trv_per_radiator"]), + cylinder_thermostat=float(raw["cylinder_thermostat"]), + cylinder_jacket=float(raw["cylinder_jacket"]), + boiler=float(raw["boiler"]), + ) + + +@dataclass(frozen=True) +class TuneUpCostInputs: + """The dwelling facts the system-tune-up catalogue math needs (ADR-0027): + which control level (standard vs zone), the radiator count driving the per- + radiator items, which standard-control parts are already fitted (so only the + missing parts are charged), and which cylinder fixes apply. Produced by the + modelling-layer interpreter, never read off the EPC here.""" + + is_zoned: bool + radiator_count: int + has_programmer: bool + has_room_thermostat: bool + has_trvs: bool + needs_cylinder_jacket: bool + needs_cylinder_thermostat: bool + + +@dataclass(frozen=True) +class BoilerCostInputs: + """The dwelling facts the boiler-upgrade catalogue math needs (ADR-0027): the + boiler is always priced; the standard-controls cost is added only when the + upgrade fired a controls change, and the cylinder fixes only when applicable. + No system-change extras — the upgrade is always a like-for-like wet swap.""" + + upgrades_controls: bool + radiator_count: int + has_programmer: bool + has_room_thermostat: bool + has_trvs: bool + needs_cylinder_jacket: bool + needs_cylinder_thermostat: bool + + class AshpExistingSystem(Enum): """The dwelling's pre-retrofit heating system, as it bears on decommission cost and whether a wet distribution system can be reused (ADR-0025). The @@ -194,11 +273,97 @@ class Products: self, rates: AshpRates | None = None, solar_rates: SolarRates | None = None, + heating_rates: HeatingRates | None = None, ) -> None: self._rates: AshpRates = rates if rates is not None else AshpRates.default() self._solar_rates: SolarRates = ( solar_rates if solar_rates is not None else SolarRates.default() ) + self._heating_rates: HeatingRates = ( + heating_rates if heating_rates is not None else HeatingRates.default() + ) + + def tune_up_cost(self, inputs: TuneUpCostInputs) -> Cost: + """Compose the fully-loaded system-tune-up total: the control upgrade + (zone full kit, or standard priced only for its missing parts) plus the + conditional cylinder fixes, with the tune-up contingency (ADR-0027).""" + controls: float = ( + self._zone_controls(inputs.radiator_count) + if inputs.is_zoned + else self._standard_controls( + inputs.radiator_count, + inputs.has_programmer, + inputs.has_room_thermostat, + inputs.has_trvs, + ) + ) + total: float = controls + self._cylinder_fixes( + inputs.needs_cylinder_jacket, inputs.needs_cylinder_thermostat + ) + measure_type: str = ( + _SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE + if inputs.is_zoned + else _SYSTEM_TUNE_UP_MEASURE_TYPE + ) + return Cost(total=total, contingency_rate=contingency_rate(measure_type)) + + def boiler_bundle_cost(self, inputs: BoilerCostInputs) -> Cost: + """Compose the fully-loaded gas-boiler-upgrade total: the all-in boiler, + plus the standard-controls cost only when the upgrade fired a controls + change, plus the conditional cylinder fixes (ADR-0027).""" + total: float = self._heating_rates.boiler + if inputs.upgrades_controls: + total += self._standard_controls( + inputs.radiator_count, + inputs.has_programmer, + inputs.has_room_thermostat, + inputs.has_trvs, + ) + total += self._cylinder_fixes( + inputs.needs_cylinder_jacket, inputs.needs_cylinder_thermostat + ) + return Cost( + total=total, + contingency_rate=contingency_rate(_GAS_BOILER_UPGRADE_MEASURE_TYPE), + ) + + def _standard_controls( + self, + radiator_count: int, + has_programmer: bool, + has_room_thermostat: bool, + has_trvs: bool, + ) -> float: + """Price the standard controls (SAP 2106) incrementally — only the parts + missing to reach programmer + room thermostat + a TRV per radiator.""" + rates = self._heating_rates + total: float = 0.0 + if not has_programmer: + total += rates.programmer + if not has_room_thermostat: + total += rates.room_thermostat + if not has_trvs: + total += rates.trv_per_radiator * radiator_count + return total + + def _zone_controls(self, radiator_count: int) -> float: + """Price the zone controls (SAP 2110) as a full smart kit: one hub plus a + smart TRV per radiator (the smart TRV is itself the room sensor).""" + rates = self._heating_rates + return rates.zone_hub + rates.smart_trv_per_radiator * radiator_count + + def _cylinder_fixes( + self, needs_jacket: bool, needs_thermostat: bool + ) -> float: + """Price the conditional cylinder fixes — an 80 mm jacket and/or a + cylinder thermostat, each only when needed.""" + rates = self._heating_rates + total: float = 0.0 + if needs_jacket: + total += rates.cylinder_jacket + if needs_thermostat: + total += rates.cylinder_thermostat + return total def ashp_bundle_cost(self, inputs: AshpCostInputs) -> Cost: """Compose the fully-loaded ASHP bundle total for a dwelling and pair it diff --git a/tests/domain/modelling/test_heating_cost_inputs.py b/tests/domain/modelling/test_heating_cost_inputs.py new file mode 100644 index 00000000..470be139 --- /dev/null +++ b/tests/domain/modelling/test_heating_cost_inputs.py @@ -0,0 +1,97 @@ +"""The dwelling interpretation that feeds `Products.boiler_bundle_cost` / +`tune_up_cost` — reading an `EpcPropertyData` into typed cost inputs (ADR-0027). +The modelling-layer half of the split: it derives the radiator count, which +standard-control parts are already fitted (from the SAP Table 4e control code), +whether the boiler upgrade fires a controls change, and which cylinder fixes +apply — the catalogue math (Products) stays EPC-free. +""" + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.heating_recommendation import ( + boiler_cost_inputs, + tune_up_cost_inputs, +) +from domain.modelling.products import BoilerCostInputs, TuneUpCostInputs +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +def test_tune_up_inputs_from_no_controls_charge_every_part() -> None: + # Arrange — control 2101 ("no control"): no programmer, room thermostat or + # TRVs fitted; an uninsulated, un-thermostatted cylinder; 10 radiators. + epc: EpcPropertyData = parse_recommendation_summary( + "tune_up_from_2101_001431_before.pdf" + ) + + # Act + inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=False) + + # Assert + assert inputs == TuneUpCostInputs( + is_zoned=False, + radiator_count=10, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + +def test_tune_up_inputs_read_existing_control_parts() -> None: + # Arrange — control 2113 ("room thermostat and TRVs"): already has a room + # thermostat + TRVs, only the programmer is missing. The is_zoned flag is + # passed through. + epc: EpcPropertyData = parse_recommendation_summary( + "tune_up_from_2113_001431_before.pdf" + ) + + # Act + inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=True) + + # Assert — so the standard cost would charge only the programmer. + assert inputs.is_zoned is True + assert inputs.has_programmer is False + assert inputs.has_room_thermostat is True + assert inputs.has_trvs is True + + +def test_boiler_inputs_flag_a_controls_upgrade_for_inadequate_controls() -> None: + # Arrange — a combi (no cylinder) with inadequate controls (2111 "TRVs and + # bypass", no room thermostat): the boiler upgrade also fires the standard + # controls, which already has TRVs but no programmer/room thermostat. + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_gas_001431_before.pdf" + ) + + # Act + inputs: BoilerCostInputs = boiler_cost_inputs(epc) + + # Assert + assert inputs == BoilerCostInputs( + upgrades_controls=True, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=True, + needs_cylinder_jacket=False, + needs_cylinder_thermostat=False, + ) + + +def test_boiler_inputs_no_controls_upgrade_when_already_adequate() -> None: + # Arrange — a gas boiler with a cylinder and already-adequate controls + # (2106): the boiler doesn't fire a controls change, but both cylinder fixes + # apply (uninsulated, un-thermostatted). + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + + # Act + inputs: BoilerCostInputs = boiler_cost_inputs(epc) + + # Assert + assert inputs.upgrades_controls is False + assert inputs.needs_cylinder_jacket is True + assert inputs.needs_cylinder_thermostat is True diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index b0f86edf..9e4ca030 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -459,6 +459,47 @@ def test_tune_up_neither_offered_when_controls_already_zoned() -> None: assert "system_tune_up_zoned" not in measure_types +def test_tune_up_carries_the_composite_per_dwelling_cost() -> None: + # Arrange — a wet boiler with "no control" (2101) and an uninsulated, un- + # thermostatted cylinder, 10 radiators: the standard tune-up fits the full + # control set + both cylinder fixes (ADR-0027). + baseline: EpcPropertyData = _tune_up_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "system_tune_up" + ) + + # Assert — programmer 120 + room stat 150 + TRVs 10x35=350 = 620 controls + + # jacket 50 + cylinder stat 150 = 820, with the 0.10 tune-up contingency + # (composed, not the stub catalogue scalar). + assert option.cost is not None + assert abs(option.cost.total - 820.0) <= 1e-9 + assert abs(option.cost.contingency_rate - 0.10) <= 1e-9 + + +def test_boiler_upgrade_carries_the_composite_per_dwelling_cost() -> None: + # Arrange — a gas boiler with a cylinder and adequate controls (2106): the + # boiler is a like-for-like swap (no controls upgrade, no system-change + # extras), with both cylinder fixes (ADR-0027). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Assert — boiler 3200 + jacket 50 + cylinder stat 150 = 3400, 0.26 boiler + # contingency. + assert option.cost is not None + assert abs(option.cost.total - 3400.0) <= 1e-9 + assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 + + def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None: # Arrange — the same combi but with already-adequate controls (2113, room # thermostat and TRVs): the upgrade must not move the controls (and must diff --git a/tests/domain/modelling/test_products.py b/tests/domain/modelling/test_products.py index d05dee20..0fa81b58 100644 --- a/tests/domain/modelling/test_products.py +++ b/tests/domain/modelling/test_products.py @@ -15,10 +15,14 @@ from domain.modelling.products import ( AshpCostInputs, AshpExistingSystem, AshpRates, + BoilerCostInputs, Products, + TuneUpCostInputs, ) from domain.modelling.recommendation import Cost +_PIN: float = 1e-9 + def test_ashp_bundle_cost_composes_an_electric_storage_full_distribution_dwelling() -> None: # Arrange — a small electric-storage dwelling: no reusable wet system, so a @@ -192,3 +196,118 @@ def test_radiator_count_is_clamped_to_the_distribution_table_bounds() -> None: assert abs(_full_distribution(products, 9) - 4680.0) <= 1e-9 assert abs(_full_distribution(products, 12) - 6288.0) <= 1e-9 assert abs(_full_distribution(products, 15) - 6288.0) <= 1e-9 + + +# --- Boiler / tune-up composite costs (ADR-0027) -------------------------- + + +def test_tune_up_standard_from_no_controls_with_cylinder_fixes() -> None: + # Arrange — a 7-radiator dwelling with no existing controls, an uninsulated + # un-thermostatted cylinder: the standard tune-up fits the full control set + # plus both cylinder fixes. + products = Products() + inputs = TuneUpCostInputs( + is_zoned=False, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + # Act + cost: Cost = products.tune_up_cost(inputs) + + # Assert — programmer 120 + room stat 150 + TRVs 7x35=245 = 515 controls, + # + jacket 50 + cylinder stat 150 = 715, with the 0.10 tune-up contingency. + assert abs(cost.total - 715.0) <= _PIN + assert abs(cost.contingency_rate - 0.10) <= _PIN + + +def test_tune_up_standard_charges_only_the_missing_control_parts() -> None: + # Arrange — the dwelling already has a room thermostat + TRVs (only the + # programmer is missing), and the cylinder is already sorted. + products = Products() + inputs = TuneUpCostInputs( + is_zoned=False, + radiator_count=7, + has_programmer=False, + has_room_thermostat=True, + has_trvs=True, + needs_cylinder_jacket=False, + needs_cylinder_thermostat=False, + ) + + # Act + cost: Cost = products.tune_up_cost(inputs) + + # Assert — only the programmer is charged (incremental, no double-charge). + assert abs(cost.total - 120.0) <= _PIN + + +def test_tune_up_zoned_prices_a_full_smart_kit_no_per_room_sensor() -> None: + # Arrange — a 7-radiator dwelling, zone tune-up, both cylinder fixes. + products = Products() + inputs = TuneUpCostInputs( + is_zoned=True, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + # Act + cost: Cost = products.tune_up_cost(inputs) + + # Assert — hub 205 + smart TRVs 7x50=350 = 555 (no separate sensor line), + # + cylinder 200 = 755, 0.10 contingency. Zone is a full kit regardless of + # the existing parts. + assert abs(cost.total - 755.0) <= _PIN + assert abs(cost.contingency_rate - 0.10) <= _PIN + + +def test_boiler_bundle_cost_controls_already_adequate() -> None: + # Arrange — a like-for-like gas boiler swap whose controls are already + # adequate (no controls upgrade), with both cylinder fixes. + products = Products() + inputs = BoilerCostInputs( + upgrades_controls=False, + radiator_count=7, + has_programmer=True, + has_room_thermostat=True, + has_trvs=True, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + # Act + cost: Cost = products.boiler_bundle_cost(inputs) + + # Assert — boiler 3200 + cylinder 200 = 3400, with the 0.26 boiler + # contingency. No controls, no system-change extras. + assert abs(cost.total - 3400.0) <= _PIN + assert abs(cost.contingency_rate - 0.26) <= _PIN + + +def test_boiler_bundle_cost_adds_standard_controls_when_upgraded() -> None: + # Arrange — a gas boiler swap that also fixes inadequate controls (from + # nothing) on a 7-radiator dwelling, no cylinder. + products = Products() + inputs = BoilerCostInputs( + upgrades_controls=True, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=False, + needs_cylinder_thermostat=False, + ) + + # Act + cost: Cost = products.boiler_bundle_cost(inputs) + + # Assert — boiler 3200 + standard controls 515 = 3715. + assert abs(cost.total - 3715.0) <= _PIN From 6ce6e89de1277069d66991ed78ec7dcdbf375b3e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 07:15:58 +0000 Subject: [PATCH 2/8] feat(modelling): gate boiler upgrade on the existing boiler's efficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't offer a like-for-like gas boiler swap to a dwelling whose existing gas boiler is already at least as efficient as the new condensing boiler (SAP 10.2 Table 4b codes 102/104 = 84% winter) — it gains nothing, and the dwelling gets the tune-up (cylinder + controls) instead. `_already_condensing` compares the existing code's Table 4b winter efficiency to 84%; a non-Table-4b code (solid fuel) has no comparable efficiency and is never treated as already-condensing. The gate is GAS-ONLY: a non-gas boiler → gas is a fuel switch whose value (cost / carbon) is not captured by winter efficiency, so oil/LPG/coal → gas is never suppressed on efficiency grounds (only gated on the mains-gas connection). This correctly demotes the gas-with-cylinder example (cert lodges code 114 "Regular, condensing", 84% winter) to a tune-up case — confirming that 114→102 is ~0 boiler-efficiency gain in both our calc and Elmhurst (both Table 4b 84%); Elmhurst's uplift there came from the cylinder + flue, not the boiler. The boiler-with-cylinder overlay stays validated by the lpg pin (code 115, non- condensing + cylinder) and by recasting the 114 fixtures' code to a pre-1998 non-condensing boiler (110) in the boiler tests — the overlay overwrites the code to 102 regardless, so only eligibility changes, not the delta-0 result. New tests: an already-condensing gas boiler yields no boiler upgrade (but a tune-up); an oil condensing boiler is not gated (the fuel switch survives). Co-Authored-By: Claude Opus 4.8 --- .../generators/heating_recommendation.py | 36 ++++++++++++- .../modelling/test_elmhurst_cascade_pins.py | 5 ++ .../modelling/test_heating_recommendation.py | 50 +++++++++++++++++-- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index a7feaa96..410eb5e6 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -27,6 +27,9 @@ from domain.modelling.products import ( from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import EpcSimulation, HeatingOverlay +from domain.sap10_calculator.tables.table_4b import ( + table_4b_seasonal_efficiencies_pct, +) from repositories.product.product_repository import ProductRepository _HEATING_SURFACE = "Heating & Hot Water" @@ -191,6 +194,14 @@ _ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197) _CYLINDER_JACKET_INSULATION_TYPE = 2 _MIN_CYLINDER_INSULATION_MM = 80 +# The new condensing boiler's winter efficiency: SAP 10.2 Table 4b codes 102 +# (regular condensing) and 104 (condensing combi) both lodge 84% winter. A +# like-for-like gas swap onto an existing gas boiler that already meets this +# gains nothing, so it is not offered (the dwelling gets a tune-up instead). The +# gate is gas-only: a non-gas boiler → gas is a fuel switch whose value is not +# captured by winter efficiency alone, so it is never suppressed on efficiency. +_NEW_BOILER_WINTER_EFFICIENCY_PCT = 84.0 + # --- ASHP cost interpretation (ADR-0025): read the dwelling into the typed # inputs the catalogue math needs. The modelling-layer half of the split; the @@ -427,7 +438,11 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: condensing boiler. The gas end-state is installable only with a mains-gas connection, so gas dwellings always qualify and a non-gas wet boiler (oil/LPG/solid) qualifies only where mains gas is present. Electric boilers - are left alone — electrification, not a gas swap, is their upgrade path.""" + are left alone — electrification, not a gas swap, is their upgrade path. A + gas boiler that already meets the new condensing efficiency is not re-offered + a like-for-like swap (it gains nothing — the dwelling gets a tune-up + instead); a non-gas boiler is a fuel switch, so it is never gated on + efficiency.""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] code: Optional[int] = main.sap_main_heating_code if code is None: @@ -436,7 +451,24 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: return False if code in _ELECTRIC_BOILER_SAP_CODE_RANGE: return False - return epc.sap_energy_source.mains_gas + if not epc.sap_energy_source.mains_gas: + return False + if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(code): + return False + return True + + +def _already_condensing(sap_main_heating_code: int) -> bool: + """Whether an existing gas boiler already meets the new condensing boiler's + winter efficiency (SAP 10.2 Table 4b). Non-Table-4b codes (e.g. solid fuel) + have no comparable efficiency and so are never treated as already-condensing.""" + efficiencies: Optional[tuple[float, float]] = table_4b_seasonal_efficiencies_pct( + sap_main_heating_code + ) + if efficiencies is None: + return False + winter_efficiency_pct: float = efficiencies[0] + return winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay: diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index a124c5c4..c89c2aa5 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -772,6 +772,11 @@ def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: before: EpcPropertyData = parse_recommendation_summary( "boiler_cyl_gas_001431_before.pdf" ) + # The cert lodges code 114 (already condensing), which the efficiency gate + # excludes from a like-for-like swap; recast to a pre-1998 non-condensing + # boiler (110) so the upgrade is offered. The overlay overwrites the code to + # 102 regardless, so this changes only eligibility, not the validated result. + before.sap_heating.main_heating_details[0].sap_main_heating_code = 110 after: EpcPropertyData = parse_recommendation_summary( "boiler_cyl_gas_001431_after.pdf" ) diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 9e4ca030..5e8e1576 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -256,9 +256,17 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> None: def _gas_boiler_with_cylinder_baseline() -> EpcPropertyData: - """A mains-gas wet boiler (Table 4b code 114) heating an uninsulated, un- - thermostatted hot-water cylinder — the boiler-with-cylinder dwelling.""" - return parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf") + """A mains-gas wet boiler heating an uninsulated, un-thermostatted hot-water + cylinder — the boiler-with-cylinder dwelling. The cert lodges code 114 + (already condensing), which the efficiency gate excludes from a like-for-like + swap; recast to a pre-1998 non-condensing boiler (code 110) so the boiler + upgrade is a genuine candidate (the overlay overwrites the code to 102 + regardless of the before).""" + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + epc.sap_heating.main_heating_details[0].sap_main_heating_code = 110 + return epc def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> None: @@ -329,6 +337,42 @@ def test_boiler_upgrade_skips_thermostat_when_already_present() -> None: assert overlay.cylinder_insulation_type == 2 +def test_already_condensing_gas_boiler_yields_no_boiler_upgrade() -> None: + # Arrange — the real cert: a mains-gas boiler already condensing (Table 4b + # code 114, 84% winter — the same as the new code 102). A like-for-like swap + # gains nothing, so the boiler upgrade is not offered; the dwelling still + # gets a tune-up for its cylinder + controls. + baseline: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + measure_types = {o.measure_type for o in recommendation.options} + assert "gas_boiler_upgrade" not in measure_types + assert "system_tune_up_zoned" in measure_types + + +def test_non_gas_boiler_is_not_gated_on_efficiency() -> None: + # Arrange — an oil condensing boiler (Table 4b code 127, 84% winter — meets + # the new gas boiler's efficiency) on a mains-gas street. Unlike a gas + # boiler, the oil→gas fuel switch has value beyond efficiency, so it is NOT + # suppressed by the efficiency gate. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 127 + baseline.sap_heating.main_heating_details[0].main_fuel_type = 28 # oil + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the boiler upgrade is still offered (the fuel switch). + assert recommendation is not None + assert "gas_boiler_upgrade" in {o.measure_type for o in recommendation.options} + + def test_electric_boiler_dwelling_yields_no_gas_boiler_upgrade() -> None: # Arrange — an electric boiler (Table 4a code 191) is left alone: # electrification, not a gas swap, is its upgrade path. From 9b286e4a22f83763230d260a9ca9d3ba5c4b3fb6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 13:13:20 +0000 Subject: [PATCH 3/8] feat(modelling): SecondaryHeatingOverlay clears the lodged secondary (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first overlay surface that sets fields to *absent* rather than to a target state: _fold_secondary_heating clears sap_heating.secondary_heating_type + secondary_fuel_type, so the calculator's Table 11 secondary-fraction split (SAP 10.2 §9a) routes 100% of space heating to the main. On an electric-storage main RdSAP §A.2.2 re-forces a default secondary, making removal a no-op there — left to the Optimiser to de-select (ADR-0028 decisions 2-3). Co-Authored-By: Claude Opus 4.8 --- .../modelling/scoring/overlay_applicator.py | 18 ++++++++++ domain/modelling/simulation.py | 19 +++++++++++ .../modelling/test_overlay_applicator.py | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index f15ff102..c11285ca 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -21,6 +21,7 @@ from domain.modelling.simulation import ( EpcSimulation, HeatingOverlay, LightingOverlay, + SecondaryHeatingOverlay, SolarOverlay, VentilationOverlay, WindowOverlay, @@ -54,12 +55,29 @@ def apply_simulations( _fold_lighting(result, simulation.lighting) if simulation.heating is not None: _fold_heating(result, simulation.heating) + if simulation.secondary_heating is not None: + _fold_secondary_heating(result, simulation.secondary_heating) if simulation.solar is not None: _fold_solar(result, simulation.solar) return result +def _fold_secondary_heating( + epc: EpcPropertyData, overlay: SecondaryHeatingOverlay +) -> None: + """Strip the dwelling's lodged secondary heating system (ADR-0028) — the one + fold that sets fields to *absent* rather than to a target state. Clears + `secondary_heating_type` + `secondary_fuel_type` on `sap_heating`, so the + calculator's Table 11 split routes 100% of space heating to the main (or, on + an electric-storage main, re-forces the §A.2.2 default — a no-op the + Optimiser de-selects).""" + if not overlay.remove: + return + epc.sap_heating.secondary_heating_type = None + epc.sap_heating.secondary_fuel_type = None + + # `HeatingOverlay` fields grouped by the object they target — the deepest fold, # spanning the primary `MainHeatingDetail`, `sap_heating`, the top-level # `EpcPropertyData`, and `sap_energy_source` (ADR-0024). diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index abf192e9..083f8898 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -140,6 +140,24 @@ class HeatingOverlay: mains_gas: Optional[bool] = None +@dataclass(frozen=True) +class SecondaryHeatingOverlay: + """The change the Secondary Heating Removal Measure makes (ADR-0028): strip + the dwelling's lodged secondary heating system so the main serves 100% of + space heating. Unlike every other overlay — which writes a *target state* + and treats ``None`` as "leave unchanged" — this overlay *clears* the + secondary fields (`secondary_heating_type`, `secondary_fuel_type`) to + absent. Its presence on an `EpcSimulation` is the signal; `remove` carries + the intent explicitly. + + On an electric-storage main RdSAP §A.2.2 forces a default secondary back, so + removal is a no-op there — the Optimiser de-selects those (it owns the + economics); eligibility still offers them. + """ + + remove: bool = True + + @dataclass(frozen=True) class SolarOverlay: """All-optional partial of the dwelling's PV-bearing energy source — the @@ -194,4 +212,5 @@ class EpcSimulation: ventilation: Optional[VentilationOverlay] = None lighting: Optional[LightingOverlay] = None heating: Optional[HeatingOverlay] = None + secondary_heating: Optional[SecondaryHeatingOverlay] = None solar: Optional[SolarOverlay] = None diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index fe1c4dfe..1dce4067 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -17,6 +17,7 @@ from domain.modelling.simulation import ( EpcSimulation, HeatingOverlay, LightingOverlay, + SecondaryHeatingOverlay, SolarOverlay, VentilationOverlay, WindowOverlay, @@ -292,6 +293,38 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: assert result.sap_energy_source.mains_gas is False +def test_secondary_heating_overlay_clears_the_lodged_secondary() -> None: + # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ + # convector/radiant heaters). Pin a fuel on it too so we prove the fold + # clears BOTH the type and the fuel (ADR-0028). + baseline: EpcPropertyData = build_epc() + baseline.sap_heating.secondary_fuel_type = 30 + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act — fold a removal overlay. + result: EpcPropertyData = apply_simulations( + baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] + ) + + # Assert — the secondary is gone from the dwelling handed to the calculator. + assert result.sap_heating.secondary_heating_type is None + assert result.sap_heating.secondary_fuel_type is None + + +def test_secondary_heating_removal_does_not_mutate_the_baseline() -> None: + # Arrange — 000490 lodges secondary SAP code 691. + baseline: EpcPropertyData = build_epc() + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act — fold a removal overlay. + _: EpcPropertyData = apply_simulations( + baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] + ) + + # Assert — the baseline's secondary is untouched (the fold copies first). + assert baseline.sap_heating.secondary_heating_type == 691 + + def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: # Arrange — 000490 lodges a mains-gas combi (fuel 26, control 2106, no # cylinder, mains_gas True). From ae7959f57c876e6bb0d238d86ee5d64235741634 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 13:35:14 +0000 Subject: [PATCH 4/8] feat(modelling): secondary-heating-removal generator + MeasureType (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_secondary_heating_removal offers one standalone Option that clears the lodged secondary system. Eligibility is purely physical (offer iff sap_heating.secondary_heating_type is set) — no effectiveness gate, since a lodged secondary is a fixed emitter per RdSAP (portables are ignored), and the electric-storage §A.2.2 no-op is the Optimiser's call (ADR-0028 decisions 1-2). Priced at a flat per-dwelling decommission cost, not room-scaled. Co-Authored-By: Claude Opus 4.8 --- .../secondary_heating_recommendation.py | 57 +++++++++++ domain/modelling/measure_type.py | 1 + tests/domain/modelling/test_measure_type.py | 1 + .../test_secondary_heating_recommendation.py | 94 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 domain/modelling/generators/secondary_heating_recommendation.py create mode 100644 tests/domain/modelling/test_secondary_heating_recommendation.py diff --git a/domain/modelling/generators/secondary_heating_recommendation.py b/domain/modelling/generators/secondary_heating_recommendation.py new file mode 100644 index 00000000..16e40e38 --- /dev/null +++ b/domain/modelling/generators/secondary_heating_recommendation.py @@ -0,0 +1,57 @@ +"""The Secondary Heating Removal Recommendation Generator (ADR-0028). + +Offers to strip a dwelling's lodged secondary heating system so the main system +serves 100% of space heating. A **standalone, co-selectable** Recommendation — +not an Option in the Heating & Hot Water rec — because removing a secondary +heater is independent of (and combinable with) a tune-up or boiler upgrade. + +Eligibility is purely physical: offered **iff a secondary is lodged** +(`sap_heating.secondary_heating_type` is set). RdSAP only records a secondary +when a *fixed* emitter is present (portable plug-in heaters are ignored), so a +lodged secondary is by definition a fixed unit worth removing. There is no +effectiveness gate — on an electric-storage main RdSAP §A.2.2 forces a default +secondary back, making removal a no-op, but that is the Optimiser's call (it +owns the economics), not eligibility's. Detection + pricing only; impact is +produced later by scoring (ADR-0016). + +Priced at a flat per-dwelling decommission cost (one electrician visit to +disconnect a fixed/hard-wired heater + localised making-good), not scaled by +room count — the EPC lodges one secondary system with no heater count (ADR-0028). +""" + +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_type import MeasureType +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, SecondaryHeatingOverlay +from repositories.product.product_repository import ProductRepository + +_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE: Final[MeasureType] = ( + MeasureType.SECONDARY_HEATING_REMOVAL +) + + +def recommend_secondary_heating_removal( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a Secondary Heating Removal Recommendation — its single Option + clears the lodged secondary system — else None when no secondary is lodged + (nothing physical to remove).""" + if epc.sap_heating.secondary_heating_type is None: + return None + + product = products.get(_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE) + overlay = EpcSimulation(secondary_heating=SecondaryHeatingOverlay()) + cost = Cost( + total=product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + option = MeasureOption( + measure_type=_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE, + description="Remove the secondary heating system", + overlay=overlay, + cost=cost, + material_id=product.id, + ) + return Recommendation(surface="Secondary Heating", options=(option,)) diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index 70a52c90..a1882853 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -36,3 +36,4 @@ class MeasureType(StrEnum): SYSTEM_TUNE_UP = "system_tune_up" SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned" SOLAR_PV = "solar_pv" + SECONDARY_HEATING_REMOVAL = "secondary_heating_removal" diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py index 214df8bc..93431c21 100644 --- a/tests/domain/modelling/test_measure_type.py +++ b/tests/domain/modelling/test_measure_type.py @@ -30,6 +30,7 @@ _EXPECTED_VALUES = { "system_tune_up", "system_tune_up_zoned", "solar_pv", + "secondary_heating_removal", } diff --git a/tests/domain/modelling/test_secondary_heating_recommendation.py b/tests/domain/modelling/test_secondary_heating_recommendation.py new file mode 100644 index 00000000..13ae6f69 --- /dev/null +++ b/tests/domain/modelling/test_secondary_heating_recommendation.py @@ -0,0 +1,94 @@ +"""Behaviour of the Secondary Heating Removal Recommendation Generator: offering +to strip a dwelling's lodged secondary heating system so the main serves 100% of +space heating (ADR-0028). A standalone, co-selectable Recommendation; eligibility +is purely physical (offer iff a secondary is lodged) — the Optimiser de-selects +the cases where removal cannot move SAP. Detection + pricing only (ADR-0016). +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import SecondaryHeatingOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed flat per-dwelling + decommission price (ADR-0028).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=250.0, + contingency_rate=0.25, + id=11, + ) + + +def _without_secondary(epc: EpcPropertyData) -> EpcPropertyData: + """Return a copy of `epc` with no secondary heating system lodged.""" + clone: EpcPropertyData = copy.deepcopy(epc) + clone.sap_heating.secondary_heating_type = None + clone.sap_heating.secondary_fuel_type = None + return clone + + +def test_dwelling_with_a_lodged_secondary_yields_a_removal_recommendation() -> None: + # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ + # convector/radiant heaters). + baseline: EpcPropertyData = build_epc() + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert — one Option whose overlay clears the secondary. + assert recommendation is not None + assert recommendation.surface == "Secondary Heating" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "secondary_heating_removal" + assert option.overlay.secondary_heating == SecondaryHeatingOverlay() + + +def test_dwelling_without_a_secondary_yields_no_recommendation() -> None: + # Arrange — nothing lodged to remove (RdSAP only records a secondary when a + # fixed emitter is present; a portable would not be lodged at all). + baseline: EpcPropertyData = _without_secondary(build_epc()) + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_per_dwelling_decommission() -> None: + # Arrange — a lodged secondary; the cost is a flat per-dwelling decommission + # figure (one electrician visit + localised making-good), not room-scaled. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 250.0 + assert cost.contingency_rate == 0.25 + assert recommendation.options[0].material_id == 11 From 0d1ec2228d243096856f33cee93ae89fd6050437 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 13:51:16 +0000 Subject: [PATCH 5/8] feat(modelling): cost data for secondary-heating-removal (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flat per-dwelling decommission price (sample_catalogue \£250) + 0.25 contingency (covers unknown heater count / hard-wired-vs-plugged / repaint extent). The JSON repo joins the contingency from config, proven by the new repo test. No composite Products machinery — a lodged secondary is one roughly-fixed job, not room-scaled. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/contingencies.py | 4 ++++ harness/sample_catalogue.json | 3 ++- .../product/test_product_json_repository.py | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 0483ebe2..4163b9ec 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -24,6 +24,10 @@ _CONTINGENCY_RATES: dict[str, float] = { "system_tune_up": 0.10, "system_tune_up_zoned": 0.10, "solar_pv": 0.15, + # Decommissioning a fixed secondary heater + localised making-good is small, + # uncertain work: the rate covers the unknown heater count / hard-wired vs + # plugged status / repaint extent (ADR-0028). + "secondary_heating_removal": 0.25, } diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index 02eb24eb..1eb5ad62 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -16,5 +16,6 @@ "gas_boiler_upgrade": { "unit_cost_per_m2": 3000.0 }, "system_tune_up": { "unit_cost_per_m2": 500.0 }, "system_tune_up_zoned": { "unit_cost_per_m2": 900.0 }, - "solar_pv": { "unit_cost_per_m2": 0.0 } + "solar_pv": { "unit_cost_per_m2": 0.0 }, + "secondary_heating_removal": { "unit_cost_per_m2": 250.0 } } diff --git a/tests/repositories/product/test_product_json_repository.py b/tests/repositories/product/test_product_json_repository.py index a991f2a6..30ebfc94 100644 --- a/tests/repositories/product/test_product_json_repository.py +++ b/tests/repositories/product/test_product_json_repository.py @@ -50,6 +50,24 @@ def test_get_raises_when_measure_type_absent(tmp_path: Path) -> None: ProductJsonRepository(catalogue).get("cavity_wall_insulation") +def test_get_joins_the_secondary_heating_removal_contingency(tmp_path: Path) -> None: + # Arrange — a flat per-dwelling decommission price for the removal measure + # (ADR-0028); the 0.25 contingency is joined from config, not the file. + catalogue: Path = _write_catalogue( + tmp_path, {"secondary_heating_removal": {"unit_cost_per_m2": 250.0}} + ) + + # Act + product: Product = ProductJsonRepository(catalogue).get( + "secondary_heating_removal" + ) + + # Assert + assert product.measure_type == "secondary_heating_removal" + assert abs(product.unit_cost_per_m2 - 250.0) <= 1e-9 + assert abs(product.contingency_rate - 0.25) <= 1e-9 + + def test_get_raises_when_entry_lacks_unit_cost(tmp_path: Path) -> None: # Arrange catalogue: Path = _write_catalogue( From f9a89a8e111f0cf7a5b3e16a8a4f88153c357f24 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 15:32:14 +0000 Subject: [PATCH 6/8] =?UTF-8?q?docs(adr):=20secondary=20heating=20removal?= =?UTF-8?q?=20=E2=80=94=20ADR-0028=20+=20CONTEXT=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the four load-bearing design decisions from the grill-with-docs session: standalone co-selectable rec; eligibility = lodged-only (no effectiveness gate, electric-storage §A.2.2 no-op is the Optimiser's call); dedicated clearing SecondaryHeatingOverlay; flat per-dwelling cost (a lodged secondary is fixed per RdSAP, so a real decommission job, not room-scaled). Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 4 +++ docs/adr/0028-secondary-heating-removal.md | 33 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 docs/adr/0028-secondary-heating-removal.md diff --git a/CONTEXT.md b/CONTEXT.md index 029640b0..13571679 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -274,6 +274,10 @@ _Avoid_: "low energy lighting" as the upgrade target (we go to **LED**); treatin The rule fixing which **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024, expanded). The competing Options are **mutually-exclusive** (the Optimiser picks at most one) and fall in two families: **whole-system replacements** — `high_heat_retention_storage_heaters`, `air_source_heat_pump` — which change main heating + **controls + fuel + meter + the implied hot water** at once (never a separate HW measure; the legacy heating-vs-HW split double-counted); and, for a dwelling keeping a serviceable wet boiler, **partial upgrades** — `gas_boiler_upgrade` (a like-for-like condensing **gas** boiler: gas→gas, or non-gas→gas only where mains gas is present; combi or regular-plus-cylinder, shaped by the dwelling) and the **system tune-up** (keep the boiler; install better **controls** + fix the **cylinder**), the tune-up offered at two competing control levels: `system_tune_up` (standard, SAP code 2106) and `system_tune_up_zoned` (time-and-temperature zone control, 2110 — more SAP uplift, more cost). Each Option is a **fixed, real, contractor-installable end-state** (ASHP via a fixed PCDB heat-pump index; HHR storage via `sap_main_heating_code=409`; the gas boiler via Table 4b code 102/104; controls via 2106/2110), not a derived ideal; **Product** stays cost-only, but a partial/bundle cost is **composed per dwelling** from the components the overlay installs (ADR-0025/0027), not a flat scalar. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR; **boiler upgrade / tune-up** → an existing (non-electric) wet boiler, the gas end-state gated on a mains-gas connection, a partial control upgrade offered only when it genuinely improves the existing control (never a downgrade or no-op). Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**. _Avoid_: separate "heating" and "hot water" recommendations (HW folds into each Option); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); treating the whole-system replacements and the partial boiler/tune-up upgrades as **separate** Recommendations (they are mutually-exclusive Options within the one heating rec — separate recs would let the Optimiser co-select and double-charge); a standalone hot-water-only or controls-only Recommendation (controls + cylinder fold into the boiler/tune-up Option) +**Secondary Heating Removal**: +The rule fixing the single Measure the **Secondary Heating Removal** Recommendation offers — strip the dwelling's lodged secondary heating system so the main system serves 100% of space heating (ADR-0028). A **standalone, co-selectable** Recommendation, **not** an Option in the Heating & Hot Water rec: removing a secondary heater is independent of (and combinable with) a tune-up or boiler upgrade, so it must not be made mutually-exclusive with them. Eligibility is purely physical — offered **iff a secondary is lodged** (`secondary_heating_type` is set); since RdSAP only records a secondary when a **fixed** emitter is present (portable plug-in heaters are ignored), a lodged secondary is by definition a fixed unit worth removing. There is **no effectiveness gate**: on an electric-storage main, RdSAP §A.2.2 *forces a default secondary back*, so removal yields zero SAP change — the **Optimiser** de-selects those (it owns the economics), eligibility does not pre-filter them. The change is a dedicated **Simulation Overlay** (`SecondaryHeatingOverlay`) that *clears* the secondary fields — the one overlay that sets a value to absent rather than to a target state. Priced at a **flat per-dwelling decommission cost** (one electrician visit to disconnect a fixed/hard-wired heater + localised making-good), not scaled by room count (the EPC carries no heater count). +_Avoid_: making it an Option inside the Heating & Hot Water rec (it is independent, not mutually-exclusive); gating out electric-storage dwellings where removal is a no-op (that is the Optimiser's call, not eligibility's); pricing by room count (the legacy room proxy — the EPC lodges one secondary system with no count); "secondary heating" as the Measure name (name the action: **Secondary Heating Removal**) + ### Valuation **Property Valuation**: diff --git a/docs/adr/0028-secondary-heating-removal.md b/docs/adr/0028-secondary-heating-removal.md new file mode 100644 index 00000000..3ccd1f78 --- /dev/null +++ b/docs/adr/0028-secondary-heating-removal.md @@ -0,0 +1,33 @@ +# Secondary Heating Removal — standalone recommendation, eligibility, overlay, costing + +We model "removal of secondary heating" — stripping a dwelling's lodged secondary heating system so the main system serves 100% of space heating — as a **standalone, co-selectable Recommendation** (`MeasureType.SECONDARY_HEATING_REMOVAL`), built API-inputs-first. This records the four load-bearing, non-obvious choices made designing it. + +## Status + +Accepted. + +## Decisions + +### 1. Standalone Recommendation, not an Option in the Heating & Hot Water rec + +The heating expansion (ADR-0024) consolidated heating into one rec whose Options are **mutually-exclusive** (the Optimiser picks ≤1) because they are whole-system replacements you would never combine. Secondary-heating removal is different in kind: it is independent of, and freely combinable with, a tune-up or a boiler upgrade (you can remove a panel heater *and* upgrade the controls). Making it an Option would falsely force mutual exclusivity with the partial heating upgrades. As a standalone rec it composes additively like loft/cavity/glazing. + +The redundancy risk with a whole-system bundle is self-resolving: an ASHP is calculator-category 4 → secondary fraction is already 0.00, so removal adds **zero marginal SAP** on top of it, and a SAP-maximising Optimiser never pays for a zero-gain measure. The two overlays touch disjoint fields, so there is no double-*SAP*-counting either. + +### 2. Eligibility is physical only — offer iff a secondary is lodged; no effectiveness gate + +Offered **iff `sap_heating.secondary_heating_type is not None`** (a surveyor-lodged secondary exists to remove). Per ADR-0024's principle — *eligibility encodes only physical/planning installability; the Optimiser owns the economics* — we deliberately do **not** gate out the cases where removal cannot move SAP. + +The surprising case: on an **electric-storage main** (SAP codes 401–407/409/421), RdSAP §A.2.2 **forces a default secondary (693, portable electric) back** even after the lodged one is removed, so removal is a guaranteed zero-SAP no-op. That is an *effectiveness* fact, not an installability one — so eligibility still offers it, and the Optimiser de-selects it (zero gain, real cost). This is why our only example cert (001431, main 402) shows F35→F35 unchanged, matching Elmhurst exactly. + +### 3. A dedicated `SecondaryHeatingOverlay` that *clears* fields + +Every other Simulation Overlay obeys "a `None` field means leave the baseline unchanged" and writes **target states**. Removal is the opposite — it must set `secondary_heating_type` and `secondary_fuel_type` *to* `None`, which that convention structurally cannot express. Rather than wart the absolute-target-state `HeatingOverlay` with a remove-flag, removal gets its own minimal overlay surface (`SecondaryHeatingOverlay`, with an explicit `remove` flag) + `EpcSimulation` slot + `_fold_secondary_heating`, mirroring the one-overlay-per-measure-family pattern. It is the one overlay that sets a value to *absent*. + +### 4. Flat per-dwelling decommission cost, not room-scaled + +Legacy `recommendations/SecondaryHeating.py` scaled cost by a room count (`habitable − heated`). We price a **flat per-dwelling scalar (~£250, contingency 0.25)** instead. Two reasons: (a) the EPC lodges a *single* secondary system with **no heater count**, so the legacy room proxy is unfounded; (b) because RdSAP only records a secondary when a **fixed** emitter is present (portable plug-in heaters are ignored), a lodged secondary is by definition a fixed unit — its removal is a real but roughly-fixed job (one electrician visit to disconnect/isolate a hard-wired panel/convector/radiant heater + a blanking plate + localised making-good of the wall). The contingency absorbs the unknown count / hard-wire status / repaint extent. No composite `Products` machinery and no `rates.json` entry — a future data-only upgrade if a real per-unit rate sheet arrives. + +## Validation + +The before/after example (cert 001431, main 402) is a forced-secondary delta-0 case, and shares the boiler-fixtures' Summary-path roof-fidelity gap, so the cascade pin asserts the **field-delta** (`score(before + remove-overlay) == score(before)` at delta 0), proving the overlay clears the fields and the calculator correctly re-forces the §A.2.2 default. A **synthetic unit test** recasts 001431's main to a non-forced gas-boiler code and asserts removal yields a *positive* SAP delta (Table 11 fraction → 0), exercising the value path without a second real cert. From 797b71cd13a2268a55fd645faa41dfb20127f1f0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 15:32:15 +0000 Subject: [PATCH 7/8] test(modelling): secondary-heating-removal cascade validation (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cascade tests on the worksheet-pinned 001431 build_epc() (the user's before/after Summary PDFs trip the documented 001431 window-extraction bug, so the repo's sanctioned 001431 baseline is used instead): - electric-storage main (code 402) + secondary 691: removal reproduces the secondary-removed cert at delta 0 — RdSAP §A.2.2 re-forces a default secondary, matching the user's F35→F35 example; - gas combi main (code 104) + secondary 691: removal strictly raises SAP (74.22→77.61) — the Table 11 fraction reallocates to the cheaper main. Co-Authored-By: Claude Opus 4.8 --- .../modelling/test_elmhurst_cascade_pins.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index c89c2aa5..0e6832b7 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -14,6 +14,7 @@ is a named generator/overlay/calculator gap to fix, never a tolerance to widen from __future__ import annotations +import copy from dataclasses import replace from typing import Final @@ -46,6 +47,9 @@ from domain.modelling.generators.solid_wall_recommendation import ( from domain.modelling.generators.glazing_recommendation import recommend_glazing from domain.modelling.generators.lighting_recommendation import recommend_lighting from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.recommendation import MeasureOption from domain.sap10_calculator.calculator import Sap10Calculator, SapResult @@ -53,6 +57,17 @@ from repositories.product.product_repository import ProductRepository from tests.domain.modelling._elmhurst_recommendation import ( parse_recommendation_summary, ) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_001431 import ( + build_epc as build_001431_epc, +) + +# RdSAP §A.2.2 forces a secondary system for electric-storage mains; SAP code +# 402 (slimline storage) is in that set. Code 104 (a gas combi boiler) is not. +_ELECTRIC_STORAGE_MAIN_CODE: Final[int] = 402 +_STANDARD_ELECTRICITY_FUEL: Final[int] = 30 +# SAP 10.2 Table 4a code 691 — electric panel/convector/radiant heaters, the +# fixed secondary the user's example cert lodges. +_SECONDARY_ELECTRIC_PANEL_CODE: Final[int] = 691 # Pin tolerance: the Summary PDFs are deterministic test vectors, so the # overlay must reproduce the re-lodged cert exactly. Matches the worksheet @@ -1122,3 +1137,58 @@ def test_solar_before_baselines() -> None: # Act / Assert — currently raises MissingMainFuelType. Sap10Calculator().calculate(before) + + +# --- Secondary Heating Removal (ADR-0028) ---------------------------------- +# The user's Elmhurst before/after Summary for this measure (cert 001431, +# electric-storage main + secondary 691) cannot be parsed — that PDF export +# trips the documented 001431 Summary window-extraction bug. So these pins use +# the worksheet-pinned `build_epc()` (a validated real-001431 representation, +# the repo's sanctioned 001431 baseline) with the secondary configuration set on +# it, exercising the real generator → overlay → calculator cascade. + + +def test_secondary_removal_on_an_electric_storage_main_is_a_no_op() -> None: + # Arrange — 001431 recast to an electric-storage main (SAP code 402, fuel 30) + # with a lodged secondary (691). RdSAP §A.2.2 forces a default secondary back + # on storage mains, so removal reproduces the after at delta 0 — exactly why + # the user's before/after Summaries both print SAP F35. + before: EpcPropertyData = build_001431_epc() + main = before.sap_heating.main_heating_details[0] + main.sap_main_heating_code = _ELECTRIC_STORAGE_MAIN_CODE + main.main_fuel_type = _STANDARD_ELECTRICITY_FUEL + main.main_heating_index_number = None + before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE + after: EpcPropertyData = copy.deepcopy(before) + after.sap_heating.secondary_heating_type = None + after.sap_heating.secondary_fuel_type = None + recommendation: Recommendation | None = recommend_secondary_heating_removal( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert — the overlay reproduces the secondary-removed cert at delta 0. + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_secondary_removal_on_a_non_forced_main_raises_sap() -> None: + # Arrange — 001431's lodged gas combi (SAP code 104, NOT a forced-secondary + # main) with an added electric secondary (691). Removing it reallocates the + # Table 11 secondary fraction to the cheaper gas main, so cost-based SAP rises + # (the value path the forced-secondary example can't exercise). + before: EpcPropertyData = build_001431_epc() + before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE + recommendation: Recommendation | None = recommend_secondary_heating_removal( + before, _AnyProduct() + ) + assert recommendation is not None + scorer = PackageScorer(Sap10Calculator()) + + # Act + with_secondary: Score = scorer.score(before, []) + removed: Score = scorer.score(before, [recommendation.options[0].overlay]) + + # Assert — removal strictly raises SAP (delta well above the pin tolerance). + assert removed.sap_continuous - with_secondary.sap_continuous > _PIN_ABS From b21171575064025fc8f67391b9d9ab978046f80a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 16:04:07 +0000 Subject: [PATCH 8/8] feat(modelling): wire secondary-heating-removal into the pipeline (ADR-0028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator runs recommend_secondary_heating_removal; report._triggers_for explains it via the lodged secondary_heating_type; harness catalogue + ARA seed price it. Re-pins the golden/integration plans it shifts: it is a cheap (\£250) SAP lever, so on gas-main certs lodging an electric secondary (691) it displaces the \£12k ASHP (0330, 0036) or joins the all-beneficial-measures package (000490, where its marginal SAP is 0 under the category-4 ASHP but the heater is still physically removed). Consistent with the optimiser's existing kitchen-sink package behaviour. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 4 ++ orchestration/modelling_orchestrator.py | 4 ++ tests/harness/test_console.py | 1 + tests/harness/test_report.py | 61 +++++++++++++------ ...test_ara_first_run_pipeline_integration.py | 30 +++++++++ 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/harness/report.py b/harness/report.py index 632c2b30..c9bd13a2 100644 --- a/harness/report.py +++ b/harness/report.py @@ -176,6 +176,10 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: epc.sap_heating.main_heating_details[0].main_heating_control ), } + if measure_type == "secondary_heating_removal": + # secondary_heating_recommendation.py fires on any lodged secondary + # system (ADR-0028); the lodged SAP code is the "why". + return {"secondary_heating_type": epc.sap_heating.secondary_heating_type} return {} diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 01428242..50be53a4 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -33,6 +33,9 @@ from domain.modelling.generators.solid_wall_recommendation import recommend_soli from domain.modelling.generators.glazing_recommendation import recommend_glazing from domain.modelling.generators.lighting_recommendation import recommend_lighting from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) from domain.modelling.generators.solar_recommendation import recommend_solar from domain.modelling.solar_potential import SolarPotential from domain.geospatial.planning_restrictions import PlanningRestrictions @@ -256,6 +259,7 @@ def _candidate_recommendations( recommend_glazing(effective_epc, products, planning_restrictions), recommend_lighting(effective_epc, products), recommend_heating(effective_epc, products, planning_restrictions), + recommend_secondary_heating_removal(effective_epc, products), recommend_solar( effective_epc, products, solar_potential, planning_restrictions ), diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 4ea237bc..5712a62c 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -41,6 +41,7 @@ _GENERATOR_MEASURE_TYPES = ( "gas_boiler_upgrade", "system_tune_up", "system_tune_up_zoned", + "secondary_heating_removal", ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 003ff7bf..af020aee 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -80,26 +80,36 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: # Assert — the Plan ran and every fired measure names its trigger fields. assert report.plan is not None assert report.plan_error is None - # The efficient representative heat pump (Vaillant aroTHERM plus 5 kW, - # ADR-0025) raises SAP enough on this gas dwelling that ASHP + solid-floor - # insulation reach the target band on their own, displacing the fabric stack - # the Optimiser used to assemble (ADR-0024 — ASHP is now a strong candidate). + # This gas dwelling lodges an electric secondary heater (SAP 691) on a + # category-2 main, so secondary-heating removal (ADR-0028) is a very cheap + # SAP lever (\£250); the Optimiser reaches the target band via the fabric + # stack + that removal, leaving the \£12k ASHP unselected (it owns the + # economics — ADR-0024). triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) assert set(triggers) == { - "air_source_heat_pump", + "cavity_wall_insulation", + "mechanical_ventilation", "solid_floor_insulation", + "secondary_heating_removal", } - # ASHP fired because the dwelling is a non-flat house not already a heat pump - # (eligibility is physical/planning only — ADR-0024). - assert triggers["air_source_heat_pump"].triggers == { - "property_type": "0", - "main_heating_category": 2, + # Cavity-fill fired off an uninsulated cavity wall; its dependent MEV fired + # because no mechanical ventilation is lodged. + assert triggers["cavity_wall_insulation"].triggers == { + "wall_construction": 4, + "wall_insulation_type": 4, + } + assert triggers["mechanical_ventilation"].triggers == { + "mechanical_ventilation_kind": None, } # Solid-floor insulation fired off an uninsulated solid ground floor. assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } + # Secondary-heating removal fired off the lodged secondary (SAP code 691). + assert triggers["secondary_heating_removal"].triggers == { + "secondary_heating_type": 691, + } def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None: @@ -125,6 +135,23 @@ def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None: } +def test_secondary_heating_removal_surfaces_its_eligibility_triggers() -> None: + # No golden API cert selects secondary-heating removal, so the trigger branch + # is exercised directly. The generator fires on any lodged secondary, so the + # lodged SAP code is what the report should explain (ADR-0028). + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a parseable 001431 cert with a secondary heating system lodged + # (SAP code 691, electric panel/convector/radiant heaters). + epc = parse_recommendation_summary("cavity_wall_001431_before.pdf") + epc.sap_heating.secondary_heating_type = 691 + + # Act / Assert + assert _triggers_for(epc, "secondary_heating_removal") == { + "secondary_heating_type": 691, + } + + def test_system_tune_up_surfaces_its_eligibility_triggers() -> None: # Like the boiler-upgrade trigger, no golden cert selects a tune-up, so the # branch is covered directly. @@ -147,18 +174,18 @@ def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: # Act report: PropertyReport = build_property_report(path) - # Assert — 0036 fires solid-floor insulation and the LED upgrade (it lodges - # 7 low-energy-unknown bulbs), and nothing else. + # Assert — 0036 reaches the target band with solid-floor insulation plus + # secondary-heating removal (it lodges an electric secondary, SAP 691, on a + # gas main — a cheap SAP lever, ADR-0028), and nothing else. The cheaper-to- + # target pair displaces the LED upgrade the Optimiser used to add. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) - assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"} + assert set(triggers) == {"solid_floor_insulation", "secondary_heating_removal"} assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } - assert triggers["low_energy_lighting"].triggers == { - "incandescent_fixed_lighting_bulbs_count": 0, - "cfl_fixed_lighting_bulbs_count": None, - "low_energy_fixed_lighting_bulbs_count": 7, + assert triggers["secondary_heating_removal"].triggers == { + "secondary_heating_type": 691, } diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index b7ffb86f..cadd5daa 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -192,6 +192,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( is_active=True, description="Zoned heating controls + cylinder tune-up", ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), ] ) session.commit() @@ -316,6 +324,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( is_active=True, description="LED bulb", ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), ] ) session.commit() @@ -379,6 +395,11 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( # ADR-0025) now raises SAP even on this gas dwelling, so the Optimiser # also keeps the ASHP bundle in the least-cost-to-band package (ADR-0024). "air_source_heat_pump", + # The sample lodges an electric secondary (SAP 691), so removal is offered + # (ADR-0028); the Optimiser keeps it in its all-beneficial-measures package + # — its SAP gain is 0 once the ASHP (category 4) ignores the secondary, but + # the heater is still physically removed at its own cost. + "secondary_heating_removal", } # Each persisted measure carries the catalogue id of the Product it installs # (the MaterialRow ids seeded above), replacing the retired @@ -387,6 +408,7 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert by_type["suspended_floor_insulation"].material_id == 2 assert by_type["mechanical_ventilation"].material_id == 3 assert by_type["low_energy_lighting"].material_id == 4 + assert by_type["secondary_heating_removal"].material_id == 9 for rec in rec_rows: assert rec.default is True assert rec.already_installed is False @@ -491,6 +513,14 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( is_active=True, description="LED bulb", ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), ] ) session.commit()