Merge branch 'feature/bill-derivation' of https://github.com/Hestia-Homes/Model into feature/junte+khalim

This commit is contained in:
Jun-te Kim 2026-06-12 12:52:40 +00:00
commit 77c5f7da49
25 changed files with 1118 additions and 35 deletions

View file

@ -275,8 +275,12 @@ 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)
**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

View file

@ -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.

View file

@ -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 401407/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.

View file

@ -21,9 +21,13 @@ _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,
# 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,
}

View file

@ -17,10 +17,19 @@ 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
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"
@ -185,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
@ -341,15 +358,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 +419,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,
)
@ -417,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:
@ -426,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:
@ -500,6 +542,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,

View file

@ -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,))

View file

@ -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
}

View file

@ -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"

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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 {}

View file

@ -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 }
}

View file

@ -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
@ -259,6 +262,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
),

View file

@ -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
@ -772,6 +787,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"
)
@ -1117,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

View file

@ -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

View file

@ -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.
@ -459,6 +503,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

View file

@ -30,6 +30,7 @@ _EXPECTED_VALUES = {
"system_tune_up",
"system_tune_up_zoned",
"solar_pv",
"secondary_heating_removal",
}

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -41,6 +41,7 @@ _GENERATOR_MEASURE_TYPES = (
"gas_boiler_upgrade",
"system_tune_up",
"system_tune_up_zoned",
"secondary_heating_removal",
)

View file

@ -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,
}

View file

@ -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()

View file

@ -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(