mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge branch 'feature/bill-derivation' of https://github.com/Hestia-Homes/Model into feature/junte+khalim
This commit is contained in:
commit
77c5f7da49
25 changed files with 1118 additions and 35 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
56
docs/adr/0027-boiler-and-tune-up-costing.md
Normal file
56
docs/adr/0027-boiler-and-tune-up-costing.md
Normal 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.
|
||||
33
docs/adr/0028-secondary-heating-removal.md
Normal file
33
docs/adr/0028-secondary-heating-removal.md
Normal 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 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.
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,))
|
||||
10
domain/modelling/heating_rates.json
Normal file
10
domain/modelling/heating_rates.json
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
tests/domain/modelling/test_heating_cost_inputs.py
Normal file
97
tests/domain/modelling/test_heating_cost_inputs.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ _EXPECTED_VALUES = {
|
|||
"system_tune_up",
|
||||
"system_tune_up_zoned",
|
||||
"solar_pv",
|
||||
"secondary_heating_removal",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -41,6 +41,7 @@ _GENERATOR_MEASURE_TYPES = (
|
|||
"gas_boiler_upgrade",
|
||||
"system_tune_up",
|
||||
"system_tune_up_zoned",
|
||||
"secondary_heating_removal",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue