mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #1198 from Hestia-Homes/feature/bill-derivation
Feature/bill derivation
This commit is contained in:
commit
ff4a2e4242
263 changed files with 19841 additions and 689 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -283,6 +283,10 @@ cache/
|
||||||
*.csv
|
*.csv
|
||||||
# Tracked reference CSV: SAP enum codes (gov api /api/codes) co-located with EpcPropertyData.
|
# Tracked reference CSV: SAP enum codes (gov api /api/codes) co-located with EpcPropertyData.
|
||||||
!datatypes/epc/domain/epc_codes.csv
|
!datatypes/epc/domain/epc_codes.csv
|
||||||
|
# Generated property-inspection report artifacts (and any fetched EPC dump).
|
||||||
|
property_report.md
|
||||||
|
modelling_e2e.md
|
||||||
|
epc_dump*/
|
||||||
*.xlsx
|
*.xlsx
|
||||||
# *.pdf
|
# *.pdf
|
||||||
**/Chunks/
|
**/Chunks/
|
||||||
|
|
@ -298,4 +302,4 @@ pyrightconfig.json
|
||||||
backlog/*
|
backlog/*
|
||||||
|
|
||||||
# Local Claude config files
|
# Local Claude config files
|
||||||
.claude/*
|
.claude/*modelling_cohort.csv
|
||||||
|
|
|
||||||
125
CONTEXT.md
125
CONTEXT.md
|
|
@ -88,15 +88,15 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits
|
||||||
### Modelling
|
### Modelling
|
||||||
|
|
||||||
**Effective EPC**:
|
**Effective EPC**:
|
||||||
The EpcPropertyData scored by the modelling pipeline for a single Property, derived from either Site Notes alone or the public EPC with Landlord Overrides applied; carries source-derived physical fields and originally recorded performance values, with model-rebaselined performance held separately in Baseline Performance.
|
The assembled `EpcPropertyData` picture the modelling pipeline scores for a single Property. Assembled from whichever source applies: Site Notes alone; or the public EPC with **Landlord Overrides** applied; or — when the EPC is **old** — its schema re-mapped to current and gaps filled from neighbour predictions; or — when there is **no EPC** — components **estimated from surrounding properties**. Carries source-derived physical fields and originally recorded performance values; the performance scored from this picture is held separately in **Baseline Performance**.
|
||||||
_Avoid_: modelling EPC, working EPC, resolved EPC, derived EPC
|
_Avoid_: modelling EPC, working EPC, resolved EPC, derived EPC
|
||||||
|
|
||||||
**Rebaselining**:
|
**Rebaselining**:
|
||||||
Re-predicting a Property's SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh via **SAP10 Calculation** (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013) so the modelling pipeline scores it against the current SAP10 methodology. Triggered when either (a) the Effective EPC was lodged under a methodology the calculator supersedes (`sap_version < 10.2`, the calculator's target spec), so the recorded scores reflect a superseded methodology, or (b) Site Notes / Landlord Overrides changed the physical state of the Property (walls / heating / windows / etc.) so the lodged scores no longer reflect what's installed. Both triggers may fire together. Produces Effective Performance; Lodged Performance is preserved unchanged. kWh is included as ML targets per ADR-0007 — see [[epc-ml-transform]].
|
Establishing a Property's **Effective Performance** (SAP score, EPC Band, CO2, Primary Energy Intensity, space-heating & hot-water kWh) by **assembling the Effective EPC picture and scoring it** through **SAP10 Calculation** (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013). The *assembly* is the substance: apply **Landlord Overrides** (e.g. boiler → ASHP, wall insulated) as a simulation on the `EpcPropertyData`; estimate components from surrounding properties when there is no EPC; re-map an old-schema EPC to current and gap-fill from neighbour predictions. The calculator is the **scoring engine at the tail**, not the whole of Rebaselining — so its call lives inside the Rebaseliner, after assembly. Triggered whenever the assembled picture differs from the lodged record: (a) the EPC was lodged under a methodology the calculator supersedes (`sap_version < 10.2`), (b) Overrides / Site Notes changed the physical state (walls / heating / windows / etc.), or (c) the picture is estimated or remapped rather than a real current EPC. Produces Effective Performance; Lodged Performance is preserved unchanged. The same single scoring also yields the per-end-use kWh that **Bill Derivation** prices — one scoring, two products. kWh is an ML target per ADR-0007 — see [[epc-ml-transform]].
|
||||||
_Avoid_: re-scoring, re-prediction, performance recomputation, refresh (for cache-freshness)
|
_Avoid_: re-scoring, re-prediction, performance recomputation, refresh (for cache-freshness)
|
||||||
|
|
||||||
**Baseline Performance**:
|
**Baseline Performance**:
|
||||||
A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, …) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI.
|
A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, cooling) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI.
|
||||||
_Avoid_: baseline predictions, predicted baseline, rebaselined values
|
_Avoid_: baseline predictions, predicted baseline, rebaselined values
|
||||||
|
|
||||||
**Lodged Performance**:
|
**Lodged Performance**:
|
||||||
|
|
@ -124,11 +124,11 @@ The subset of corpus certs used to validate **SAP10 Calculation** against **Lodg
|
||||||
_Avoid_: parity cohort, validation set, corpus sample
|
_Avoid_: parity cohort, validation set, corpus sample
|
||||||
|
|
||||||
**Measure Application**:
|
**Measure Application**:
|
||||||
The process that translates an Optimised Package into cert-field changes and produces the "ending state snapshot" EpcPropertyData that Plan Phase persists. Implemented by the `MeasureApplicator` service class in `domain/sap/` (or a sibling package). Each Measure Type's translation rules (e.g. `loft_insulation` → `roof_insulation_thickness_mm = 270mm`, `ashp` → `main_heating_details[0]` replacement) live here. Pure function — does not run SAP10 Calculation itself; the caller chains `MeasureApplicator.apply(epc, package) → Sap10Calculator.calculate(post_epc)`. ADR-0009.
|
The process that translates an Optimised Package into cert-field changes and produces the "ending state snapshot" EpcPropertyData that the **Plan** persists. Implemented by the `MeasureApplicator` service class in `domain/sap/` (or a sibling package). Each Measure Type's translation rules (e.g. `loft_insulation` → `roof_insulation_thickness_mm = 270mm`, `ashp` → `main_heating_details[0]` replacement) live here. Pure function — does not run SAP10 Calculation itself; the caller chains `MeasureApplicator.apply(epc, package) → Sap10Calculator.calculate(post_epc)`. ADR-0009.
|
||||||
_Avoid_: measure overrides (rejected during ADR-0009 grill — phantom mid-layer), package applier, retrofit simulator
|
_Avoid_: measure overrides (rejected during ADR-0009 grill — phantom mid-layer), package applier, retrofit simulator
|
||||||
|
|
||||||
**Bill Derivation**:
|
**Bill Derivation**:
|
||||||
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/property_baseline/` (deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
|
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/billing/` (a cross-stage concern — the Baseline stage derives the current bill, the Modelling stage re-runs it on the post-package end-state for post-retrofit bills; deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
|
||||||
_Avoid_: EPC Energy Derivation (renamed), EpcEnergyDerivationService (no "service" suffix), kWh prediction, baseline kWh, energy estimation
|
_Avoid_: EPC Energy Derivation (renamed), EpcEnergyDerivationService (no "service" suffix), kWh prediction, baseline kWh, energy estimation
|
||||||
|
|
||||||
**UCL Correction**:
|
**UCL Correction**:
|
||||||
|
|
@ -152,7 +152,7 @@ The second stage. Reads the persisted source data from repos, hydrates the **Pro
|
||||||
_Avoid_: rebaseline (that is a specific ML trigger — see Rebaselining), enrichment
|
_Avoid_: rebaseline (that is a specific ML trigger — see Rebaselining), enrichment
|
||||||
|
|
||||||
**Modelling** (stage):
|
**Modelling** (stage):
|
||||||
The third stage. Takes the baselined Property plus a set of **Scenarios** and produces **Recommendations** → an **Optimised Package** per **Scenario Phase** → **Plans**, persisted to repos. A separate orchestrator from Baseline so the single-property flow can stop after Baseline and only run Modelling when the user hits "play".
|
The third stage. Takes the baselined Property plus a set of **Scenarios** and produces **Recommendations** → an **Optimised Package** → **Plans**, persisted to repos. A separate orchestrator from Baseline so the single-property flow can stop after Baseline and only run Modelling when the user hits "play".
|
||||||
_Avoid_: scoring (overloaded), recommendation engine
|
_Avoid_: scoring (overloaded), recommendation engine
|
||||||
|
|
||||||
**First Run**:
|
**First Run**:
|
||||||
|
|
@ -194,41 +194,110 @@ _Avoid_: emission factors (ambiguous), CO2 rates
|
||||||
### Outputs
|
### Outputs
|
||||||
|
|
||||||
**Scenario**:
|
**Scenario**:
|
||||||
A named portfolio-level retrofit plan, built by a user in the scenario-builder UI and persisted before any modelling fires; carries the overall goal (e.g. Increasing EPC), budget, exclusions, housing type, and an ordered list of Scenario Phases. The model is triggered against one or more Scenarios at once; each Scenario yields one Plan per Property.
|
A named portfolio-level retrofit plan, built by a user in the scenario-builder UI and persisted before any modelling fires; carries the overall goal (e.g. Increasing EPC), budget, exclusions, housing type, and the set of measure types it permits. The model is triggered against one or more Scenarios at once; each Scenario yields one Plan per Property.
|
||||||
_Avoid_: project, batch, run-set
|
_Avoid_: project, batch, run-set
|
||||||
|
|
||||||
**Scenario Phase**:
|
|
||||||
One ordered step inside a Scenario, carrying a measure-type allowlist (e.g. "loft insulation and walls in phase 1; ASHP in phase 2"), an optional phase budget, and an optional phase target. A single-phase Scenario is one Scenario Phase with all measure types allowed and the full budget on it — there is no special-case path.
|
|
||||||
_Avoid_: scenario stage, scenario step, tranche
|
|
||||||
|
|
||||||
**Scenario Snapshot**:
|
**Scenario Snapshot**:
|
||||||
A frozen copy of a Scenario pinned at trigger time, keyed by (task, scenario); used by the modelling pipeline so mid-run edits to the live Scenario do not affect an in-flight job. Snapshots are read-only and may be garbage-collected after the task completes.
|
A frozen copy of a Scenario pinned at trigger time, keyed by (task, scenario); used by the modelling pipeline so mid-run edits to the live Scenario do not affect an in-flight job. Snapshots are read-only and may be garbage-collected after the task completes.
|
||||||
_Avoid_: scenario version, frozen scenario, pinned scenario
|
_Avoid_: scenario version, frozen scenario, pinned scenario
|
||||||
|
|
||||||
**Plan**:
|
**Plan**:
|
||||||
The per-Property output of one Scenario's modelling run; carries an ordered list of Plan Phases matching the Scenario's Phase shape. A Property modelled against N Scenarios in one trigger ends up with N Plans.
|
The per-Property output of one Scenario's modelling run; carries the **Optimised Package** selected for the Property (its **Plan Measures**) and the Property's post-retrofit figures (SAP / kWh / CO₂ / bills). A Property modelled against N Scenarios in one trigger ends up with N Plans.
|
||||||
_Avoid_: recommendation set, output, result
|
_Avoid_: recommendation set, output, result
|
||||||
|
|
||||||
**Plan Phase**:
|
**Plan Measure**:
|
||||||
The per-Property output of one Scenario Phase: the Optimised Package selected for that phase, the ending state snapshot (the Property's SAP / kWh / bills after the package is applied), and any Rolled-over Options that flow as candidates into the next Plan Phase.
|
One selected **Measure Option** as persisted inside a **Plan** — the single Option the Optimiser kept for a given **Recommendation**, recorded with its installed **Cost** and its **final-package (role-3) attributed impact** (the SAP points and CO₂ / energy savings that telescope exactly to the Plan's package total, per ADR-0016). It is the *output* counterpart to a Recommendation's *candidate* Option: a Recommendation proposes mutually-exclusive Options carrying no stored impact, whereas a Plan Measure is the one that was chosen with its truthful attributed impact frozen in. The persisted set of a Plan's Plan Measures **is** its Optimised Package.
|
||||||
_Avoid_: plan stage, plan step
|
_Avoid_: recommendation (that is the candidate — never persist an output as a Recommendation), installed measure, selected measure (that names the package, not the line), plan item, plan recommendation
|
||||||
|
|
||||||
**Rolled-over Options**:
|
|
||||||
Recommendations generated but not selected by the Optimiser in a given Plan Phase, that remain eligible as candidates in subsequent Plan Phases. Exact roll-over rule (automatic vs user-marked) is under design.
|
|
||||||
_Avoid_: deferred measures, leftover recommendations
|
|
||||||
|
|
||||||
**Recommendation**:
|
**Recommendation**:
|
||||||
A single proposed retrofit measure for a Property, with its cost, SAP impact, kWh savings, carbon savings, and parts list.
|
The finding that a Property needs work on a given **target surface** — a building part (the MAIN wall, an extension roof…) or a system (heating + hot water + controls, treated as one). Carries one or more mutually-exclusive **Measure Options**; the Optimiser selects at most one. The target itself is encoded in each Option's **Simulation Overlay** (which addresses a building part, a specific window, or a system) — never as a typed key on the Recommendation, so the type stays stable as new surfaces land. Recommendations **partition** the modifiable surface of EpcPropertyData: no two Recommendations write the same field of the same target, so selected Options never collide. Exclusivity between competing treatments (cavity-fill vs EWI; a boiler bundle vs an ASHP) is captured *within* one Recommendation, never across them.
|
||||||
_Avoid_: suggestion, option
|
_Avoid_: suggestion, recommendation engine, keying by measure type (a Recommendation can span measure types — e.g. a heating + hot-water bundle), the persisted selected-measure output line (that is a **Plan Measure**, which carries impact; a Recommendation never does)
|
||||||
|
|
||||||
|
**Measure Option**:
|
||||||
|
One mutually-exclusive way to satisfy a **Recommendation** — possibly a **bundle** of sub-measures (e.g. "new condensing boiler + cylinder insulation"), possibly a single intervention at a chosen size/product (a 4 kWp PV array of product X). Carries its total cost and a **Simulation Overlay** for its combined effect on the target surface. Cost is intrinsic to the Option; SAP / kWh / carbon impact is **not** — impact is cascade-conditional (depends on what is already installed) and is produced by scoring, never stored on the Option. Two Options under one Recommendation may share an identical Simulation Overlay (differing only on cost/product) or differ (e.g. PV kWp), so scoring runs per distinct Overlay.
|
||||||
|
_Avoid_: option (too generic), variant, SKU
|
||||||
|
|
||||||
|
**Simulation Overlay** (type `EpcSimulation`):
|
||||||
|
The change a single **Measure Option** makes to a Property's EpcPropertyData, expressed as an all-optional partial mirror of EpcPropertyData and its nested types — covering only the retrofit-relevant surface (walls/roofs/floors, windows, heating + controls, hot water, ventilation, lighting, PV, draughtproofing), never identity/location fields. Targets a specific building part by `BuildingPartIdentifier` (MAIN, EXTENSION_1..4) so "insulate the cavity wall" addresses the exact `SapBuildingPart`; targets a specific **window by its index** in `sap_windows` (the PDF's W1/W2/W3) — glazing measures address windows directly by number, regardless of which wall they sit on; the window's building-part association is carried separately via `window_location` (resolved by `_window_bp_index`), not used for targeting; and targets whole-dwelling systems (e.g. `sap_heating`) directly. Carries no scores. It is **not** an EpcPropertyData (composition, not inheritance — an all-`None` overlay is not a valid EPC). A domain operation folds a baseline EpcPropertyData + an ordered set of Overlays into a throwaway EpcPropertyData handed to the calculator; only the score is kept, the EPD is discarded.
|
||||||
|
_Avoid_: simulation config (the legacy EPC-API flag object), patch, delta, diff
|
||||||
|
|
||||||
|
**Product**:
|
||||||
|
A catalogue entry a **Measure Option** installs — insulation, glazing units, heat pumps, boilers, cylinders, PV panels, inverters, batteries — carrying the data to price an Option and shape its **Simulation Overlay**. Named *Product*, not *material*: the catalogue is dominated by equipment and appliances, and a heat pump is not a building material. Read via `ProductRepository`, which for now combines two inputs — the Products in the database plus a committed costs file holding what the ETL does not yet supply. Single-source unification (ETL-supplied costs) is separate, queued work; legacy `Costs.py` is retained but queued for deletion.
|
||||||
|
_Avoid_: material, building material (inaccurate for appliances), part (the per-Option installed line item), SKU
|
||||||
|
|
||||||
|
**Products** (the catalogue collection):
|
||||||
|
The rich in-memory domain collection over **Product** — an iterable the **ProductRepository** yields, carrying the cost-composition behaviour a single `Product` row cannot. Where a simple measure prices as one row (unit cost × area), a composite measure (an **ASHP bundle**) prices by *selecting and summing many priced line items* — so `Products` exposes per-measure cost methods (e.g. `ashp_bundle_cost`) that filter the relevant catalogue rows and sum them into a **Cost**. The split is load-bearing: `Products` owns the **catalogue math** (line-item lookup + summation from clean cost-inputs) and stays free of `EpcPropertyData` / the `Sap10Calculator`; the **dwelling interpretation** that produces those inputs (sizing a heat pump from heat loss, proxying beds/radiators, detecting a reusable wet system) lives in the modelling layer, which may depend on the calculator. ProductRepository = fetch; Products = behaviour.
|
||||||
|
_Avoid_: ProductRepository (that is the IO port, not the domain collection), putting sizing/EPC logic on Products
|
||||||
|
|
||||||
|
**Cost** (of a Measure Option):
|
||||||
|
A single **fully-loaded total** — products + labour + preliminaries + VAT + margin rolled into one figure — **plus a separately-carried Contingency**. Only contingency is broken out; the rest is not decomposed, as that breakdown proved unhelpful.
|
||||||
|
|
||||||
|
**Contingency**:
|
||||||
|
A per-**Measure-Type** percentage uplift on an Option's cost covering job-specific risk (e.g. cavity-wall 10%, internal/external wall 26%, ASHP 25% — cf. legacy `Costs.CONTINGENCIES`). The one cost component carried separately from the fully-loaded total, because the rate is measure-type-specific and meaningful to surface.
|
||||||
|
_Avoid_: preliminaries (a different, rolled-in 10%), margin
|
||||||
|
|
||||||
|
**Measure Dependency**:
|
||||||
|
A "selecting A requires B" edge between **Recommendations**, for couplings that are real but that the Optimiser would not choose on its own — e.g. wall (and possibly roof) insulation requires adequate ventilation. The required Option is excluded from the optimiser's candidate pool (it is mandatory-when-triggered, not a free choice) but is **injected into the Optimised Package before the package re-score**, so its real SAP contribution — which for ventilation is *negative* — is captured in the true package score and in the undershoot/repair loop. Trigger set is held as **data** (cf. legacy `assumptions.measures_needing_ventilation`), not control flow, so extending the triggers (e.g. to roof insulation) is a data edit. Distinct from the legacy post-optimisation best-practice add, which tacked cost on *after* scoring and so undershot.
|
||||||
|
_Avoid_: best-practice measure (legacy term), forced measure
|
||||||
|
|
||||||
**Optimised Package**:
|
**Optimised Package**:
|
||||||
The subset of a Property's Recommendations selected by the Optimiser Service for installation, chosen to satisfy the Scenario's goal subject to budget.
|
The subset of a Property's Recommendations selected by the Optimiser Service for installation. For an **Increasing EPC** goal the objective is **least-cost-to-target**: the cheapest package that reaches the goal band — so it **stops at the target and does not overshoot** into a higher band, leaving surplus budget unspent. When the target is **unreachable within budget**, it falls back to the **maximum improvement the budget buys** (best effort, below target). With **no budget** it is simply the cheapest package that reaches the target. Reaching the target is judged on the **true whole-package re-score** (ADR-0016), not on summed per-measure scores. (Other goals — Energy Savings, Reducing CO₂ — don't yet set a target and currently maximise improvement within budget; future work.)
|
||||||
_Avoid_: selected measures, default measures, optimal solution, recommended bundle
|
_Avoid_: selected measures, default measures, optimal solution, recommended bundle
|
||||||
|
|
||||||
**Measure Type**:
|
**Measure Type**:
|
||||||
The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insulation`, `ashp`); one or more Recommendations reference the same Measure Type with property-specific cost and impact.
|
The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insulation`, `ashp`); one or more Recommendations reference the same Measure Type with property-specific cost and impact.
|
||||||
_Avoid_: measure (ambiguous), category
|
_Avoid_: measure (ambiguous), category
|
||||||
|
|
||||||
|
**Solar Potential**:
|
||||||
|
The typed domain projection over a Property's Google Solar `buildingInsights` response (fetched by **Ingestion**, persisted whole as JSONB, read by Modelling — never re-fetched). It carries the per-**roof-segment** geometry the PV simulation needs — `azimuthDegrees` (→ SAP orientation octant), `pitchDegrees` (→ SAP pitch), per-segment panel counts, panel capacity, and `sunshineQuantiles` (→ overshading) — plus the candidate panel layouts (`solarPanelConfigs`). It is the source of the **PV Overlay**: the solar Recommendation Generator reads the Solar Potential (NOT the EPC's `photovoltaic_arrays`, which is the dwelling's *existing* PV) and builds competing PV Options from it. Distinct from existing/installed PV.
|
||||||
|
_Avoid_: solar config (ambiguous — the API response vs the chosen array set), Google insights (the raw JSON, not the typed projection)
|
||||||
|
|
||||||
|
**Solar PV Recommendation**:
|
||||||
|
The single "Solar PV" **Recommendation** for a Property, carrying competing whole-array **Measure Options** built from the **Solar Potential** (ADR-0026). Up to **five conservatively-sized array configs** (capped, ranked by energy generation as the size-suitability proxy; deliberately *not* full-roof — imagery can miss obstructions, so a coverage/edge-setback haircut applies per MCS), each offered **with and without a battery** (≤ 10 Options). Each Option is priced at a **single price point** from the rate sheet (kWp band + scaffolding-by-elevation + optional battery/diverter) — no multi-product price variants. The PV Overlay sets `photovoltaic_arrays` (one per segment: peak_power, orientation, pitch, overshading) plus `pv_diverter_present`, `pv_connection`, and **`is_dwelling_export_capable=True`** (an export meter is ensured post-install); the battery variant adds `pv_batteries`.
|
||||||
|
_Avoid_: solar bundle (PV is competing sized Options, not one fixed bundle like ASHP)
|
||||||
|
|
||||||
|
**Solar PV Eligibility**:
|
||||||
|
The rule fixing whether the **Solar PV Recommendation** is offered (ADR-0026): a **house or bungalow**, **not listed and not heritage**, with **no existing PV**, and a **feasible Solar Potential** (the Google Solar API returned usable, non-north roof segments). Crucially a **conservation area does NOT block PV** — panels are offered (installed sympathetically), so the planning gate is `not blocks_internal` (listed/heritage only), **not** `blocks_external`; this is the opposite of an external fabric measure like EWI, and is deliberate (legacy + planning practice allow conservation-area PV on non-prominent roofs). Flats/maisonettes (building-level shared roof) are deferred.
|
||||||
|
_Avoid_: blocking conservation-area PV (only listed/heritage block), roof-area-from-floor-area estimate (eligibility uses the real Solar Potential)
|
||||||
|
|
||||||
|
**External Wall Insulation (EWI)** / **Internal Wall Insulation (IWI)**:
|
||||||
|
The two competing **Measure Options** for insulating a *solid* (non-cavity) main wall — insulation fixed to the outside face (`wall_insulation_type = 1`) or the room side (`wall_insulation_type = 3`), both 100 mm at λ = 0.04 W/m·K; the calculator **derives** the post-insulation U-value from the type + thickness (the lodged cert carries no U-value). IWI additionally lowers the wall's thermal-mass parameter (changing heating demand); EWI does not.
|
||||||
|
_Avoid_: solid wall insulation (that names the pair, not one Option), cladding, drylining
|
||||||
|
|
||||||
|
**Wall Insulation Eligibility**:
|
||||||
|
The rule fixing which wall Option(s) the main-wall **Recommendation** offers, by wall construction then planning status. By construction: **cavity** → cavity fill only (never solid insulation); **solid brick** / **system-built** → IWI + EWI; **timber-frame** → IWI only (EWI is not constructable); **cob** / **granite or whinstone** / **sandstone or limestone** → none (breathable fabric — standard insulation risks trapping moisture). Then, as planning gates: a **conservation area** or **flat** removes EWI (external-appearance / whole-block constraints), and a **listed** or **heritage** building removes **both** EWI and IWI (protected fabric).
|
||||||
|
_Avoid_: restricted measures (legacy collapsed conservation/listed/heritage into one boolean — they now gate different Options, so keep them distinct)
|
||||||
|
|
||||||
|
**Roof Insulation Eligibility**:
|
||||||
|
The rule fixing which single roof Measure the main-roof **Recommendation** offers, by roof type. One Measure per roof — never a menu the Optimiser chooses between (ADR-0021): **pitched with an accessible loft** (incl. **thatch** — the covering doesn't block insulating the loft floor) → **loft insulation** (laid flat at the ceiling joists, 300 mm); **pitched with a sloping ceiling** → **sloping-ceiling insulation** (at the rafters, 100 mm); **flat roof** → **flat-roof insulation** (200 mm); **pitched, no access** → none (can't reach the void). A **room-in-roof** takes neither loft nor sloping-ceiling insulation — it is insulated at its own slopes/stud-walls (rafter-area, not floor-area, quantity) as a distinct **room-in-roof insulation** Measure, currently **deferred** (pending retrofit-specialist examples). A Measure is offered only when the roof is genuinely uninsulated ("As Built" / "None" / 0 mm).
|
||||||
|
_Avoid_: "roof insulation" (name the specific Measure — loft / sloping-ceiling / flat-roof / room-in-roof); "joist insulation" (use **loft insulation**, the established Measure Type)
|
||||||
|
|
||||||
|
**Glazing Eligibility**:
|
||||||
|
The rule fixing the single glazing Measure the **Windows** Recommendation offers. We upgrade **only single-glazed windows** (a pragmatic scope — already-double/secondary/triple windows are left alone), **all of them together** as one Measure. Planning status **hard-picks** the Measure (not a choice the Optimiser makes — ADR-0022): unrestricted → **double glazing** (replace the units); a **conservation area** / **listed** / **heritage** building → **secondary glazing** (an internal second pane, since the external units can't be replaced). Priced at a flat **average price per window** × the count of single-glazed windows (we have per-window areas but no size-varying prices, so size is ignored). When the dwelling has no single-glazed windows, no Recommendation is offered.
|
||||||
|
_Avoid_: "windows" as a Measure (name **double glazing** / **secondary glazing**); pricing glazing by area (it's per-window count × average)
|
||||||
|
|
||||||
|
**Lighting Eligibility**:
|
||||||
|
The rule fixing the single lighting Measure the **Lighting** Recommendation offers. We convert **all non-LED bulbs** (incandescent + CFL + low-energy-unknown) to **LED** — all the way to LED, not the legacy "fill to low energy", because SAP rates LED efficacy above CFL (ADR-0023). One Measure, no planning gate (lighting isn't planning-restricted). Offered only when the dwelling lodges at least one non-LED bulb; a dwelling already all-LED, or one that lodged **no** bulb counts (nothing to size against), gets no Recommendation. Unlike the fabric measures it is a **whole-dwelling** Measure — its **Simulation Overlay** writes the four top-level bulb counts directly (`led = total`, others 0), the first overlay surface that isn't a building part / window / system sub-object. Priced at a flat **average price per bulb** × the count of non-LED bulbs replaced. A free Optimiser candidate (it *improves* SAP), contrast the forced ventilation **Measure Dependency**.
|
||||||
|
_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 **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024, expanded). The competing Options are **mutually-exclusive** (the Optimiser picks at most one) and fall in two families: **whole-system replacements** — `high_heat_retention_storage_heaters`, `air_source_heat_pump` — which change main heating + **controls + fuel + meter + the implied hot water** at once (never a separate HW measure; the legacy heating-vs-HW split double-counted); and, for a dwelling keeping a serviceable wet boiler, **partial upgrades** — `gas_boiler_upgrade` (a like-for-like condensing **gas** boiler: gas→gas, or non-gas→gas only where mains gas is present; combi or regular-plus-cylinder, shaped by the dwelling) and the **system tune-up** (keep the boiler; install better **controls** + fix the **cylinder**), the tune-up offered at two competing control levels: `system_tune_up` (standard, SAP code 2106) and `system_tune_up_zoned` (time-and-temperature zone control, 2110 — more SAP uplift, more cost). Each Option is a **fixed, real, contractor-installable end-state** (ASHP via a fixed PCDB heat-pump index; HHR storage via `sap_main_heating_code=409`; the gas boiler via Table 4b code 102/104; controls via 2106/2110), not a derived ideal; **Product** stays cost-only, but a partial/bundle cost is **composed per dwelling** from the components the overlay installs (ADR-0025/0027), not a flat scalar. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR; **boiler upgrade / tune-up** → an existing (non-electric) wet boiler, the gas end-state gated on a mains-gas connection, a partial control upgrade offered only when it genuinely improves the existing control (never a downgrade or no-op). Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**.
|
||||||
|
_Avoid_: separate "heating" and "hot water" recommendations (HW folds into each Option); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); treating the whole-system replacements and the partial boiler/tune-up upgrades as **separate** Recommendations (they are mutually-exclusive Options within the one heating rec — separate recs would let the Optimiser co-select and double-charge); a standalone hot-water-only or controls-only Recommendation (controls + cylinder fold into the boiler/tune-up Option)
|
||||||
|
|
||||||
|
**Secondary Heating Removal**:
|
||||||
|
The rule fixing the single Measure the **Secondary Heating Removal** Recommendation offers — strip the dwelling's lodged secondary heating system so the main system serves 100% of space heating (ADR-0028). A **standalone, co-selectable** Recommendation, **not** an Option in the Heating & Hot Water rec: removing a secondary heater is independent of (and combinable with) a tune-up or boiler upgrade, so it must not be made mutually-exclusive with them. Eligibility is purely physical — offered **iff a secondary is lodged** (`secondary_heating_type` is set); since RdSAP only records a secondary when a **fixed** emitter is present (portable plug-in heaters are ignored), a lodged secondary is by definition a fixed unit worth removing. There is **no effectiveness gate**: on an electric-storage main, RdSAP §A.2.2 *forces a default secondary back*, so removal yields zero SAP change — the **Optimiser** de-selects those (it owns the economics), eligibility does not pre-filter them. The change is a dedicated **Simulation Overlay** (`SecondaryHeatingOverlay`) that *clears* the secondary fields — the one overlay that sets a value to absent rather than to a target state. Priced at a **flat per-dwelling decommission cost** (one electrician visit to disconnect a fixed/hard-wired heater + localised making-good), not scaled by room count (the EPC carries no heater count).
|
||||||
|
_Avoid_: making it an Option inside the Heating & Hot Water rec (it is independent, not mutually-exclusive); gating out electric-storage dwellings where removal is a no-op (that is the Optimiser's call, not eligibility's); pricing by room count (the legacy room proxy — the EPC lodges one secondary system with no count); "secondary heating" as the Measure name (name the action: **Secondary Heating Removal**)
|
||||||
|
|
||||||
|
### Valuation
|
||||||
|
|
||||||
|
**Property Valuation**:
|
||||||
|
The current open-market value of a Property — an externally-sourced **Baseline** attribute (customer upload or, later, an estimate), **absent for most Properties** and never derived from the EPC.
|
||||||
|
_Avoid_: valuation (ambiguous with Valuation Uplift), market price, current value, house price
|
||||||
|
|
||||||
|
**Valuation Uplift**:
|
||||||
|
The estimated increase in a Property's market value produced by a **Plan's** retrofit — **plan-conditional** (it depends on the Plan's target **EPC Band**) and **percentage-primary**: always expressible as a % from the Band jump (current → target), and as an absolute £ amount **only when a Property Valuation is known**. Capped so the £ uplift never exceeds twice the Plan's cost (the cap can only bite once a Property Valuation supplies the £ form — see ADR-0018).
|
||||||
|
_Avoid_: valuation increase, value gain, financial uplift, property_valuation_increase (pick one — Valuation Uplift is canonical)
|
||||||
|
|
||||||
### Address matching
|
### Address matching
|
||||||
|
|
||||||
**Lexiscore**:
|
**Lexiscore**:
|
||||||
|
|
@ -289,10 +358,10 @@ _Avoid_: API key, auth token, secret
|
||||||
- **Rebaselining** produces **Effective Performance** by ML re-prediction across SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh, when either (a) the Effective EPC was lodged under a pre-SAP10 schema, or (b) the Effective EPC's physical state diverges from the lodged EPC. **Lodged Performance** is never overwritten.
|
- **Rebaselining** produces **Effective Performance** by ML re-prediction across SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh, when either (a) the Effective EPC was lodged under a pre-SAP10 schema, or (b) the Effective EPC's physical state diverges from the lodged EPC. **Lodged Performance** is never overwritten.
|
||||||
- **Bill Derivation** derives **fuel split** and **bills** from kWh values (sourced from the EPC's `renewable_heat_incentive` fields for baseline SAP10 properties, or from ML when Rebaselining fires), reading current **Fuel Rates** and **Carbon Factors** from their respective repos.
|
- **Bill Derivation** derives **fuel split** and **bills** from kWh values (sourced from the EPC's `renewable_heat_incentive` fields for baseline SAP10 properties, or from ML when Rebaselining fires), reading current **Fuel Rates** and **Carbon Factors** from their respective repos.
|
||||||
- The **EPC Prediction Service** uses **Comparable Properties** for both gap-filling and producing **EPC Anomaly Flags**.
|
- The **EPC Prediction Service** uses **Comparable Properties** for both gap-filling and producing **EPC Anomaly Flags**.
|
||||||
- A **Scenario** carries one or more ordered **Scenario Phases**. Triggering the model against N Scenarios produces N **Plans** per Property; each Plan carries an ordered list of **Plan Phases** matching the Scenario's shape.
|
- Triggering the model against N **Scenarios** produces N **Plans** per Property. Each **Plan** holds one **Optimised Package** — its selected **Plan Measures** — plus the Property's post-retrofit figures.
|
||||||
- Each **Plan Phase** holds its **Optimised Package**, the ending state snapshot, and any **Rolled-over Options** that flow as candidates into the next Plan Phase. A single-phase Scenario is one Scenario Phase with all measure types allowed; the same machinery handles it.
|
|
||||||
- A **Scenario Snapshot** is pinned at trigger time per (task, scenario) so mid-run edits to the live Scenario do not affect an in-flight modelling job.
|
- A **Scenario Snapshot** is pinned at trigger time per (task, scenario) so mid-run edits to the live Scenario do not affect an in-flight modelling job.
|
||||||
- A **Recommendation** references one **Measure Type** and carries property-specific cost and impact.
|
- A **Recommendation** references one **Measure Type** and carries property-specific cost and impact.
|
||||||
|
- A **Property Valuation** (current market value) is a Baseline attribute and is mostly absent; a **Valuation Uplift** is a Plan output, always a percentage from the **EPC Band** jump and an absolute £ only when a Property Valuation exists.
|
||||||
- **Address Matching** uses a **User Address** and **Postcode** to find a **UPRN** by scoring **UPRN Candidates** from an EPC search. A **Lexirank** of 1 with no **Ambiguous Match** and a **Lexiscore** ≥ the **Score Threshold** produces a **Best Match**.
|
- **Address Matching** uses a **User Address** and **Postcode** to find a **UPRN** by scoring **UPRN Candidates** from an EPC search. A **Lexirank** of 1 with no **Ambiguous Match** and a **Lexiscore** ≥ the **Score Threshold** produces a **Best Match**.
|
||||||
|
|
||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
@ -317,10 +386,6 @@ _Avoid_: API key, auth token, secret
|
||||||
>
|
>
|
||||||
> **Domain expert:** "Those are **Lodged Performance** and **Effective Performance**. **Lodged** is what the gov register says — the EPC was rated under SAP 2012. **Effective** is what we scored against — we ran **Rebaselining** to predict the SAP10-equivalent rating because the methodology changed. Both stay on the **Baseline Performance** so users can see what's on record and what we're modelling against."
|
> **Domain expert:** "Those are **Lodged Performance** and **Effective Performance**. **Lodged** is what the gov register says — the EPC was rated under SAP 2012. **Effective** is what we scored against — we ran **Rebaselining** to predict the SAP10-equivalent rating because the methodology changed. Both stay on the **Baseline Performance** so users can see what's on record and what we're modelling against."
|
||||||
|
|
||||||
> **Dev:** "A landlord wants a 3-year retrofit plan — fabric work this year, heat pump next, solar after. How do we model that?"
|
|
||||||
>
|
|
||||||
> **Domain expert:** "Three **Scenario Phases** in one **Scenario**. Phase 1 allows fabric measures with this year's budget, phase 2 allows the heat pump with next year's budget, phase 3 allows solar. When we model, the **Optimiser Service** runs per phase against the rolling state — the heat pump is scored against the post-insulation property, not the original one. Each **Plan Phase** captures the **Optimised Package** plus the ending SAP / bills, and any **Rolled-over Options** that didn't make this phase's budget become candidates next phase."
|
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
- **"property"** was historically warned against in favour of "dwelling"; that has been inverted. **Property** is now canonical for the Ara domain aggregate. Legacy code still uses "dwelling" in places — treat as alias.
|
- **"property"** was historically warned against in favour of "dwelling"; that has been inverted. **Property** is now canonical for the Ara domain aggregate. Legacy code still uses "dwelling" in places — treat as alias.
|
||||||
|
|
@ -332,5 +397,7 @@ _Avoid_: API key, auth token, secret
|
||||||
- **"user_inputed_address"** in `backend/address2UPRN/main.py` is a misspelling and a synonym for **User Address** — the canonical term. New code should use `user_address`.
|
- **"user_inputed_address"** in `backend/address2UPRN/main.py` is a misspelling and a synonym for **User Address** — the canonical term. New code should use `user_address`.
|
||||||
- **"EPC"** is overloaded as both the document and the rating band letter. Use **EPC** for the document, **EPC Band** for the letter.
|
- **"EPC"** is overloaded as both the document and the rating band letter. Use **EPC** for the document, **EPC Band** for the letter.
|
||||||
- **"re-scoring"** has two meanings in the codebase — **Rebaselining** (re-predicting baseline performance after an EPC change) and post-optimisation measure re-prediction. Prefer **Rebaselining** for the former; for the latter, the **Optimiser Service** step does its own scoring without a special name.
|
- **"re-scoring"** has two meanings in the codebase — **Rebaselining** (re-predicting baseline performance after an EPC change) and post-optimisation measure re-prediction. Prefer **Rebaselining** for the former; for the latter, the **Optimiser Service** step does its own scoring without a special name.
|
||||||
- **"phase"** appears in two unrelated contexts: as cut-over timeline language in the PRD ("Phase 0 — Status quo", "Phase 1 — Forced cut-over") and as a domain concept in **Scenario Phase** / **Plan Phase**. Only the latter is a glossary term; cut-over phases are project-management vocabulary that does not enter code.
|
- **"phase"** (sequencing measures into ordered steps within a Scenario/Plan) was a speculative, prospective-client feature and is **deferred — out of scope** (see ADR-0005). It is *not* a current domain term: a **Scenario** carries one set of measures, a **Plan** one **Optimised Package**. The only live use of "phase" is cut-over timeline language in the PRD ("Phase 0 — Status quo"), which is project-management vocabulary and does not enter code.
|
||||||
|
- **"valuation"** was used for both a Property's current market value and the increase a retrofit produces — resolved into two distinct terms: **Property Valuation** (current value, a Baseline attribute) and **Valuation Uplift** (the plan-conditional, percentage-primary increase). The bare word "valuation" should be qualified to one of these.
|
||||||
- **"stale"** appears in two senses: cache-freshness ("a Repo record is stale and the orchestrator should refetch") — a legitimate operational concept; and as loose shorthand for the EPC's recorded cost fields being unusable. The cost fields are not stale — they are pinned to the inspection-date fuel rates by design. Use "pinned to inspection date" or "pre-SAP10 schema" (whichever applies) instead.
|
- **"stale"** appears in two senses: cache-freshness ("a Repo record is stale and the orchestrator should refetch") — a legitimate operational concept; and as loose shorthand for the EPC's recorded cost fields being unusable. The cost fields are not stale — they are pinned to the inspection-date fuel rates by design. Use "pinned to inspection date" or "pre-SAP10 schema" (whichever applies) instead.
|
||||||
|
- **"restricted_measures"** (legacy `backend/Property.py`) collapsed `in_conservation_area`, `is_listed_building`, and `is_heritage_building` into one boolean that blocked EWI only. Resolved: the rebuild keeps the three flags **distinct**, because they gate different Options — a **conservation area** blocks EWI but allows IWI, whereas **listed/heritage** block both (see **Wall Insulation Eligibility**). Don't reintroduce a single collapsed flag.
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,11 @@ from orchestration.ingestion_orchestrator import (
|
||||||
)
|
)
|
||||||
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
||||||
from orchestration.task_orchestrator import TaskOrchestrator
|
from orchestration.task_orchestrator import TaskOrchestrator
|
||||||
|
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||||
|
FuelRatesStaticFileRepository,
|
||||||
|
)
|
||||||
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
||||||
from repositories.materials.materials_repository import MaterialsRepository
|
|
||||||
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
||||||
from repositories.scenario.scenario_repository import ScenarioRepository
|
|
||||||
from repositories.unit_of_work import UnitOfWork
|
from repositories.unit_of_work import UnitOfWork
|
||||||
from utilities.aws_lambda.subtask_handler import subtask_handler
|
from utilities.aws_lambda.subtask_handler import subtask_handler
|
||||||
|
|
||||||
|
|
@ -69,8 +70,7 @@ def build_first_run_pipeline(
|
||||||
|
|
||||||
Each stage opens its own unit(s) and commits per batch (ADR-0012); the
|
Each stage opens its own unit(s) and commits per batch (ADR-0012); the
|
||||||
handler no longer holds a session. The source clients are passed in because
|
handler no longer holds a session. The source clients are passed in because
|
||||||
their config is not settled — see ``_source_clients_from_env``. Modelling is
|
their config is not settled — see ``_source_clients_from_env``.
|
||||||
stubbed (#1136); its Scenario / Materials ports are seams.
|
|
||||||
"""
|
"""
|
||||||
return AraFirstRunPipeline(
|
return AraFirstRunPipeline(
|
||||||
ingestion=IngestionOrchestrator(
|
ingestion=IngestionOrchestrator(
|
||||||
|
|
@ -85,10 +85,12 @@ def build_first_run_pipeline(
|
||||||
# certs, lodged + divergence-logged at/above 10.2; a raise aborts the
|
# certs, lodged + divergence-logged at/above 10.2; a raise aborts the
|
||||||
# batch (ADR-0013 amendment).
|
# batch (ADR-0013 amendment).
|
||||||
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
|
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
|
||||||
|
fuel_rates=FuelRatesStaticFileRepository(),
|
||||||
),
|
),
|
||||||
modelling=ModellingOrchestrator(
|
modelling=ModellingOrchestrator(
|
||||||
scenario_repo=ScenarioRepository(),
|
unit_of_work=unit_of_work,
|
||||||
materials_repo=MaterialsRepository(),
|
calculator=Sap10Calculator(),
|
||||||
|
fuel_rates=FuelRatesStaticFileRepository(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcMod
|
||||||
from backend.app.db.models.recommendations import (
|
from backend.app.db.models.recommendations import (
|
||||||
Recommendation,
|
Recommendation,
|
||||||
PlanModel,
|
PlanModel,
|
||||||
PlanRecommendations,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -124,20 +123,15 @@ class Outputs:
|
||||||
return plans_data
|
return plans_data
|
||||||
|
|
||||||
def get_recommendations_from_db(self, plan_ids):
|
def get_recommendations_from_db(self, plan_ids):
|
||||||
# Get recommendations through PlanRecommendations for those plans and that are default
|
# Get default recommendations for those plans, linked by recommendation.plan_id
|
||||||
recommendations_query = (
|
recommendations_query = (
|
||||||
self.session.query(Recommendation, PlanModel.scenario_id)
|
self.session.query(Recommendation, PlanModel.scenario_id)
|
||||||
.join(
|
|
||||||
PlanRecommendations,
|
|
||||||
Recommendation.id == PlanRecommendations.recommendation_id,
|
|
||||||
)
|
|
||||||
.join(
|
.join(
|
||||||
PlanModel,
|
PlanModel,
|
||||||
PlanModel.id
|
PlanModel.id == Recommendation.plan_id, # access scenario_id
|
||||||
== PlanRecommendations.plan_id, # Join with Plan to access scenario_id
|
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
PlanRecommendations.plan_id.in_(plan_ids),
|
Recommendation.plan_id.in_(plan_ids),
|
||||||
Recommendation.default == True, # Filtering for default recommendations
|
Recommendation.default == True, # Filtering for default recommendations
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from backend.app.db.models.recommendations import (
|
from backend.app.db.models.recommendations import (
|
||||||
PlanModel,
|
PlanModel,
|
||||||
PlanRecommendations,
|
|
||||||
Recommendation,
|
Recommendation,
|
||||||
ScenarioModel,
|
ScenarioModel,
|
||||||
)
|
)
|
||||||
|
|
@ -26,11 +25,7 @@ def aggregate_portfolio_recommendations(
|
||||||
),
|
),
|
||||||
func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"),
|
func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"),
|
||||||
)
|
)
|
||||||
.join(
|
.join(PlanModel, PlanModel.id == Recommendation.plan_id)
|
||||||
PlanRecommendations,
|
|
||||||
PlanRecommendations.recommendation_id == Recommendation.id,
|
|
||||||
)
|
|
||||||
.join(PlanModel, PlanModel.id == PlanRecommendations.plan_id)
|
|
||||||
.filter(
|
.filter(
|
||||||
PlanModel.portfolio_id == portfolio_id,
|
PlanModel.portfolio_id == portfolio_id,
|
||||||
PlanModel.scenario_id == scenario_id,
|
PlanModel.scenario_id == scenario_id,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ from backend.app.db.models.recommendations import (
|
||||||
PlanModel,
|
PlanModel,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
RecommendationMaterials,
|
RecommendationMaterials,
|
||||||
PlanRecommendations,
|
|
||||||
ScenarioModel,
|
ScenarioModel,
|
||||||
)
|
)
|
||||||
from backend.app.db.models.portfolio import PropertyModel
|
from backend.app.db.models.portfolio import PropertyModel
|
||||||
|
|
@ -236,23 +235,6 @@ def create_recommendation_material(
|
||||||
return new_recommendation_material.id
|
return new_recommendation_material.id
|
||||||
|
|
||||||
|
|
||||||
def create_plan_recommendations(session: Session, plan_id, recommendation_ids):
|
|
||||||
"""
|
|
||||||
This function will create records for the plan_recommendation in the database.
|
|
||||||
:param session: The database session
|
|
||||||
:param plan_id: ID of the plan
|
|
||||||
:param recommendation_ids: list of recommendation IDs
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Prepare a list of dictionaries for bulk insert
|
|
||||||
data = [
|
|
||||||
{"plan_id": plan_id, "recommendation_id": rid} for rid in recommendation_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
# Bulk insert using SQLAlchemy's core API
|
|
||||||
session.execute(insert(PlanRecommendations).values(data))
|
|
||||||
|
|
||||||
|
|
||||||
def upload_recommendations(
|
def upload_recommendations(
|
||||||
session: Session, recommendations_to_upload, property_id, new_plan_id
|
session: Session, recommendations_to_upload, property_id, new_plan_id
|
||||||
):
|
):
|
||||||
|
|
@ -261,6 +243,7 @@ def upload_recommendations(
|
||||||
recommendations_data = [
|
recommendations_data = [
|
||||||
{
|
{
|
||||||
"property_id": property_id,
|
"property_id": property_id,
|
||||||
|
"plan_id": new_plan_id,
|
||||||
"type": rec["type"],
|
"type": rec["type"],
|
||||||
"measure_type": rec["measure_type"],
|
"measure_type": rec["measure_type"],
|
||||||
"description": rec["description"],
|
"description": rec["description"],
|
||||||
|
|
@ -319,10 +302,6 @@ def upload_recommendations(
|
||||||
# flush the changes to get the newly created IDs
|
# flush the changes to get the newly created IDs
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
create_plan_recommendations(
|
|
||||||
session, plan_id=new_plan_id, recommendation_ids=uploaded_recommendation_ids
|
|
||||||
)
|
|
||||||
|
|
||||||
# Commit the transaction
|
# Commit the transaction
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
@ -347,12 +326,12 @@ def bulk_upload_recommendations_and_materials(
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
recommendation_rows = []
|
recommendation_rows = []
|
||||||
parts_by_index = []
|
parts_by_index = []
|
||||||
plan_ids_by_index = []
|
|
||||||
|
|
||||||
for rec in recommendation_payload:
|
for rec in recommendation_payload:
|
||||||
recommendation_rows.append(
|
recommendation_rows.append(
|
||||||
{
|
{
|
||||||
"property_id": rec["property_id"],
|
"property_id": rec["property_id"],
|
||||||
|
"plan_id": rec["plan_id"],
|
||||||
"type": rec["type"],
|
"type": rec["type"],
|
||||||
"measure_type": rec["measure_type"],
|
"measure_type": rec["measure_type"],
|
||||||
"description": rec["description"],
|
"description": rec["description"],
|
||||||
|
|
@ -373,7 +352,6 @@ def bulk_upload_recommendations_and_materials(
|
||||||
)
|
)
|
||||||
|
|
||||||
parts_by_index.append(rec["parts"])
|
parts_by_index.append(rec["parts"])
|
||||||
plan_ids_by_index.append(rec["plan_id"])
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# 2. Insert recommendations and get IDs
|
# 2. Insert recommendations and get IDs
|
||||||
|
|
@ -405,18 +383,8 @@ def bulk_upload_recommendations_and_materials(
|
||||||
if materials_rows:
|
if materials_rows:
|
||||||
session.execute(insert(RecommendationMaterials).values(materials_rows))
|
session.execute(insert(RecommendationMaterials).values(materials_rows))
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# Recommendations carry their plan via recommendation.plan_id (set above) —
|
||||||
# 4. Insert plan ↔ recommendation links
|
# the plan_recommendations m2m is retired (ADR-0017 amendment).
|
||||||
# ---------------------------------------------------------
|
|
||||||
plan_recommendation_rows = [
|
|
||||||
{
|
|
||||||
"plan_id": plan_id,
|
|
||||||
"recommendation_id": recommendation_id,
|
|
||||||
}
|
|
||||||
for plan_id, recommendation_id in zip(plan_ids_by_index, recommendation_ids)
|
|
||||||
]
|
|
||||||
|
|
||||||
session.execute(insert(PlanRecommendations).values(plan_recommendation_rows))
|
|
||||||
|
|
||||||
|
|
||||||
def chunked(iterable, size=100):
|
def chunked(iterable, size=100):
|
||||||
|
|
@ -455,21 +423,6 @@ def delete_property_batch(session: Session, property_ids: list[int]):
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# plan_recommendations (via plan)
|
|
||||||
# --------------------------------------------------
|
|
||||||
session.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
DELETE FROM plan_recommendations pr
|
|
||||||
USING plan p
|
|
||||||
WHERE pr.plan_id = p.id
|
|
||||||
AND p.property_id = ANY(:property_ids)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# funding_package_measures
|
# funding_package_measures
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
|
|
|
||||||
96
backend/app/db/functions/tests/test_portfolio_functions.py
Normal file
96
backend/app/db/functions/tests/test_portfolio_functions.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Characterisation of the FE-facing portfolio aggregation
|
||||||
|
(`aggregate_portfolio_recommendations`): it sums a Scenario's **default**
|
||||||
|
Recommendations and writes the totals onto the Scenario row.
|
||||||
|
|
||||||
|
This pins the `recommendation.plan_id` linkage the m2m retirement introduced
|
||||||
|
(ADR-0017 amendment): the aggregation joins Recommendation → Plan on
|
||||||
|
`recommendation.plan_id`, so only measures carrying the right `plan_id` (and
|
||||||
|
`default = True`) are summed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from backend.app.db.functions.portfolio_functions import (
|
||||||
|
aggregate_portfolio_recommendations,
|
||||||
|
)
|
||||||
|
from backend.app.db.models.recommendations import (
|
||||||
|
PlanModel,
|
||||||
|
Recommendation,
|
||||||
|
ScenarioModel,
|
||||||
|
)
|
||||||
|
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||||
|
|
||||||
|
|
||||||
|
def _rec(
|
||||||
|
*, plan_id: int, default: bool, cost: float, kwh: float, gbp: float, co2: float
|
||||||
|
) -> Recommendation:
|
||||||
|
return Recommendation(
|
||||||
|
property_id=10,
|
||||||
|
plan_id=plan_id,
|
||||||
|
type="cavity_wall_insulation",
|
||||||
|
measure_type="cavity_wall_insulation",
|
||||||
|
description="Cavity wall insulation",
|
||||||
|
estimated_cost=cost,
|
||||||
|
kwh_savings=kwh,
|
||||||
|
energy_cost_savings=gbp,
|
||||||
|
co2_equivalent_savings=co2,
|
||||||
|
total_work_hours=4.0,
|
||||||
|
default=default,
|
||||||
|
already_installed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregation_sums_default_measures_linked_by_plan_id(
|
||||||
|
db_session: Session,
|
||||||
|
) -> None:
|
||||||
|
# Arrange — one Scenario + Plan, two default measures (summed) plus a
|
||||||
|
# non-default one (excluded), all linked by recommendation.plan_id.
|
||||||
|
db_session.add(
|
||||||
|
ScenarioModel(
|
||||||
|
id=7,
|
||||||
|
portfolio_id=1,
|
||||||
|
goal=PortfolioGoal.INCREASING_EPC,
|
||||||
|
goal_value="C",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.add(
|
||||||
|
PlanModel(id=100, portfolio_id=1, property_id=10, scenario_id=7, is_default=True)
|
||||||
|
)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
_rec(plan_id=100, default=True, cost=1000.0, kwh=500.0, gbp=120.0, co2=0.5),
|
||||||
|
_rec(plan_id=100, default=True, cost=500.0, kwh=300.0, gbp=80.0, co2=0.2),
|
||||||
|
# excluded: not default
|
||||||
|
_rec(plan_id=100, default=False, cost=9.0, kwh=9.0, gbp=9.0, co2=9.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
aggregate_portfolio_recommendations(
|
||||||
|
db_session,
|
||||||
|
portfolio_id=1,
|
||||||
|
scenario_id=7,
|
||||||
|
total_valuation_increase=2500.0,
|
||||||
|
labour_days=3.0,
|
||||||
|
aggregated_data={},
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Assert — the default measures' sums land on the Scenario row
|
||||||
|
scenario = db_session.query(ScenarioModel).filter_by(id=7).one()
|
||||||
|
assert scenario.cost is not None
|
||||||
|
assert abs(scenario.cost - 1500.0) <= 1e-9 # 1000 + 500
|
||||||
|
assert scenario.energy_savings is not None
|
||||||
|
assert abs(scenario.energy_savings - 800.0) <= 1e-9 # Σ kwh_savings
|
||||||
|
assert scenario.energy_cost_savings is not None
|
||||||
|
assert abs(scenario.energy_cost_savings - 200.0) <= 1e-9 # 120 + 80
|
||||||
|
assert scenario.co2_equivalent_savings is not None
|
||||||
|
assert abs(scenario.co2_equivalent_savings - 0.7) <= 1e-9 # 0.5 + 0.2
|
||||||
|
assert scenario.total_work_hours is not None
|
||||||
|
assert abs(scenario.total_work_hours - 8.0) <= 1e-9 # 4 + 4
|
||||||
|
assert scenario.property_valuation_increase == 2500.0
|
||||||
|
assert scenario.labour_days == 3.0
|
||||||
|
|
@ -18,6 +18,11 @@ from backend.app.db.models.users import UserModel # noqa
|
||||||
from backend.app.db.models.materials import MaterialType
|
from backend.app.db.models.materials import MaterialType
|
||||||
from datatypes.epc.domain.epc import Epc
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
|
||||||
|
# PortfolioGoal moved to the domain layer (ADR-0017 amendment). Re-exported here
|
||||||
|
# so the existing `from backend.app.db.models.portfolio import PortfolioGoal`
|
||||||
|
# callers keep working.
|
||||||
|
from domain.modelling.portfolio_goal import PortfolioGoal # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
class PortfolioStatus(enum.Enum):
|
class PortfolioStatus(enum.Enum):
|
||||||
SCOPING = "scoping"
|
SCOPING = "scoping"
|
||||||
|
|
@ -32,14 +37,6 @@ class PortfolioStatus(enum.Enum):
|
||||||
NEEDS_REVIEW = "needs review"
|
NEEDS_REVIEW = "needs review"
|
||||||
|
|
||||||
|
|
||||||
class PortfolioGoal(enum.Enum): # TODO: Move to domain?
|
|
||||||
VALUATION_IMPROVEMENT = "Valuation Improvement"
|
|
||||||
INCREASING_EPC = "Increasing EPC"
|
|
||||||
REDUCING_CO2_EMISSIONS = "Reducing CO2 emissions"
|
|
||||||
ENERGY_SAVINGS = "Energy Savings"
|
|
||||||
NONE = "None"
|
|
||||||
|
|
||||||
|
|
||||||
class Portfolio(Base):
|
class Portfolio(Base):
|
||||||
__tablename__ = "portfolio"
|
__tablename__ = "portfolio"
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
|
||||||
|
|
@ -1,289 +1,41 @@
|
||||||
import enum
|
"""Re-export shim (ADR-0017 amendment).
|
||||||
from typing import Iterable, List, NamedTuple, Optional, Type
|
|
||||||
from sqlalchemy import (
|
The Modelling-stage persistence models — `plan`, `recommendation`,
|
||||||
Column,
|
`recommendation_materials`, `scenario`, `installed_measure` — moved to
|
||||||
BigInteger,
|
`infrastructure/postgres/modelling/` as single SQLModel definitions (the
|
||||||
String,
|
`epc_property` pattern). This module re-exports them under their legacy names so
|
||||||
Float,
|
the dying `backend/` callers keep working; new code imports from
|
||||||
Boolean,
|
`infrastructure.postgres.modelling` directly. The `plan_recommendations` m2m is
|
||||||
TIMESTAMP,
|
retired — measures link to their Plan via `recommendation.plan_id`.
|
||||||
ForeignKey,
|
"""
|
||||||
Enum,
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from infrastructure.postgres.modelling import (
|
||||||
|
InstalledMeasureModel,
|
||||||
|
PlanModel,
|
||||||
|
PlanType,
|
||||||
|
RecommendationMaterialModel,
|
||||||
|
RecommendationModel,
|
||||||
|
ScenarioModel,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from backend.app.db.base import Base
|
# Legacy names → the single SQLModel definitions now in
|
||||||
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
# `infrastructure/postgres/modelling/`.
|
||||||
from backend.app.db.models.materials import Material
|
Recommendation = RecommendationModel
|
||||||
from datatypes.enums import QuantityUnits
|
RecommendationMaterials = RecommendationMaterialModel
|
||||||
from datatypes.epc.domain.epc import Epc
|
PlanTypeEnum = PlanType
|
||||||
|
InstalledMeasure = InstalledMeasureModel
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
|
"PlanModel",
|
||||||
return [e.value for e in enum_cls]
|
"ScenarioModel",
|
||||||
|
"Recommendation",
|
||||||
|
"RecommendationMaterials",
|
||||||
class Recommendation(Base):
|
"InstalledMeasure",
|
||||||
__tablename__ = "recommendation"
|
"PlanTypeEnum",
|
||||||
|
"PlanPersistence",
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
]
|
||||||
property_id = Column(BigInteger, ForeignKey(PropertyModel.id), nullable=False)
|
|
||||||
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
|
||||||
type = Column(String, nullable=False)
|
|
||||||
measure_type = Column(String)
|
|
||||||
description = Column(String, nullable=False)
|
|
||||||
estimated_cost = Column(Float)
|
|
||||||
default = Column(Boolean, nullable=False)
|
|
||||||
starting_u_value = Column(Float)
|
|
||||||
new_u_value = Column(Float)
|
|
||||||
sap_points = Column(Float)
|
|
||||||
heat_demand = Column(Float)
|
|
||||||
kwh_savings = Column(Float)
|
|
||||||
co2_equivalent_savings = Column(Float)
|
|
||||||
energy_savings = Column(Float)
|
|
||||||
energy_cost_savings = Column(Float)
|
|
||||||
property_valuation_increase = Column(Float)
|
|
||||||
rental_yield_increase = Column(Float)
|
|
||||||
total_work_hours = Column(Float)
|
|
||||||
labour_days = Column(Float)
|
|
||||||
already_installed = Column(Boolean, nullable=False, default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationMaterials(Base):
|
|
||||||
__tablename__ = "recommendation_materials"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
|
|
||||||
recommendation_id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey("recommendation.id"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
material_id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
ForeignKey(Material.id),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
TIMESTAMP,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
depth: Mapped[float] = mapped_column(
|
|
||||||
Float,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
quantity: Mapped[float] = mapped_column(
|
|
||||||
Float,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
quantity_unit: Mapped[QuantityUnits] = mapped_column(
|
|
||||||
Enum(QuantityUnits, values_callable=lambda x: [e.value for e in x]),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
estimated_cost: Mapped[float] = mapped_column(
|
|
||||||
Float,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlanTypeEnum(enum.Enum): # TODO: move this to domain?
|
|
||||||
SOLAR_ECO4 = "solar_eco4"
|
|
||||||
SOLAR_HHRSH_ECO4 = "solar_hhrsh_eco4"
|
|
||||||
EMPTY_CAVITY_ECO = "empty_cavity_eco"
|
|
||||||
PARTIAL_CAVITY_ECO = "partial_cavity_eco"
|
|
||||||
EXTRACTION_ECO = "extraction_eco"
|
|
||||||
|
|
||||||
|
|
||||||
class PlanModel(Base):
|
|
||||||
__tablename__ = "plan"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
|
|
||||||
name: Mapped[Optional[str]] = mapped_column(String, nullable=True, default="")
|
|
||||||
|
|
||||||
portfolio_id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger, ForeignKey(Portfolio.id), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
property_id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger, ForeignKey(PropertyModel.id), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
scenario_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
BigInteger, ForeignKey("scenario.id")
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column( # type: ignore
|
|
||||||
TIMESTAMP, nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
|
||||||
|
|
||||||
valuation_increase_lower_bound: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
valuation_increase_upper_bound: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
valuation_increase_average: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
|
|
||||||
plan_type: Mapped[Optional[PlanTypeEnum]] = mapped_column(
|
|
||||||
Enum(
|
|
||||||
PlanTypeEnum,
|
|
||||||
name="plan_type",
|
|
||||||
values_callable=lambda e: [m.value for m in e],
|
|
||||||
create_type=False,
|
|
||||||
),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
post_sap_points: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
post_epc_rating: Mapped[Optional[Epc]] = mapped_column(Enum(Epc))
|
|
||||||
post_co2_emissions: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
co2_savings: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
post_energy_bill: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
energy_bill_savings: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
post_energy_consumption: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
energy_consumption_savings: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
valuation_post_retrofit: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
valuation_increase: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
|
|
||||||
# Financial metrics, excluding funding
|
|
||||||
cost_of_works: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
contingency_cost: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
|
|
||||||
|
|
||||||
class PlanRecommendations(Base):
|
|
||||||
__tablename__ = "plan_recommendations"
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
plan_id = Column(BigInteger, ForeignKey("plan.id"), nullable=False)
|
|
||||||
recommendation_id = Column(
|
|
||||||
BigInteger, ForeignKey("recommendation.id"), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScenarioModel(Base):
|
|
||||||
__tablename__ = "scenario"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
TIMESTAMP, nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
budget: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
portfolio_id: Mapped[int] = mapped_column(
|
|
||||||
BigInteger, ForeignKey(Portfolio.id), nullable=False
|
|
||||||
)
|
|
||||||
housing_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
goal: Mapped[PortfolioGoal] = mapped_column(
|
|
||||||
Enum(PortfolioGoal, values_callable=portfolio_goal_values, name="goal"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
goal_value: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
trigger_file_path: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
already_installed_file_path: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
patches_file_path: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
non_invasive_recommendations_file_path: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String
|
|
||||||
)
|
|
||||||
exclusions: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
multi_plan: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
|
|
||||||
# Add in the fields we need, which were previously sitting at the portfolio level
|
|
||||||
cost: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
contingency: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
funding: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
total_work_hours: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
energy_savings: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
co2_equivalent_savings: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
energy_cost_savings: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
epc_breakdown_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
epc_breakdown_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
number_of_properties: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
||||||
n_units_to_retrofit: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
||||||
co2_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
co2_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
energy_bill_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
energy_bill_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
energy_consumption_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String
|
|
||||||
)
|
|
||||||
energy_consumption_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String
|
|
||||||
)
|
|
||||||
valuation_improvement_per_unit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
cost_per_unit: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
cost_per_co2_saved: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
cost_per_sap_point: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
valuation_return_on_investment: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
property_valuation_increase: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
labour_days: Mapped[Optional[float]] = mapped_column(Float)
|
|
||||||
|
|
||||||
|
|
||||||
class MeasureType(enum.Enum):
|
|
||||||
air_source_heat_pump = "air_source_heat_pump"
|
|
||||||
boiler_upgrade = "boiler_upgrade"
|
|
||||||
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
|
|
||||||
secondary_heating = "secondary_heating"
|
|
||||||
|
|
||||||
roomstat_programmer_trvs = "roomstat_programmer_trvs"
|
|
||||||
time_temperature_zone_control = "time_temperature_zone_control"
|
|
||||||
cylinder_thermostat = "cylinder_thermostat"
|
|
||||||
|
|
||||||
cavity_wall_insulation = "cavity_wall_insulation"
|
|
||||||
extension_cavity_wall_insulation = "extension_cavity_wall_insulation"
|
|
||||||
external_wall_insulation = "external_wall_insulation"
|
|
||||||
internal_wall_insulation = "internal_wall_insulation"
|
|
||||||
loft_insulation = "loft_insulation"
|
|
||||||
flat_roof_insulation = "flat_roof_insulation"
|
|
||||||
room_roof_insulation = "room_roof_insulation"
|
|
||||||
solid_floor_insulation = "solid_floor_insulation"
|
|
||||||
suspended_floor_insulation = "suspended_floor_insulation"
|
|
||||||
|
|
||||||
double_glazing = "double_glazing"
|
|
||||||
secondary_glazing = "secondary_glazing"
|
|
||||||
draught_proofing = "draught_proofing"
|
|
||||||
|
|
||||||
mechanical_ventilation = "mechanical_ventilation"
|
|
||||||
low_energy_lighting = "low_energy_lighting"
|
|
||||||
solar_pv = "solar_pv"
|
|
||||||
hot_water_tank_insulation = "hot_water_tank_insulation"
|
|
||||||
sealing_open_fireplace = "sealing_open_fireplace"
|
|
||||||
|
|
||||||
|
|
||||||
class InstalledMeasure(Base):
|
|
||||||
__tablename__ = "installed_measure"
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
|
||||||
uprn = Column(BigInteger, nullable=False)
|
|
||||||
measure_type = Column(
|
|
||||||
Enum(
|
|
||||||
MeasureType,
|
|
||||||
name="measure_type",
|
|
||||||
values_callable=lambda e: [m.value for m in e],
|
|
||||||
create_type=False, # <-- critical
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
installed_at = Column(TIMESTAMP)
|
|
||||||
sap_points = Column(Float)
|
|
||||||
carbon_savings = Column(Float)
|
|
||||||
kwh_savings = Column(Float)
|
|
||||||
bill_savings = Column(Float)
|
|
||||||
heat_demand_savings = Column(Float)
|
|
||||||
source = Column(String)
|
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
|
||||||
|
|
||||||
|
|
||||||
def enum_values(e: Iterable[PlanTypeEnum]) -> list[str]:
|
|
||||||
return [m.value for m in e]
|
|
||||||
|
|
||||||
|
|
||||||
class PlanPersistence(NamedTuple):
|
class PlanPersistence(NamedTuple):
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from collections import defaultdict
|
||||||
from backend.app.db.models.recommendations import (
|
from backend.app.db.models.recommendations import (
|
||||||
Recommendation,
|
Recommendation,
|
||||||
PlanModel,
|
PlanModel,
|
||||||
PlanRecommendations,
|
|
||||||
RecommendationMaterials,
|
RecommendationMaterials,
|
||||||
)
|
)
|
||||||
from backend.app.db.models.portfolio import (
|
from backend.app.db.models.portfolio import (
|
||||||
|
|
@ -157,13 +156,9 @@ class DbMethods:
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Recommendation, PlanModel.scenario_id, PlanModel.name)
|
select(Recommendation, PlanModel.scenario_id, PlanModel.name)
|
||||||
.join(
|
.join(PlanModel, PlanModel.id == Recommendation.plan_id)
|
||||||
PlanRecommendations,
|
|
||||||
Recommendation.id == PlanRecommendations.recommendation_id,
|
|
||||||
)
|
|
||||||
.join(PlanModel, PlanModel.id == PlanRecommendations.plan_id)
|
|
||||||
.where(
|
.where(
|
||||||
PlanRecommendations.plan_id.in_(plan_ids),
|
Recommendation.plan_id.in_(plan_ids),
|
||||||
Recommendation.default.is_(True),
|
Recommendation.default.is_(True),
|
||||||
Recommendation.already_installed.is_(False),
|
Recommendation.already_installed.is_(False),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,23 @@ def engine(postgresql):
|
||||||
|
|
||||||
engine = create_engine(connection_string)
|
engine = create_engine(connection_string)
|
||||||
|
|
||||||
# Create tables once per test session
|
# Create tables once per test session. SQLModel first: the Modelling tables
|
||||||
Base.metadata.create_all(engine)
|
# (`plan` / `recommendation` / …) are SQLModel definitions, and Base tables
|
||||||
|
# FK them (`funding_package` → `plan`), so they must exist before Base's
|
||||||
|
# create_all runs (ADR-0017 amendment — single model per table).
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
# Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all
|
# Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all
|
||||||
# tests have completed
|
# tests have completed
|
||||||
yield engine
|
yield engine
|
||||||
|
|
||||||
# Clean-up after entire test session
|
# The `postgresql` fixture is function-scoped — a fresh, throwaway database
|
||||||
SQLModel.metadata.drop_all(engine)
|
# per test — so an explicit drop_all is redundant. We skip it: the `epc`
|
||||||
Base.metadata.drop_all(engine)
|
# Postgres enum type is now shared across both metadatas (Base `portfolio`
|
||||||
|
# tables and the SQLModel `plan`), and a two-phase metadata drop cannot drop
|
||||||
|
# a cross-metadata type cleanly (ADR-0017 amendment). Disposing the engine
|
||||||
|
# and letting the fixture discard the database is correct and conflict-free.
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ from backend.app.db.models.portfolio import (
|
||||||
from backend.app.db.models.recommendations import (
|
from backend.app.db.models.recommendations import (
|
||||||
PlanModel,
|
PlanModel,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
PlanRecommendations,
|
|
||||||
RecommendationMaterials,
|
RecommendationMaterials,
|
||||||
)
|
)
|
||||||
from backend.app.db.models.materials import Material
|
from backend.app.db.models.materials import Material
|
||||||
|
|
@ -171,13 +170,17 @@ def test_default_export_integration(db_session):
|
||||||
# 5) Insert recommendation
|
# 5) Insert recommendation
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
|
||||||
|
rec_to_plan = dict(
|
||||||
|
zip(plan_recs_df["recommendation_id"], plan_recs_df["plan_id"])
|
||||||
|
)
|
||||||
recs = [
|
recs = [
|
||||||
Recommendation(
|
Recommendation(
|
||||||
|
plan_id=rec_to_plan.get(row["id"]),
|
||||||
**{
|
**{
|
||||||
col: row[col]
|
col: row[col]
|
||||||
for col in Recommendation.__table__.columns.keys()
|
for col in Recommendation.__table__.columns.keys()
|
||||||
if col in row
|
if col in row and col != "plan_id"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
for _, row in recommendations_df.iterrows()
|
for _, row in recommendations_df.iterrows()
|
||||||
]
|
]
|
||||||
|
|
@ -185,18 +188,9 @@ def test_default_export_integration(db_session):
|
||||||
db_session.bulk_save_objects(recs)
|
db_session.bulk_save_objects(recs)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
|
|
||||||
# ----------------------------------------
|
# Recommendations are linked to their plan by recommendation.plan_id (set
|
||||||
# 6) Insert PlanRecommendations
|
# above from plan_recs_df) — the plan_recommendations m2m is retired
|
||||||
# ----------------------------------------
|
# (ADR-0017 amendment).
|
||||||
links = [
|
|
||||||
PlanRecommendations(
|
|
||||||
plan_id=row.plan_id,
|
|
||||||
recommendation_id=row.recommendation_id,
|
|
||||||
)
|
|
||||||
for row in plan_recs_df.itertuples(index=False)
|
|
||||||
]
|
|
||||||
|
|
||||||
db_session.bulk_save_objects(links)
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
logger.info("Inserted all data in %.2f seconds", time.perf_counter() - db_load_t0)
|
logger.info("Inserted all data in %.2f seconds", time.perf_counter() - db_load_t0)
|
||||||
|
|
||||||
|
|
@ -607,9 +601,11 @@ def test_solar_with_battery_example(db_session):
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
recommendations_df.loc[0, "measure_type"] = "solar_pv"
|
recommendations_df.loc[0, "measure_type"] = "solar_pv"
|
||||||
|
|
||||||
|
rec_to_plan = dict(zip(plan_recs_df.recommendation_id, plan_recs_df.plan_id))
|
||||||
for row in recommendations_df.itertuples(index=False):
|
for row in recommendations_df.itertuples(index=False):
|
||||||
rec = Recommendation(
|
rec = Recommendation(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
|
plan_id=rec_to_plan.get(row.id),
|
||||||
property_id=row.property_id,
|
property_id=row.property_id,
|
||||||
measure_type=row.measure_type,
|
measure_type=row.measure_type,
|
||||||
estimated_cost=row.estimated_cost,
|
estimated_cost=row.estimated_cost,
|
||||||
|
|
@ -622,17 +618,8 @@ def test_solar_with_battery_example(db_session):
|
||||||
db_session.add(rec)
|
db_session.add(rec)
|
||||||
db_session.flush()
|
db_session.flush()
|
||||||
|
|
||||||
# -------------------------------------------------
|
# Plan ↔ Recommendation link is recommendation.plan_id (set above) — the
|
||||||
# Link Plan -> Recommendation
|
# plan_recommendations m2m is retired (ADR-0017 amendment).
|
||||||
# -------------------------------------------------
|
|
||||||
for row in plan_recs_df.itertuples(index=False):
|
|
||||||
db_session.add(
|
|
||||||
PlanRecommendations(
|
|
||||||
plan_id=row.plan_id,
|
|
||||||
recommendation_id=row.recommendation_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db_session.flush()
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Insert Material (includes_battery=True)
|
# Insert Material (includes_battery=True)
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,18 @@ class Epc(Enum):
|
||||||
if score >= 21:
|
if score >= 21:
|
||||||
return cls.F
|
return cls.F
|
||||||
return cls.G
|
return cls.G
|
||||||
|
|
||||||
|
def sap_lower_bound(self) -> int:
|
||||||
|
"""The minimum SAP rating in this band — the inverse of
|
||||||
|
`from_sap_score` (A → 92, B → 81, C → 69, D → 55, E → 39, F → 21,
|
||||||
|
G → 1). Used as an optimisation target, e.g. "reach band C" → 69."""
|
||||||
|
bounds: dict["Epc", int] = {
|
||||||
|
Epc.A: 92,
|
||||||
|
Epc.B: 81,
|
||||||
|
Epc.C: 69,
|
||||||
|
Epc.D: 55,
|
||||||
|
Epc.E: 39,
|
||||||
|
Epc.F: 21,
|
||||||
|
Epc.G: 1,
|
||||||
|
}
|
||||||
|
return bounds[self]
|
||||||
|
|
|
||||||
|
|
@ -601,6 +601,16 @@ class RenewableHeatIncentive:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EpcPropertyData:
|
class EpcPropertyData:
|
||||||
|
"""The cert aggregate every downstream stage reads.
|
||||||
|
|
||||||
|
Currently **loosely typed** (`Union[int, str]` fuel/emitter fields, raw
|
||||||
|
`Optional[int]` codes, `str` fallbacks) and filled by three mappers — EPC
|
||||||
|
API, Elmhurst site notes, pashub — with different conventions, so
|
||||||
|
normalization happens *downstream* (e.g. fuel resolution in the calculator's
|
||||||
|
`cert_to_inputs`). The direction is to push normalization to the mappers and
|
||||||
|
make this a strict type — see docs/adr/0015-mappers-own-cert-normalization.md.
|
||||||
|
"""
|
||||||
|
|
||||||
# General
|
# General
|
||||||
dwelling_type: str # TODO: make enum?
|
dwelling_type: str # TODO: make enum?
|
||||||
inspection_date: date
|
inspection_date: date
|
||||||
|
|
|
||||||
|
|
@ -4188,6 +4188,21 @@ def _elmhurst_bp_roof_type(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _elmhurst_bp_has_room_in_roof(
|
||||||
|
w: ElmhurstWindow, survey: ElmhurstSiteNotes,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether the building part carrying window `w` has a room-in-roof — a
|
||||||
|
sloping-ceiling structure that can host an inclined rooflight. Gates the
|
||||||
|
high-U roof-window backstop in `_is_elmhurst_roof_window`."""
|
||||||
|
bp = w.building_part
|
||||||
|
if bp in ("Main", "Main Property"):
|
||||||
|
return survey.room_in_roof is not None
|
||||||
|
for ext in survey.extensions:
|
||||||
|
if ext.name == bp:
|
||||||
|
return ext.room_in_roof is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_elmhurst_roof_window(
|
def _is_elmhurst_roof_window(
|
||||||
w: ElmhurstWindow, survey: ElmhurstSiteNotes,
|
w: ElmhurstWindow, survey: ElmhurstSiteNotes,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -4216,14 +4231,19 @@ def _is_elmhurst_roof_window(
|
||||||
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS
|
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
# U > 3.0 backstop — Elmhurst routes high-U "Double pre 2002" units
|
# U > 3.0 backstop, gated on the BP having a room-in-roof. Elmhurst routes a
|
||||||
# through the worksheet's (27a) Roof Windows line regardless of the
|
# high-U "Double pre 2002" unit through the worksheet's (27a) Roof Windows
|
||||||
# lodged "External wall" location, which is a §11 lodging artifact
|
# line regardless of its lodged "External wall" location — but ONLY where the
|
||||||
# (cert 000516's W6 is lodged "External wall" yet scored via (27a)).
|
# BP has a room-in-roof whose sloping ceiling can host an inclined rooflight.
|
||||||
# The location string is therefore NOT a reliable vertical signal:
|
# cert 000516's W6 (Double pre 2002 / Wood / 6 mm / U 3.10) is lodged
|
||||||
# all six of 000516's §11 rows read "External wall", and only U
|
# "External wall" yet scored via (27a): its Main BP has a "Room in roof type
|
||||||
# separates the five vertical (2.8) panes from the one rooflight
|
# 1". cert 001431 lodges a BYTE-IDENTICAL window row (same glazing, frame,
|
||||||
# (3.1). Matching the worksheet means trusting U here, not location.
|
# gap, U) that must stay a vertical `sap_window` — and its Main BP has NO
|
||||||
|
# room-in-roof. The §11 row alone can't separate them; the room-in-roof
|
||||||
|
# context is the discriminator (the location string is a §11 lodging
|
||||||
|
# artifact, so it is not a reliable vertical signal either).
|
||||||
|
if not _elmhurst_bp_has_room_in_roof(w, survey):
|
||||||
|
return False
|
||||||
return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD
|
return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4983,6 +5003,16 @@ _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = {
|
||||||
"Jacket": 2,
|
"Jacket": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Elmhurst §15.1 "Insulated" labels for an uninsulated cylinder. These are
|
||||||
|
# lodged (not absent), but an uninsulated cylinder has no insulation *type* —
|
||||||
|
# per the no-misleading-insulation convention it maps to
|
||||||
|
# `cylinder_insulation_type = None` rather than naming a material. The lodged
|
||||||
|
# §15.1 "Insulation Thickness" (0 mm) carries the storage-loss signal the
|
||||||
|
# cascade's SAP 10.2 Table 2 dispatch needs.
|
||||||
|
_ELMHURST_CYLINDER_NO_INSULATION_LABELS: frozenset[str] = frozenset({
|
||||||
|
"No Insulation",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# Elmhurst §15.0 "Water Heating Fuel Type" labels that route to solid-
|
# Elmhurst §15.0 "Water Heating Fuel Type" labels that route to solid-
|
||||||
# fuel Table 32 codes (Anthracite, House coal, Wood logs/pellets, etc.).
|
# fuel Table 32 codes (Anthracite, House coal, Wood logs/pellets, etc.).
|
||||||
|
|
@ -5093,6 +5123,8 @@ def _elmhurst_cylinder_insulation_code(
|
||||||
mapping dict — see `_elmhurst_cylinder_size_code` rationale."""
|
mapping dict — see `_elmhurst_cylinder_size_code` rationale."""
|
||||||
if not cylinder_present or cylinder_insulation_label is None:
|
if not cylinder_present or cylinder_insulation_label is None:
|
||||||
return None
|
return None
|
||||||
|
if cylinder_insulation_label in _ELMHURST_CYLINDER_NO_INSULATION_LABELS:
|
||||||
|
return None
|
||||||
code = _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10.get(cylinder_insulation_label)
|
code = _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10.get(cylinder_insulation_label)
|
||||||
if code is None:
|
if code is None:
|
||||||
raise UnmappedElmhurstLabel("cylinder_insulation", cylinder_insulation_label)
|
raise UnmappedElmhurstLabel("cylinder_insulation", cylinder_insulation_label)
|
||||||
|
|
|
||||||
84
docs/HANDOVER_API_FETCH_AND_REPORT.md
Normal file
84
docs/HANDOVER_API_FETCH_AND_REPORT.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# HANDOVER — EPC API fetch + property inspection report
|
||||||
|
|
||||||
|
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `7be4d83f`.
|
||||||
|
**Prior phase (DONE this session):** DB-less offline Modelling harness + `material_id` + Valuation Uplift + fuel-rate proxies. See "What already exists" below.
|
||||||
|
|
||||||
|
## The goal (this phase)
|
||||||
|
|
||||||
|
Fetch real EPCs **from the live EPC API**, run them through the offline Modelling harness, and **save a per-property report** covering three things:
|
||||||
|
|
||||||
|
1. **Calculator error** — for each property, compare the **lodged SAP** on the API response against **our calculator's** SAP; flag where `|lodged − calculated| > 0.5`.
|
||||||
|
2. **Plans + costings** — the optimised Plan: measures, cost of works + contingency, SAP/band transition, bill & CO₂ savings, valuation uplift.
|
||||||
|
3. **Individual recommended measures + the property attributes that triggered them** — for each fired measure, show the EPC field(s) and value(s) that caused the generator to recommend it (the "why").
|
||||||
|
|
||||||
|
## FIRST: read these
|
||||||
|
|
||||||
|
1. This file (the API client + the three report ingredients are mapped below — load-bearing).
|
||||||
|
2. `docs/HANDOVER_MODELLING.md` + auto-memory `project_modelling_stage_state` — full Modelling state.
|
||||||
|
3. `CONTEXT.md` — glossary, esp. **Calculated SAP10 Performance**, **Validation Cohort**, **Lodged Performance** (the calculator-divergence concept behind report #1), and Plan / Plan Measure / Recommendation.
|
||||||
|
4. ADR-0010/0013 (calculator shadow-validation), ADR-0014 (bills), ADR-0016 (scoring), ADR-0018 (valuation).
|
||||||
|
|
||||||
|
## What already exists (build ON this, don't rebuild)
|
||||||
|
|
||||||
|
- **Offline harness (no DB, no network for modelling):**
|
||||||
|
- `harness/console.py::run_modelling(epc, goal_band="C", current_market_value=None, print_table=True) -> Plan` — runs ONLY the Modelling stage (no Ingestion/Baseline), so it needs no lodged-performance/RHI and works on any calculator-scorable EPC. (`run_one` is the full pipeline; use `run_modelling` for inspection.)
|
||||||
|
- `harness/cohort.py::run_cohort(paths) -> list[CertResult]` + `format_cohort_summary` + `format_cohort_csv`. `CertResult` carries the `Plan` (+ flat `measures`/`baseline_sap`/`post_sap`). Errors are captured per-cert, never abort the sweep.
|
||||||
|
- `scripts/run_modelling_cohort.py` — CLI over a directory of API JSONs (prints tables + summary, writes `modelling_cohort.csv`, gitignored).
|
||||||
|
- `harness/plan_table.py::format_plan_table(plan)` — the sense-check table.
|
||||||
|
- `harness/sample_catalogue.json` — prices all 5 generator measure types (cavity/loft/solid-floor/suspended-floor/ventilation).
|
||||||
|
- In-memory `FakeUnitOfWork` etc. in `tests/orchestration/fakes.py`.
|
||||||
|
- **Proven offline:** the 57 golden API certs (`tests/domain/sap10_calculator/rdsap/fixtures/golden/*.json`, schema 21.0.1, API-shaped) run **57/57, 0 errors** after the fuel-rate proxies landed.
|
||||||
|
|
||||||
|
## Report ingredient #1 — EPC API client (the user's "can't find the file")
|
||||||
|
|
||||||
|
- **Client:** `infrastructure/epc_client/epc_client_service.py::EpcClientService`.
|
||||||
|
- Base URL `https://api.get-energy-performance-data.communities.gov.uk`; **Bearer token** in the constructor.
|
||||||
|
- **Env var:** the bulk-fetch script reads `OPEN_EPC_API_TOKEN` (`scripts/fetch_cohort2_api_jsons.py:49`); CONTEXT.md's glossary names the New-EPC-API token `EPC_AUTH_TOKEN`. **Confirm which is set in `backend/.env` before relying on either.**
|
||||||
|
- Methods: `get_by_uprn(uprn) -> Optional[EpcPropertyData]`, `get_by_certificate_number(cert) -> EpcPropertyData`, `search_by_postcode(postcode) -> list[EpcSearchResult]`. Internally hits `/api/certificate` + `/api/domestic/search`, unwraps `data`, maps via `EpcPropertyDataMapper.from_api_response`. Handles 404/429 + retry.
|
||||||
|
- **Working example to copy:** `scripts/fetch_cohort2_api_jsons.py` bulk-fetches raw API JSON and writes one file per cert (it calls the client's certificate fetch via a retry wrapper). Mirror it to fetch the user's target set (by UPRN list / postcode) into a dump dir, then feed that dir to `run_cohort`.
|
||||||
|
- **NOTE:** the API returns the cert as raw JSON identical to the committed golden fixtures, so the **same `from_api_response` path** the harness already uses applies. The raw JSON (not just the mapped EPC) is what report #1 needs — keep both (raw for the lodged SAP, mapped for the calculator + generators).
|
||||||
|
|
||||||
|
## Report ingredient #2 — lodged vs calculated SAP (calculator error > 0.5)
|
||||||
|
|
||||||
|
- **Calculated:** `domain/sap10_calculator/calculator.py::Sap10Calculator().calculate(epc) -> SapResult`; use `SapResult.sap_score_continuous` (un-rounded) — `sap_score` is the rounded int.
|
||||||
|
- **Lodged:** `EpcPropertyData.energy_rating_current` (mapped from the API response; SAP points 0–100). (Confirm it is populated for live certs — some samples leave it blank; the API response itself carries `current-energy-efficiency`.)
|
||||||
|
- **Divergence:** `error = epc.energy_rating_current − calculate(epc).sap_score_continuous`; flag `abs(error) > 0.5`. This is exactly the **Validation Cohort / shadow-validation** idea (ADR-0010/0013) — the calculator runs alongside the lodged figure and logs divergence.
|
||||||
|
- **Existing scaffolding:** `domain/sap10_calculator/validation/parity_report.py` — `ParityCase(certificate_number, actual_sap, predicted_sap, is_typical)` + `build_parity_report(...) -> ParityReport` (MAE / RMSE / bias / worst-N). The 0.5 is a **design target, not a hardcoded filter** — you implement the per-property flag. Consider reusing `ParityCase`/`build_parity_report` for the cohort-level stats in the report.
|
||||||
|
- **Gotcha:** the calculator can **raise** on an un-mapped cert (UnmappedSapCode / UnmappedApiCode) — catch per-cert (like `run_cohort` does) so one bad cert doesn't abort the report; record the raise as the "error" for that property.
|
||||||
|
|
||||||
|
## Report ingredient #3 — measures + the attributes that triggered them
|
||||||
|
|
||||||
|
Each generator reads `epc.sap_building_parts` filtered to `BuildingPartIdentifier.MAIN` (ventilation is whole-dwelling). The exact trigger fields (so the report can say "fired because X = Y"):
|
||||||
|
|
||||||
|
| Measure | Trigger fields (on `SapBuildingPart` unless noted) | Fires when |
|
||||||
|
|---|---|---|
|
||||||
|
| **cavity_wall_insulation** | `wall_construction`, `wall_insulation_type` | `wall_construction == 4` (cavity) AND `wall_insulation_type == 4` (as-built/uninsulated) — `wall_recommendation.py:42` |
|
||||||
|
| **loft_insulation** | `roof_insulation_thickness` | `== 0` (uninsulated loft) — `roof_recommendation.py:41` |
|
||||||
|
| **{suspended,solid}_floor_insulation** | `floor_insulation_thickness`, `floor_construction_type` | thickness None/blank/"0" AND construction contains "suspended"/"solid" — `floor_recommendation.py:64` |
|
||||||
|
| **mechanical_ventilation** | `epc.sap_ventilation.mechanical_ventilation_kind` (whole-dwelling) | `sap_ventilation is None` OR `mechanical_ventilation_kind is None` (not already mechanically ventilated); only injected when a wall is selected (Measure Dependency) — `ventilation_recommendation.py:41` |
|
||||||
|
|
||||||
|
To produce report #3, run each generator on the EPC (or read the Plan's `PlanMeasure.measure_type`) and, for each fired measure, surface the above field values from `epc.sap_building_parts[MAIN]` (and `sap_ventilation`). The generators currently only return the Recommendation — you may add a small "explain" helper that returns the trigger fields, or read them directly off the EPC in the report builder.
|
||||||
|
|
||||||
|
## Suggested shape (grill the owner first if unsure)
|
||||||
|
|
||||||
|
Extend `harness/cohort.py` / a new `harness/report.py`:
|
||||||
|
- Enrich `CertResult` with `lodged_sap`, `calculated_sap`, `sap_error`, `sap_error_exceeds_0_5` (report #1), and a per-measure `[(measure_type, {trigger_field: value})]` list (report #3). Plan/costings (report #2) already on `CertResult.plan`.
|
||||||
|
- A `format_report` (Markdown and/or CSV) with the three sections; the script writes it to a file (gitignore the artifact).
|
||||||
|
- A live-fetch entrypoint: a script that takes a UPRN list / postcode, fetches via `EpcClientService` into a dump dir (raw JSON), then runs the report. Keep the raw JSON so #1 has the lodged figure.
|
||||||
|
|
||||||
|
## Critical gotchas (carry these)
|
||||||
|
|
||||||
|
- **Worktree import trap** — run via `pytest` / `python -m` from the worktree root, NOT `python /tmp/foo.py` (imports `/workspaces/model`).
|
||||||
|
- **`mip`/CBC broken on aarch64; `moto` not installed** — `--ignore tests/orchestration/test_postcode_splitter_orchestrator.py` + `tests/repositories/unstandardised_address/` when sweeping. Run tests `python -m pytest <path> -q` (NOT `-p no:cov`).
|
||||||
|
- **Don't edit `heat_transmission.py`** (another agent owns it). Per-element U-values still aren't surfaced in `SapResult` (deferred — a request to that owner).
|
||||||
|
- **Live API calls hit the network + rate limits (429)** — the client retries; for a big fetch, throttle and cache raw JSON to disk (mirror `fetch_cohort2_api_jsons.py`), then run the report offline against the cached dump.
|
||||||
|
- **Fuel proxies:** COAL + HEAT_NETWORK are documented **estimates** (see `repositories/fuel_rates/data/fuel_rates_2026_q2.json` `_note`/`_gaps`); coal/heat-network bills are indicative.
|
||||||
|
- **Many certs yield 0 measures** — they're already efficient; that's correct, not a bug. Report #1 (calculator error) is independent of whether measures fire.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
Stay on `feature/bill-derivation`; one TDD slice = one commit; conventional-commit ending `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`; AAA test headers; assert `abs(x - y) <= tol` (not `pytest.approx`); pyright strict zero errors; annotate call-return locals.
|
||||||
|
|
||||||
|
## How to start
|
||||||
|
|
||||||
|
Confirm the API token env var + that you can fetch one cert (`EpcClientService(...).get_by_uprn(<uprn>)`). Then decide with the owner: report format (Markdown report + CSV?), the property set (UPRN list / postcode / the user's dump), and whether the calculator-error section is per-property flags + a cohort ParityReport. Then TDD the report builder on the committed golden certs (offline) before pointing it at the live API.
|
||||||
149
docs/HANDOVER_MODELLING.md
Normal file
149
docs/HANDOVER_MODELLING.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# HANDOVER — Modelling stage rebuild
|
||||||
|
|
||||||
|
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `6f0dcc04`.
|
||||||
|
**PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.**
|
||||||
|
|
||||||
|
## Issue status
|
||||||
|
|
||||||
|
| Issue | What | State |
|
||||||
|
|---|---|---|
|
||||||
|
| #1153 | Overlay Applicator + `EpcSimulation` | ✅ closed |
|
||||||
|
| #1154 | Package Scorer | ✅ closed — Elmhurst cascade pin (`4c0a907a`) |
|
||||||
|
| #1155 | wall Recommendation Generator | ✅ closed; cascade-pinned |
|
||||||
|
| #1156 | score Options + attribution | ✅ closed |
|
||||||
|
| #1157 | persist a Plan via `ModellingOrchestrator` | ✅ **closed this session** (`772cdd4f`→`c7e2aa37`) |
|
||||||
|
| #1158 | roof (loft) generator | ✅ closed — 300 mm + cascade pin |
|
||||||
|
| #1159 | floor generator | ✅ closed — overlay insulation-type field + pins |
|
||||||
|
| #1160 | Optimiser (knapsack + greedy repair) | ✅ **closed this session** (`77983cae`→`34d4748a`) |
|
||||||
|
| #1161 | Measure Dependency (ventilation) | ✅ **closed this session** (`7c59e919`→`0fec0699`) |
|
||||||
|
|
||||||
|
## What this session did
|
||||||
|
|
||||||
|
1. **Cascade pins for #1154/#1158/#1159** — `tests/domain/modelling/test_elmhurst_cascade_pins.py`. Parse Elmhurst before/after recommendation Summaries via the extractor chain (NOT `parse_site_notes_pdf`), apply the generator's overlay, score, assert delta 0 vs the after-cert. Found+fixed: loft 270→**300** mm; suspended floor needs the overlay to also set `floor_insulation_type_str='Retro-fitted'`.
|
||||||
|
2. **`ProductJsonRepository`** (`cc0bb8f9`) — file-backed catalogue behind the `ProductRepository` port.
|
||||||
|
3. **#1157 — persist a Plan.** Design review (`/grill-with-docs`) + 5 TDD slices. See "Design decisions" below.
|
||||||
|
4. **#1160 — the Optimiser.** 4 TDD slices. See "Design decisions".
|
||||||
|
|
||||||
|
## Design decisions locked this session (READ THESE)
|
||||||
|
|
||||||
|
- **Multi-phase is DEFERRED** (speculative prospective-client ask). **ADR-0005 rewritten to "Deferred".** No `plan_phase` table, no `phase` column. `CONTEXT.md` no longer has Scenario Phase / Plan Phase / Rolled-over Options. Everything is **single-phase**. Future: a migration adds `plan_phase` + back-fills live plans as 1-phase.
|
||||||
|
- **Plan Measure** is the new term (in `CONTEXT.md`): the persisted selected Option + its role-3 attributed impact + cost. **Recommendation** stays the *candidate* (never persisted; no stored impact).
|
||||||
|
- **Reuse the LIVE tables** (`plan`, `recommendation`) — they exist in the live product (`backend/app/db/models/recommendations.py`, SQLAlchemy `Base`) and the FE reads them. The rebuild writes the **same physical tables via SQLModel mirrors** (`infrastructure/postgres/plan_table.py`) — the established pattern (`task_table.py`→`tasks`, `product_table.py`→`material`). **ADR-0017** records this.
|
||||||
|
- Added **`recommendation.plan_id`** (FK→plan, ON DELETE CASCADE); retire the `plan_recommendations` m2m for new writes. FE-owned Drizzle migration: `docs/migrations/recommendation-plan-id.md`.
|
||||||
|
- Tracer persists **SAP + CO₂ (tonnes = calc kg ÷ 1000) + cost + derived `post_epc_rating`**. Energy/bill columns deferred. Idempotent replace per (property_id, scenario_id).
|
||||||
|
- **Optimiser = exact pure-Python multiple-choice knapsack**, NOT `mip`. Recycles `GainOptimiser`/`CostOptimiser`'s *formulation* (≤1/group, maximise gain s.t. budget) but not the dependency — **`mip`'s CBC backend does not load on this aarch64 container** (`NameError: cbclib`), so the legacy solver can't run/be tested here. ADR-0016's MILP is only a warm-start signal, so exact small-scale enumeration is ample. Re-score + greedy-repair toward the goal's SAP target gives the truth.
|
||||||
|
|
||||||
|
## `domain/modelling/` layout (grouped `84ec6da0`)
|
||||||
|
|
||||||
|
Behaviour lives in subpackages; shared value-object vocabulary stays flat at the top (imported everywhere): `recommendation.py` (Recommendation / MeasureOption / Cost), `plan.py`, `scenario.py`, `product.py`, `contingencies.py`, `simulation.py` (EpcSimulation overlay).
|
||||||
|
- `generators/` — `wall_recommendation` / `roof_recommendation` / `floor_recommendation`.
|
||||||
|
- `scoring/` — `overlay_applicator` (apply_simulations), `package_scorer` (role 2), `scoring` (role-1 `independent_option_impacts` + role-3 `marginal_impacts`). Note the path is `domain.modelling.scoring.scoring` for the role-1/3 module.
|
||||||
|
- `optimisation/` — `optimiser`, `measure_dependency`.
|
||||||
|
|
||||||
|
## What's built (all in `domain/modelling/`, `infrastructure/postgres/`, `repositories/`, `orchestration/`)
|
||||||
|
|
||||||
|
- Generators (`generators/`): `recommend_cavity_wall` / `recommend_loft_insulation` (300 mm) / `recommend_floor_insulation` (sets `floor_insulation_type_str`).
|
||||||
|
- `simulation.py` overlay + `scoring/overlay_applicator.apply_simulations` (generic field-fold) + `scoring/package_scorer.PackageScorer.score` (role 2) + `scoring/scoring.py` (`marginal_impacts` role 3, `independent_option_impacts` role 1).
|
||||||
|
- `scenario.py` `Scenario(id, goal, goal_value, budget, is_default)`; `plan.py` `Plan` + `PlanMeasure` (derives cost_of_works/contingency_cost/co2_savings/post_epc_rating).
|
||||||
|
- `optimisation/optimiser.py` — `optimise(groups, budget)` (exact knapsack) + `optimise_package(...)` (re-score + greedy repair, `Scorer` Protocol, `OptimisedPackage`).
|
||||||
|
- `infrastructure/postgres/`: `scenario_table.ScenarioRow`, `plan_table.{PlanRow,RecommendationRow}` (mirrors of live tables; `from_domain`).
|
||||||
|
- `repositories/`: `scenario/`, `plan/`, `product/` (Postgres + Json) — all on the `UnitOfWork` (`uow.scenario`/`uow.product`/`uow.plan`).
|
||||||
|
- `ModellingOrchestrator.run(property_ids, scenario_ids, portfolio_id)` — one UoW, commit once; generate (wall/roof/floor) → role-1 score → `optimise_package` → role-3 attribute → persist. Wired into `AraFirstRunPipeline` + `handler.py`.
|
||||||
|
- `datatypes/epc/domain/epc.py::Epc.sap_lower_bound()` (band → min SAP, target for INCREASING_EPC).
|
||||||
|
|
||||||
|
## Gotchas (will bite a fresh agent)
|
||||||
|
|
||||||
|
- **`mip` / CBC is broken on aarch64** here — never build runnable code on `mip`. The legacy `recommendations/optimiser/` tests only "pass" because they avoid constructing a `mip.Model`.
|
||||||
|
- **`moto` is not installed** — `tests/orchestration/test_postcode_splitter_orchestrator.py` and `tests/repositories/unstandardised_address/` fail at *collection*. Pre-existing, unrelated; `--ignore` them when sweeping.
|
||||||
|
- **Run tests:** `python -m pytest <path> -q` (do NOT pass `-p no:cov`). Ephemeral Postgres via the `db_engine` fixture builds **only `SQLModel.metadata`** — legacy `Base` tables are absent in tests, which is why mirrors work.
|
||||||
|
- **Worktree import trap:** `python /tmp/foo.py` imports `/workspaces/model`, not this worktree. Use `pytest` (rootdir handles it) or a `python -c` from the worktree root.
|
||||||
|
- **Driving Modelling in an integration test:** the calculator fixtures (`_elmhurst_worksheet_000490.build_epc()`) lack lodged recorded-performance fields, so the **Baseline stage can't run on them**. Drive `ModellingOrchestrator` directly off a repo-seeded EPC (`EpcPostgresRepository(session).save(epc, property_id, portfolio_id)`) — see `test_modelling_optimises_and_persists_a_multi_measure_plan`. The sample API EPC (`_lodged_epc()`) does go through the full pipeline.
|
||||||
|
- **`PortfolioGoal.INCREASING_EPC` value is `"Increasing EPC"`** (with a space) — the orchestrator compares `scenario.goal == "Increasing EPC"`.
|
||||||
|
- A generator calls `products.get(...)` during candidate generation, so the integration test must **seed a `material` row for every measure type that fires** (e.g. the sample EPC's uninsulated solid floor needs `solid_floor_insulation`).
|
||||||
|
- **Don't edit the SAP calculator's `heat_transmission.py`** (another agent owns it).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
Commit per TDD slice; conventional-commit message ending `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`; stay on `feature/bill-derivation`. Tests use literal `# Arrange / # Act / # Assert`; assert with `abs(x - y) <= tol` (not `pytest.approx`); pyright strict, zero errors; annotate call-return locals. Cascade pins target the worksheet at delta 0.
|
||||||
|
|
||||||
|
## #1161 — Measure Dependency (ventilation), as built (4 TDD slices, all green)
|
||||||
|
|
||||||
|
Forks resolved with the user (AskUserQuestion): **guard now** (skip when already MEV/MVHR), **persist as a Plan Measure** (cost + real negative marginal), **forced but its cost counts toward spend** (mandatory-when-triggered, never budget-gated; repair sees less headroom).
|
||||||
|
|
||||||
|
1. **`7c59e919`** — Simulation Overlay grows a dwelling-level segment: `VentilationOverlay` (all-optional partial of `SapVentilation`, field `mechanical_ventilation_kind`) + `EpcSimulation.ventilation`; `apply_simulations` folds it onto `sap_ventilation` (creating one if the baseline lodged none). Until now the overlay was building-part only — ventilation is whole-dwelling.
|
||||||
|
2. **`6b11c902`** — generic injection in the optimiser: `MeasureDependency(triggers: frozenset[str], required: ScoredOption)` lives in `optimisation/optimiser.py` (its input contract). `optimise_package(..., dependencies=())` injects any dependency whose triggers ∩ selected-measure-types, before every re-score (initial **and** each repair). `_inject` dedups by required measure-type. Forced (injected even over budget) but its cost is in `_package_cost`, so repair headroom shrinks. `_best_repair_candidate` folds in any dependency a candidate newly triggers, so its marginal SAP and incremental cost are truthful; affordability gates on whole-package cost vs budget. Returned `selected` includes the injected deps. Optimiser stays domain-agnostic — no ventilation import.
|
||||||
|
3. **`1bf5b410`** — `domain/modelling/optimisation/measure_dependency.py`: `MEASURES_NEEDING_VENTILATION` (cavity/internal/external wall, cf. legacy `assumptions.measures_needing_ventilation`) + `ventilation_dependency(epc, products)` → MEV Option (`mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"`, decentralised MEV = legacy "mechanical, extract only"), priced at 2 fully-loaded units. Returns **None** when `sap_ventilation.mechanical_ventilation_kind` is already set (= legacy `has_ventilation` — confirmed against `backend/Property.py:1236`). Note: builder fetches the Product up-front, so the catalogue needs a `mechanical_ventilation` row for **every** not-yet-ventilated dwelling, even if no wall is ultimately selected.
|
||||||
|
4. **`0fec0699`** — orchestrator wiring: `_measure_dependencies` builds the (≤1) dependency; `_BEST_PRACTICE_ORDER` gains `"mechanical_ventilation"` between loft and floors (role-3 cascade walls→roof→**vent**→floor); ventilation persists as a Plan Measure with its real negative marginal + cost. Added `mechanical_ventilation: 0.26` contingency (legacy `Costs.CONTINGENCIES`). On 000490 the real calculator scores MEV at **−1.275 SAP**.
|
||||||
|
|
||||||
|
**Post-#1161 refactor (`631df921`→`02afc04c`):** production split from selection-semantics. Detection + pricing moved into a proper generator `generators/ventilation_recommendation.py::recommend_ventilation(epc, products) -> Optional[Recommendation]` (same shape as wall/roof/floor; guard returns None when already mechanically ventilated). `optimisation/measure_dependency.py` now owns only the trigger set + the forced-edge wrapping: `ventilation_dependency` delegates to the generator and wraps the Recommendation (cheapest Option) into the `MeasureDependency`. The orchestrator's `_measure_dependencies` call is unchanged. **Key asymmetry:** `recommend_ventilation` lives in `generators/` but is **not** in `_candidate_recommendations`' generator tuple — it's consumed only by the dependency path, never the free pool. This is the natural home for the multi-option future (MEV-c / MVHR) and the FE swap-in front.
|
||||||
|
|
||||||
|
Gotchas for the next agent: the ventilation Product/contingency must exist for any not-yet-ventilated dwelling (the generator fetches the Product at build time, not inject-time); the stub scorer in `test_optimiser.py` indexes `building_parts[MAIN]`, so vent-only overlays need the separate `_VentStubScorer`.
|
||||||
|
|
||||||
|
## Optimiser objective realigned to least-cost-to-target (`5620f49f`→`641c1bd7`)
|
||||||
|
|
||||||
|
A `/grill-with-docs` pass found the rebuild had the **wrong optimiser objective**: it maximised SAP gain within budget (target as a repair floor), whereas the legacy `StrategicOptimiser.solve()` Case 1 (the intended behaviour) is **min-cost subject to gain ≥ target and cost ≤ budget, fall back to max-gain only if the target is unreachable**. ADR-0016 was amended (it had specified the wrong objective). 4 slices, all green:
|
||||||
|
|
||||||
|
- **`05a4f5f8`** — `optimise_min_cost(groups, budget, target_gain, dependencies=())`: exact-enumeration sibling to `optimise`; cheapest package reaching `target_gain` within budget (ties → higher gain), `None` if unreachable.
|
||||||
|
- **`2bf42d04`** — `optimise_package` rewired: target present → min-cost warm-start → inject → re-score → repair toward target; if warm-start infeasible or repaired package still short on the true score → `_max_gain_package` fallback. No target → max-gain (unchanged). Stops at the target, no overshoot into a higher band, surplus budget unspent.
|
||||||
|
- **`af501fce`** — **ventilation-aware selection**: `_with_role1_signals` scores each dependency's true (negative) role-1 impact (was a `0.0` placeholder); `_augmented_cost_gain` folds the triggered dependency into every candidate's cost+gain in both selectors. Stops min-cost picking a wall whose mandatory ventilation (−1 to −5 SAP) it can't justify, or whose £900 a wall-free package would avoid.
|
||||||
|
- **`641c1bd7`** — orchestrator needed **no change** (already threads budget/target/deps); added an end-to-end pin (band-D property + goal D = already met → Plan with no measures).
|
||||||
|
|
||||||
|
Decisions locked (in the ADR amendment): target predicate `sap_continuous ≥ band_floor` (e.g. ≥ 69 for C — conservative, no legacy `allow_slack`); **budget is a hard envelope** — a wall whose ventilation would bust the budget is **dropped, not forced over** (reverses the earlier "forced regardless of budget" call; presence still guaranteed for any *selected* wall); warm-start-on-signal + re-score + repair kept (not exhaustive re-score) for scalability; "recommend slightly more than land short" is satisfied by the conservative floor + repair, not by spending budget for headroom.
|
||||||
|
|
||||||
|
## Bill-Derivation: plan-level post-retrofit bills (`75ba5dd7`→`198122d1`)
|
||||||
|
|
||||||
|
A `/grill-with-docs` pass designed the Modelling Bill-Derivation slice (ADR-0014 amended). Plan-level columns done across 4 slices; per-measure is the next slice.
|
||||||
|
|
||||||
|
- **`ced6287b`** — relocated `Bill` / `EnergyBreakdown` / `BillDerivation` / `sap_fuel` (+ tests) from `domain/property_baseline/` to a neutral **`domain/billing/`** (cross-stage concern; both Baseline and Modelling consume it). Pure move, ~10 files.
|
||||||
|
- **`2bbc401f`** — `Score` gains `sap_result: Optional[SapResult]`, populated by `PackageScorer`. Lets Modelling bill the scored end-state reusing a `SapResult` the optimiser/orchestrator already computed — **no second `calculate`**. Optimiser ignores it (stays `Score`-only; stubs unaffected).
|
||||||
|
- **`26de28aa`** — `Plan` carries optional `baseline_bill` / `post_bill` and derives `post_energy_bill` / `energy_bill_savings` / `post_energy_consumption` / `energy_consumption_savings` (None until billed → NULL).
|
||||||
|
- **`198122d1`** — `ModellingOrchestrator` gains a constructor-injected `FuelRatesRepository` (mirrors Baseline — `get_current()` once, one `BillDerivation` per batch); `_plan_for` bills the baseline (`scorer.score(epc, [])`) and post-package (`package.score`) `SapResult`s at the same snapshot, savings = baseline − post. `PlanRow` mirror + `from_domain` persist the four columns (they already exist on the live `plan` table — no FE migration). Pipeline/handler wired.
|
||||||
|
|
||||||
|
Key properties: **fuel-switch is handled for free** — we bill the fully-overlaid post-package `SapResult`, so a future oil→ASHP measure prices at the new fuel via `sap_code_to_fuel` (no per-measure fuel bookkeeping). Baseline and post are priced at one `FuelRates` snapshot, so the delta is rate-consistent. Carries ADR-0014's **appliances+cooking-stubbed-at-0** limitation (shared with Baseline, so savings stay consistent).
|
||||||
|
|
||||||
|
## Bill-Derivation: per-measure bill savings (`e79ffabf`→`b976c3ab`) — DONE
|
||||||
|
|
||||||
|
Filled `recommendation.kwh_savings` + `energy_cost_savings` via the **telescoping bill cascade** over the role-3 best-practice order. 3 slices, all green + pyright-strict-clean:
|
||||||
|
|
||||||
|
- **`e79ffabf`** — enabling refactor: pulled the cumulative-prefix scoring out of `marginal_impacts` into a reusable `scoring.cascade_scores(scorer, baseline, overlays) -> list[Score]` (index 0 = baseline, one `calculate` per prefix) + a pure `marginals_from_scores`. Each Score carries its `SapResult`, so the bill cascade re-bills the same prefixes the role-3 attribution scores — **no extra `calculate`**. `marginal_impacts` now delegates (behaviour unchanged).
|
||||||
|
- **`7e79c30a`** — `PlanMeasure` grows optional `kwh_savings` (delivered energy) + `energy_cost_savings` (£), signed so positive = saving, `None` until billed. `RecommendationRow` declares the live `recommendation.kwh_savings`/`energy_cost_savings` columns + maps them (None→NULL). Vestigial `recommendation.energy_savings` stays **undeclared** (legacy = 0). No FE migration (columns already live).
|
||||||
|
- **`b976c3ab`** — `_plan_for` scores baseline + every prefix once via `cascade_scores`, bills each at one Fuel Rates snapshot, and takes **consecutive Bill deltas** as each measure's marginal delivered-kWh + £ saving. The Plan's `baseline_bill`/`post_bill` are now the **same cascade endpoints** (`bills[0]`/`bills[-1]`), so per-measure savings telescope **exactly** to the headline savings — pinned on the real calculator (Σ per-measure == plan totals, abs ≤ 1e-6). Ventilation's saving is **negative** and still telescopes. Added `Bill.total_consumption_kwh` (shared by Plan + orchestrator); dropped the redundant standalone baseline `calculate`.
|
||||||
|
|
||||||
|
Key property: `MeasureImpact.energy_savings_kwh_per_yr` is *primary* energy and does **not** feed `kwh_savings` — `kwh_savings` is **delivered** energy from the Bill section kWh. Carries ADR-0014's appliances+cooking-stubbed-at-0 limitation.
|
||||||
|
|
||||||
|
## Retire `plan_recommendations` + consolidate models (`b76d0f81`→`6f0dcc04`) — DONE
|
||||||
|
|
||||||
|
Designed in `/grill-with-docs` + `/grill-me`. The live `plan`/`recommendation` tables are read **directly by the Drizzle FE**, so this was a two-repo expand/contract. **FE-visibility goal met:** Plans and their measures now link solely by `recommendation.plan_id`; the m2m is gone. 9 slices, all green + pyright-strict-clean, and the rebuild + legacy suites are now **co-runnable** (the consolidation fixed a pre-existing dual-definition collision).
|
||||||
|
|
||||||
|
- **`b76d0f81`** — migration spec ([docs/migrations/recommendation-plan-id.md](migrations/recommendation-plan-id.md): add `plan_id` → backfill → dual-write → cut reads → drop; backfill-before-reads + dual-write are the load-bearing rules since the FE can't deploy atomically) + ADR-0017 amendment.
|
||||||
|
- **`c1c7b06f`** — consolidate `plan`/`recommendation`/`recommendation_materials` into **`infrastructure/postgres/modelling/`** as single SQLModel defs (absorbing the partial `PlanRow`/`RecommendationRow` mirrors, full column parity + `plan_id`). `backend/app/db/models/recommendations.py` → re-export shim. Export conftest: create SQLModel-first / skip the redundant `drop_all` (the `epc` enum type is now shared across both metadatas).
|
||||||
|
- **`27fcc5b1`** — legacy writers set `recommendation.plan_id` (dual-write).
|
||||||
|
- **`af5dbe32`** — cut all three readers (`portfolio_functions`, `Outputs`, `export/property_scenarios`) onto `plan_id`.
|
||||||
|
- **`b97d0688`** — drop the m2m: writes, `delete_property_batch` cleanup, the `PlanRecommendationRow` model, the `test_export` fixtures.
|
||||||
|
- **`01c2c391`** — rename the cluster `…Row` → **`…Model`** (matches the `epc_property` precedent + the legacy names `backend/` already imports, so the shim's plan re-export is literal). The non-cluster `…Row` tables stay until their live legacy `…Model` counterparts retire (renaming now would re-create dual-definition collisions).
|
||||||
|
- **`2fbd7147`** — move `PortfolioGoal` to **`domain/modelling/portfolio_goal.py`** (domain vocab; infra→domain is the normal direction); `portfolio.py` keeps a re-export.
|
||||||
|
- **`c18968ba`** — consolidate `scenario` + `installed_measure` (full-parity `ScenarioModel`/`InstalledMeasureModel` + `MeasureType`). **`ScenarioModel.goal` is the `PortfolioGoal` enum** (legacy planning branches on it); the repo's `to_domain` maps it to its value, so `Scenario.goal` is now the value `"Increasing EPC"` consistent with the orchestrator — fixing the latent name-vs-value bug the old `str` column masked.
|
||||||
|
- **`6f0dcc04`** — characterization test for the FE aggregation `aggregate_portfolio_recommendations` (was untested), pinning the `plan_id` join.
|
||||||
|
|
||||||
|
**Gotchas for the next agent:** the modelling SQLModel classes are `…Model` and live in `infrastructure/postgres/modelling/` (NOT the old flat `plan_table.py`/`scenario_table.py` — deleted); `backend/app/db/models/recommendations.py` is now a pure shim. Out-of-cluster columns are plain ints (no FK) per the mirror convention. **`PortfolioGoal` lives in `domain/modelling/`** now. The `etl/`+`sfr/` reporting scripts still reference the m2m and are **deferred** (out of scope). The live DB changes (add `plan_id`, backfill, drop `plan_recommendations`) are the **FE-owned Drizzle** migrations in the migration doc — this branch is the backend end-state.
|
||||||
|
|
||||||
|
## NEXT PHASE — depth + scale e2e (handover for a grilling session)
|
||||||
|
|
||||||
|
The owner's goal: run a large dump of **SAP 10.2 EPCs (1,000–10,000)** through Modelling and inspect the recommendations — a large-scale integration test — plus **manual testing via a Python console**. Measure *coverage* (heating/solar/glazing/…) is explicitly **deferred** ("we'll flesh this out"). This phase is **depth + scale on the existing 4 fabric measures** (cavity wall / loft / floor / ventilation):
|
||||||
|
|
||||||
|
1. **Close the persisted-field gaps** so a persisted Plan matches the engine's richness for the measures we *do* model: `recommendation_materials` (BOM — depth/quantity/unit/cost; rebuild `Cost` is a single total today, no per-material breakdown), per-measure U-values (`starting_u_value`/`new_u_value`), `total_work_hours`/`labour_days`. Source of truth: the rebuild `ProductRepository` (`repositories/product/`) + legacy `materials_functions.py` / `recommendations_functions.upload_recommendations` (writes `rec["parts"]`).
|
||||||
|
2. **Financial uplift modelling** — valuation columns (`plan.valuation_*`, `recommendation.property_valuation_increase`/`rental_yield_increase`) are **greenfield in the rebuild** (no domain concept yet). Legacy logic: `backend/Property.py`, `backend/Funding.py`, `backend/app/db/functions/funding_functions.py`, `portfolio_functions.py`. Needs a domain design (likely a `/grill-with-docs` pass).
|
||||||
|
3. **Large-scale e2e harness** — template is `tests/orchestration/test_ara_first_run_pipeline_integration.py::test_modelling_optimises_and_persists_a_multi_measure_plan` (seeds an EPC via `EpcPostgresRepository` + `MaterialRow`s + a `ScenarioModel`, runs `ModellingOrchestrator` directly — the Baseline stage can't run on calculator fixtures). For the dump: parse each EPC via `EpcPropertyDataMapper.from_api_response` / `from_rdsap_schema_21_0_x` (see `datatypes/epc/domain/mapper.py`), seed, run, inspect. EPC samples live under `backend/epc_api/json_samples/`.
|
||||||
|
4. **Python-console manual run** — instantiate `ModellingOrchestrator` against a real DB and inspect Plans/Recommendations. Mind the **worktree import trap** (run from the worktree root, not `/tmp`).
|
||||||
|
|
||||||
|
A self-contained handover prompt for the next agent is in **`docs/HANDOVER_NEXT_PHASE_PROMPT.md`**.
|
||||||
|
|
||||||
|
## What's left
|
||||||
|
|
||||||
|
**Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); persist **unselected alternatives** (`default=False` rows linked via `plan_id`) for the swap-in UX — open ADR-0016 question: what impact figure they carry; promote `ProductRepository` to the DB+file composite; non-EPC goal objectives (Energy Savings, Reducing CO2) in the optimiser. Possible extension of the ventilation trigger set to roof insulation (now a one-line data edit in `MEASURES_NEEDING_VENTILATION`); and making the dependency builder lazy (thunk) so the Product is only fetched when a trigger is actually selected.
|
||||||
|
|
||||||
|
## Key references
|
||||||
|
|
||||||
|
- ADRs: **0005** (multi-phase deferred), **0011/0012** (orchestrators + UoW), **0016** (three scoring roles + warm-start/re-score/repair), **0017** (Plan persistence — evolve live tables).
|
||||||
|
- `CONTEXT.md`: Plan, Plan Measure, Recommendation, Measure Option, Optimised Package, Scenario, Measure Dependency.
|
||||||
|
- Auto-memory `project_modelling_stage_state` has the running state.
|
||||||
53
docs/HANDOVER_NEXT_PHASE_PROMPT.md
Normal file
53
docs/HANDOVER_NEXT_PHASE_PROMPT.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Handover prompt — Modelling: depth + scale e2e (next phase)
|
||||||
|
|
||||||
|
> Paste this to a fresh agent. The owner will then run a **grilling session** to lock the design before any code.
|
||||||
|
|
||||||
|
You are continuing the **Modelling stage rebuild** (3rd pipeline stage) on branch `feature/bill-derivation`, worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`, HEAD at the tip of that branch.
|
||||||
|
|
||||||
|
## FIRST: read these, in order
|
||||||
|
1. `docs/HANDOVER_MODELLING.md` — full state, locked decisions, gotchas (read in full; the "NEXT PHASE" section frames this work).
|
||||||
|
2. Auto-memory `project_modelling_stage_state` — running state.
|
||||||
|
3. ADRs **0011/0012** (orchestrators + UoW), **0014** (billing), **0016** (three scoring roles), **0017 + its amendment** (Plan persistence; the `…Model` SQLModel cluster in `infrastructure/postgres/modelling/`; `plan_recommendations` retired).
|
||||||
|
4. `CONTEXT.md` — domain glossary (Plan, Plan Measure, Recommendation, Measure Option, Scenario, …).
|
||||||
|
|
||||||
|
## Where things stand (what works)
|
||||||
|
- The `ModellingOrchestrator` **runs end-to-end and persists to a real Postgres**: generate fabric candidates → role-1 score → optimise (least-cost-to-target) → role-3 attribute → bill → persist a **Plan** + its **Plan Measures** (`recommendation` rows linked by `recommendation.plan_id`; the m2m is gone). Persists SAP, CO₂ (tonnes), cost + contingency, post-band, **plan + per-measure energy/bill/kWh savings**.
|
||||||
|
- Proven by `tests/orchestration/test_ara_first_run_pipeline_integration.py::test_modelling_optimises_and_persists_a_multi_measure_plan` (drives the orchestrator directly off a repo-seeded EPC — **the e2e template**).
|
||||||
|
- All green: rebuild suite + legacy export/functions; pyright strict clean.
|
||||||
|
- **4 fabric generators only**: cavity wall, loft, floor, ventilation (`domain/modelling/generators/`).
|
||||||
|
|
||||||
|
## The owner's goal (this phase)
|
||||||
|
> "I have a big dump of SAP 10.2 EPCs. I want to run a bunch (1,000–10,000) through this and inspect the recommendations — a reasonably large-scale integration test. I also want to run the code via a Python console for manual testing. Once these measures work e2e, we flesh out the others."
|
||||||
|
|
||||||
|
**Measure coverage is explicitly deferred.** This phase is **depth + scale on the existing 4 fabric measures**:
|
||||||
|
|
||||||
|
1. **Close the persisted-field gaps** (make a persisted Plan as rich as the engine for the measures we model):
|
||||||
|
- `recommendation_materials` (BOM: depth / quantity / quantity_unit / estimated_cost). Today the rebuild's `Cost` (`domain/modelling/recommendation.py`) is a single fully-loaded `total` + `contingency_rate` — **no per-material breakdown**. Source: rebuild `ProductRepository` (`repositories/product/`), legacy `backend/app/db/functions/materials_functions.py` + `recommendations_functions.upload_recommendations` (writes `rec["parts"]`).
|
||||||
|
- Per-measure U-values (`starting_u_value` / `new_u_value`), `total_work_hours`, `labour_days`. These columns already exist on `RecommendationModel` (NULL today).
|
||||||
|
2. **Financial uplift modelling** (valuations) — **greenfield in the rebuild** (no domain concept exists; only `plan.valuation_*` / `recommendation.property_valuation_increase` columns sit NULL). Legacy logic: `backend/Property.py`, `backend/Funding.py`, `backend/app/db/functions/funding_functions.py`, `portfolio_functions.py`. This wants its own design.
|
||||||
|
3. **Large-scale e2e harness** — run the EPC dump through Modelling and inspect recommendations:
|
||||||
|
- Parse each EPC via `EpcPropertyDataMapper` (`datatypes/epc/domain/mapper.py`): `from_api_response` (API JSON) / `from_rdsap_schema_21_0_0` / `from_rdsap_schema_21_0_1`. Samples: `backend/epc_api/json_samples/`.
|
||||||
|
- Seed via `EpcPostgresRepository(session).save(epc, property_id, portfolio_id)` + a `ScenarioModel` + the `MaterialRow`s every firing generator prices against, then `ModellingOrchestrator(...).run([...], [scenario_id], portfolio_id)`. (Baseline can't run on calculator fixtures — drive Modelling directly, as the template does.)
|
||||||
|
4. **Python-console manual run** — instantiate the orchestrator against a DB and inspect Plans/Recommendations interactively.
|
||||||
|
|
||||||
|
## Critical gotchas (carry these)
|
||||||
|
- **`mip`/CBC is broken on this aarch64 container** — never build on `mip`.
|
||||||
|
- **`moto` not installed** — `--ignore` `tests/orchestration/test_postcode_splitter_orchestrator.py` + `tests/repositories/unstandardised_address/` when sweeping.
|
||||||
|
- Run tests with `python -m pytest <path> -q` (NOT `-p no:cov`). The rebuild `db_engine` fixture builds **only `SQLModel.metadata`**.
|
||||||
|
- **Worktree import trap** — run via `pytest` / `python -c` **from the worktree root**, not `python /tmp/foo.py` (that imports `/workspaces/model`).
|
||||||
|
- Don't edit the SAP calculator's `heat_transmission.py` (another agent owns it).
|
||||||
|
- The modelling SQLModel classes are **`…Model`** in **`infrastructure/postgres/modelling/`** (the old flat `plan_table.py`/`scenario_table.py` are deleted); `backend/app/db/models/recommendations.py` is a pure re-export shim. `PortfolioGoal` lives in `domain/modelling/`. Out-of-cluster columns are plain ints (no FK — mirror convention). `ScenarioModel.goal` is the `PortfolioGoal` **enum**; the repo's `to_domain` maps it to its `.value`.
|
||||||
|
- `etl/` + `sfr/` and the live Drizzle migrations (add `plan_id` / backfill / drop `plan_recommendations`, per `docs/migrations/recommendation-plan-id.md`) are the **owner's**, not yours.
|
||||||
|
- ADR-0014 limitation still applies: **appliances + cooking stubbed at 0 kWh** in bills.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
Stay on `feature/bill-derivation`; one TDD slice = one commit; conventional-commit ending `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`; AAA test headers; assert with `abs(x - y) <= tol` (not `pytest.approx`); pyright strict zero errors; annotate call-return locals.
|
||||||
|
|
||||||
|
## How to start
|
||||||
|
**Do NOT write code yet.** The owner wants a **grilling session** first. Open by mapping the decision tree and surfacing the design questions, e.g.:
|
||||||
|
- **BOM / Cost shape:** does `Cost` grow into a per-material breakdown (parts with depth/quantity/unit), or do materials become a separate concept the generators emit alongside the Option? How does the rebuild `ProductRepository` supply material parts + U-values today vs. what the BOM needs?
|
||||||
|
- **Financial uplift:** what's the valuation model (legacy `Property.py`/`Funding.py` — back-solve or formula)? Which columns are in-scope (valuation lower/upper/avg, post-retrofit, rental yield)? Domain home for it?
|
||||||
|
- **Scale harness:** is the EPC dump API-JSON or RdSAP-schema? Where does it live / how is it provided? Is it a committed test (subset) + a separate runnable script for the full 1k–10k? What's "inspect the recommendations" — assertions, a CSV/report, or console exploration? How to seed materials for *all* measure types at scale (catalogue completeness).
|
||||||
|
- **Console UX:** a small documented entrypoint/helper to build a `ModellingOrchestrator` + UoW against a chosen DB and run one property?
|
||||||
|
|
||||||
|
Tell the owner what you'll tackle first and whether you want a `/grill-with-docs` design pass (the financial-uplift and BOM-shape decisions are load-bearing and want ADRs).
|
||||||
|
|
@ -1,14 +1,31 @@
|
||||||
# Multi-phase scenarios with per-phase recompute against rolling state
|
# Multi-phase scenarios — deferred (speculative)
|
||||||
|
|
||||||
The Scenario aggregate becomes ordered phases: each phase has a measure-type allowlist, an optional budget, and an optional goal. The `ModellingPipeline` walks the phases in order; for each phase it (1) generates candidate recommendations restricted to the phase's measure types, (2) re-runs `ImpactPredictionService` against the **rolling** Effective EPC state (baseline for phase 1; post-phase-1 for phase 2; etc.), (3) optimises within the phase's budget/goal, (4) applies the selected package and rolls the state forward. We considered scoring all measures once against the baseline and slicing the scored list by phase, and rejected that.
|
**Status: Deferred / Out of scope.** Superseded by the single-phase decision taken in a `/grill-with-docs` session (2026-06-03) while scoping the #1157 Plan persistence schema. This ADR previously proposed an *Accepted* multi-phase Scenario aggregate with per-phase recompute against rolling state; that design is **not** being built now. The original proposal is preserved below under "Deferred design" for the day the requirement returns.
|
||||||
|
|
||||||
Per-phase recompute makes phase ordering load-bearing in the optimisation, not decorative. Installing fabric measures before a heat pump materially changes the heat pump's SAP impact; a single-pass-against-baseline pipeline forces that fact into the optimiser as a hard rule rather than a derived effect, and any cross-measure interaction we don't know to encode becomes silent error. The cost is ML calls scaling with `N_phases × N_scenarios × N_candidate_measures` per property — multi-phase scenarios pay their own ML bill, single-phase scenarios cost the same as today (the loop body runs once).
|
## Why deferred
|
||||||
|
|
||||||
A single-phase Scenario is `phases: [<one ScenarioPhase>]` with all measure types allowed and the full budget on it. There is no special-case path for single-phase — the pipeline always loops. This avoids two code paths and lets the FE evolve from single-phase to multi-phase without rewiring the backend.
|
Multi-phase sequencing — letting a user split a Scenario into ordered phases ("fabric this year, heat pump next, solar after"), each with its own measure allowlist / budget / target, and producing Plans shaped to match — came from a **prospective (not current) client**. It is entirely speculative: we may never build it. Baking it into the core domain as an accepted decision made the model "too strong" — it forced a first-class **Scenario Phase** / **Plan Phase** / **Rolled-over Options** vocabulary and a `plan_phase` table into a live product that has no consumer for any of it.
|
||||||
|
|
||||||
## Consequences
|
The current goal is to **replicate and improve the existing pipeline**, which is single-phase. So:
|
||||||
|
|
||||||
- `Plan` carries `phases: list[PlanPhase]` rather than a flat `OptimisedPackage`. Every consumer of plan output (FE, exports, downstream reports) reads phases.
|
- A **Scenario** carries one set of permitted measure types (no ordered phases).
|
||||||
- The optimiser must accept rolling-state input rather than only baseline state — a generalisation of today's single-shot pass.
|
- A **Plan** holds one **Optimised Package** of **Plan Measures** plus the Property's flat post-retrofit figures (the legacy `plan` columns). There is no `plan_phase` table and no `phase` column.
|
||||||
- ML cost can be controlled at the scenario layer: keeping a scenario single-phase is the lever for "score once, optimise once" if cost becomes a problem.
|
- The terms **Scenario Phase**, **Plan Phase**, and **Rolled-over Options** are removed from `CONTEXT.md`.
|
||||||
- Open future change: SAP impact of a measure is not strictly additive even within a phase. The current per-measure scoring + linear optimisation approximates this. A future iteration may pre-define candidate packages and ML-score whole packages, accepting combinatorial cost for accuracy. Track in PRD §15.
|
|
||||||
|
This is cheap to reverse: re-introducing phases is additive, and the [ADR-0016](0016-package-rescore-over-warm-start-optimisation.md) scoring split (per-Option signal → whole-package re-score → marginal-cascade attribution) already works against a single package and generalises to per-phase rolling state unchanged.
|
||||||
|
|
||||||
|
## Future migration path (when/if the requirement returns)
|
||||||
|
|
||||||
|
Scope it properly as a feature in its own right — do **not** retrofit it implicitly. The migration shape we expect:
|
||||||
|
|
||||||
|
1. Add a `plan_phase` table; give each existing live **Plan** exactly one Plan Phase and back-fill its current Optimised Package + post-retrofit figures into that single phase.
|
||||||
|
2. Add ordered phases to the **Scenario** aggregate (allowlist / budget / target per phase).
|
||||||
|
3. Generalise the Optimiser to run per phase against the **rolling** Effective EPC (phase 1 = baseline; phase 2 = post-phase-1 state; …), so phase ordering becomes load-bearing in the optimisation rather than decorative.
|
||||||
|
|
||||||
|
This back-fill keeps every live single-phase Plan valid as a degenerate one-phase case.
|
||||||
|
|
||||||
|
## Deferred design (original proposal, for reference)
|
||||||
|
|
||||||
|
The Scenario aggregate becomes ordered phases: each phase has a measure-type allowlist, an optional budget, and an optional goal. The pipeline walks the phases in order; for each phase it (1) generates candidate recommendations restricted to the phase's measure types, (2) re-runs scoring against the **rolling** Effective EPC state (baseline for phase 1; post-phase-1 for phase 2; etc.), (3) optimises within the phase's budget/goal, (4) applies the selected package and rolls the state forward.
|
||||||
|
|
||||||
|
The rationale was that per-phase recompute makes phase ordering load-bearing in the optimisation, not decorative: installing fabric measures before a heat pump materially changes the heat pump's SAP impact. The cost is ML/calculator calls scaling with `N_phases × N_scenarios × N_candidate_measures` per property. A single-phase Scenario was modelled as `phases: [<one ScenarioPhase>]` with all measure types allowed — i.e. exactly the single-phase product we are now building directly, without the phase machinery.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Seven open questions resolved through a `/grill-with-docs` session before Sessio
|
||||||
| 4 | Living-area fraction default | **RdSAP 10 Table 27** — direct lookup from `habitable_rooms_count`. Unambiguous, one-line table. |
|
| 4 | Living-area fraction default | **RdSAP 10 Table 27** — direct lookup from `habitable_rooms_count`. Unambiguous, one-line table. |
|
||||||
| 5 | Secondary-heating allocation | **SAP 10.2/10.3 Table 11** keyed on main heating type. RdSAP doesn't redefine the fraction — it identifies the type only. Forcing rule: when main is micro-CHP and Table N9 says non-zero secondary heat with no secondary specified, assume portable electric heaters. |
|
| 5 | Secondary-heating allocation | **SAP 10.2/10.3 Table 11** keyed on main heating type. RdSAP doesn't redefine the fraction — it identifies the type only. Forcing rule: when main is micro-CHP and Table N9 says non-zero secondary heat with no secondary specified, assume portable electric heaters. |
|
||||||
| 6 | Validation cohort | **Stratified random of 1000 certs**; report MAE per stratum. Session A success criterion = MAE ≤ 1.0 SAP-point on the **typical subset** (excluding sap_score ≤ 5, sap_score ≥ 100, multi-heating, conservatory, RIR). Global MAE reported alongside for honesty. |
|
| 6 | Validation cohort | **Stratified random of 1000 certs**; report MAE per stratum. Session A success criterion = MAE ≤ 1.0 SAP-point on the **typical subset** (excluding sap_score ≤ 5, sap_score ≥ 100, multi-heating, conservatory, RIR). Global MAE reported alongside for honesty. |
|
||||||
| 7 | `MeasureOverrides` shape | **Rejected as phantom mid-layer.** `Sap10Calculator.calculate(epc) -> SapResult` takes a single immutable cert. A separate **MeasureApplicator** service translates Optimised Package → cert-field changes, returning the "ending state snapshot" EpcPropertyData that Plan Phase already persists. Three pure functions in chain: applicator → calculator → result. |
|
| 7 | `MeasureOverrides` shape | **Rejected as phantom mid-layer.** `Sap10Calculator.calculate(epc) -> SapResult` takes a single immutable cert. A separate **MeasureApplicator** service translates Optimised Package → cert-field changes, returning the "ending state snapshot" EpcPropertyData the **Plan** persists. Three pure functions in chain: applicator → calculator → result. |
|
||||||
|
|
||||||
## Additional findings from the grill that change Session A scope
|
## Additional findings from the grill that change Session A scope
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,3 +107,26 @@ Effective Performance; no third value-set); only the timing changes:
|
||||||
The `≥1000-cert parity` gate from ADR-0009/0010 still governs whether the calculator's figures are
|
The `≥1000-cert parity` gate from ADR-0009/0010 still governs whether the calculator's figures are
|
||||||
*trusted as definitive* for the SAP-10.2 cohort, but it no longer gates *wiring* — pre-10.2 certs
|
*trusted as definitive* for the SAP-10.2 cohort, but it no longer gates *wiring* — pre-10.2 certs
|
||||||
have no current-spec lodged figure to fall back to, so the calculator is the only source there.
|
have no current-spec lodged figure to fall back to, so the calculator is the only source there.
|
||||||
|
|
||||||
|
## Amendment (2026-06-02): the calculator is the *scoring engine* within Rebaselining, which also feeds Bill Derivation
|
||||||
|
|
||||||
|
This ADR's shorthand — "the calculator *is* the Rebaseliner" — is sharpened by the fuller picture of
|
||||||
|
Rebaselining. **Rebaselining is _assemble the Effective EPC picture, then score it_**: apply
|
||||||
|
**Landlord Overrides** (boiler → ASHP, wall insulated) as a simulation on `EpcPropertyData`; estimate
|
||||||
|
components from surrounding properties when there is no EPC; re-map an old-schema EPC and gap-fill from
|
||||||
|
neighbour predictions (the override/estimation work lands shortly). The `Sap10Calculator` is the
|
||||||
|
**scoring engine at the tail of that assembly**, not the whole of Rebaselining — so the calculator
|
||||||
|
call lives **inside** the Rebaseliner (after assembly), never hoisted up into the orchestrator.
|
||||||
|
|
||||||
|
Because [Bill Derivation](0014-bill-derivation-from-real-fuel-rates.md) prices the **same scored
|
||||||
|
picture**, the Rebaseliner **exposes its `SapResult` as a first-class part of its result** — not just
|
||||||
|
`(Performance, reason)`. The orchestrator runs the calculator **once** (via the Rebaseliner) and
|
||||||
|
composes two products from that one `SapResult`: Effective Performance, and the Bill
|
||||||
|
(`EnergyBreakdown.from_sap_result` → `BillDerivation`). Running the calculator a second time for bills
|
||||||
|
is rejected — it is the expensive step over the ~40k cohort and a second call could drift from the
|
||||||
|
first.
|
||||||
|
|
||||||
|
Corollary: once Overrides/estimation land, Effective Performance is the calculator's output **even for
|
||||||
|
`sap_version ≥ 10.2`** — a user-modified or estimated dwelling has no valid lodged figure to keep. The
|
||||||
|
"keep lodged ≥ 10.2" rule holds only for a real, current, un-overridden EPC; the **Bill always derives
|
||||||
|
from the `SapResult` regardless** (lodged figures carry no per-end-use kWh).
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,54 @@ production migration is FE-owned (Drizzle); `docs/migrations/` updated.
|
||||||
- **Bill at SAP Table 32 prices** — rejected: standardised rating prices, ~half real electricity.
|
- **Bill at SAP Table 32 prices** — rejected: standardised rating prices, ~half real electricity.
|
||||||
- **JSON `bill_breakdown` block** — rejected: end-uses are fixed-cardinality, so flat columns are
|
- **JSON `bill_breakdown` block** — rejected: end-uses are fixed-cardinality, so flat columns are
|
||||||
clean and stay queryable (ADR-0004).
|
clean and stay queryable (ADR-0004).
|
||||||
|
|
||||||
|
## Amendment (2026-06-02): fuel is a calculator *output*; §3's mapping helpers corrected
|
||||||
|
|
||||||
|
Wiring the `SapResult → EnergyBreakdown` adapter forced the question §3 left implicit: *where does
|
||||||
|
the fuel each end use burns come from?* Resolved in a `/grill-with-docs` session.
|
||||||
|
|
||||||
|
- **Decision: per-end-use fuel is calculator output.** The calculator resolves the fuel for each
|
||||||
|
billable end use (it already uses it to derive the delivered kWh and the rating cost), so it emits
|
||||||
|
the **per-end-use fuel codes** on `SapResult` (main-1 / main-2 / secondary / hot water — the electric
|
||||||
|
end uses are electricity by construction), alongside `pv_exported_kwh` for the SEG credit. These are
|
||||||
|
the calculator's own fuel codes (which, per [ADR-0015](0015-mappers-own-cert-normalization.md), may
|
||||||
|
be raw API codes or already-Table-32 depending on the mapper), so `sap_fuel.sap_code_to_fuel`
|
||||||
|
**normalizes them through the calculator's own `table_32.to_table_32_code`** (T32-first, then
|
||||||
|
API-translate — the same normalization the calculator's pricing/classification uses) before the
|
||||||
|
Table-32 → `Fuel` dispatch. `BillDerivation`'s adapter is then a **pure `SapResult → EnergyBreakdown`
|
||||||
|
map** and can never price the calculator's kWh at a fuel the calculator never used. Rejected: an adapter that re-reads raw
|
||||||
|
`EpcPropertyData` fuel fields and re-normalizes them — that duplicates `cert_to_inputs`
|
||||||
|
(`_main_fuel_code`, `_water_heating_fuel_code`, HW→main default, CHP blend, the `MissingMainFuelType`
|
||||||
|
strict-raise) and reopens divergence between the bill and the rating.
|
||||||
|
|
||||||
|
- **§3 correction.** §3 says the per-end-use fuel codes map to `Fuel` "via the existing
|
||||||
|
`is_gas_code` / `is_electric_fuel_code` / `is_liquid_fuel_code` helpers." That is not what shipped:
|
||||||
|
mapping is `domain/property_baseline/sap_fuel.py::sap_code_to_fuel`, a bounded **Table-32 fuel-code
|
||||||
|
→ `Fuel`** dispatch that strict-raises `UnmappedSapCode` on an unmapped code. The "meet at one
|
||||||
|
vocabulary, not raw SAP codes" intent stands; the named helpers do not.
|
||||||
|
|
||||||
|
- **Interim, pending [ADR-0015](0015-mappers-own-cert-normalization.md).** Fuel resolution sits in
|
||||||
|
the calculator *because* `EpcPropertyData` is not yet a strict normalized type. Once ADR-0015 lands
|
||||||
|
(mappers normalize at the boundary), attribution can move upstream and the `SapResult` fuel-code
|
||||||
|
fields may be retired.
|
||||||
|
|
||||||
|
- **`COOLING` section added.** §1 listed cooling as an end use but §6's flat columns omitted it.
|
||||||
|
`BillSection` gains `COOLING` (kWh from `SapResult.space_cooling_fuel_kwh_per_yr`, electricity by
|
||||||
|
construction), so §6's layout gains a `cooling_kwh` + `cooling_cost_gbp` column pair (FE-owned
|
||||||
|
Drizzle migration).
|
||||||
|
|
||||||
|
## Amendment (2026-06-03): Bill Derivation is cross-stage; the Modelling stage prices the post-package end-state
|
||||||
|
|
||||||
|
Bill Derivation is no longer Baseline-only — the **Modelling** stage now re-runs it on the **Optimised Package** to produce post-retrofit bills and savings. Decided in a `/grill-with-docs` session.
|
||||||
|
|
||||||
|
- **Bill Derivation is a cross-stage domain concern → relocate to `domain/billing/`.** `Bill` / `EnergyBreakdown` / `BillDerivation` / `sap_fuel` were under `domain/property_baseline/` only because Baseline was built first. Two stages now consume them, and a `modelling → property_baseline` import would couple two stages ADR-0011 keeps independent under a name that wrongly implies ownership. They move to a neutral `domain/billing/` (`Fuel`/`FuelRates` already live in the shared `domain/fuel_rates/`). Mechanical move + import rewrite; covered by the existing Baseline tests.
|
||||||
|
|
||||||
|
- **Modelling bills the simulated *end-state*, never adjusts the baseline bill.** The post-retrofit bill is `BillDerivation.derive(EnergyBreakdown.from_sap_result(post_package_sap_result))`, where the `SapResult` comes from scoring the fully-overlaid `EpcPropertyData` (all selected Simulation Overlays + injected dependencies). **This is what makes fuel-switch measures correct for free:** a measure that switches heating fuel (e.g. oil → electric ASHP) changes the heating fuel *code* on that `SapResult`, so `sap_code_to_fuel` prices it at the *new* fuel automatically — no per-measure fuel bookkeeping. Savings are `baseline − post`, both priced at the **same** `FuelRates` snapshot (read once per run), so the delta is never polluted by a rate change.
|
||||||
|
|
||||||
|
- **No second calculator pass.** The post-package `SapResult` is the one the optimiser's whole-package re-score (role 2) already computed; it rides on the `Score` (`Score.sap_result`, populated by `PackageScorer`, ignored by the optimiser — so the optimiser stays `Score`-only and its stub-scorer tests are unaffected). Likewise the baseline `SapResult` is the one the orchestrator already scores for the role-3 cascade and the target gain. Billing reuses both — zero extra `calculate`.
|
||||||
|
|
||||||
|
- **`FuelRatesRepository` is constructor-injected into `ModellingOrchestrator`**, mirroring the Baseline orchestrator — `get_current()` once per `run()`, one `BillDerivation` reused across the batch. Not on the `UnitOfWork` (read-once reference data, ADR-0011). The extra per-pipeline read (Baseline + Modelling each resolve rates) is accepted; a shared/injected snapshot is a future optimisation.
|
||||||
|
|
||||||
|
- **Plan-level first, per-measure savings next (telescoping cascade).** This slice fills the plan columns (`post_energy_bill`, `post_energy_consumption`, `energy_bill_savings`, `energy_consumption_savings`). Per-measure `recommendation.kwh_savings` / `energy_cost_savings` come from a **bill cascade over the role-3 best-practice order** (fabric → heating → renewables) — re-bill each cumulative prefix and diff, telescoping exactly to the plan totals (mirroring the SAP role-3 attribution; reuses the per-prefix `sap_result`s, no extra calls). Per-measure savings can be **negative** (ventilation increases energy) and still telescope. The legacy `recommendation.energy_savings` column is **vestigial** (legacy set it to `0`; the canonical delivered-energy field is `kwh_savings`) — left NULL.
|
||||||
|
|
||||||
|
- **Limitation carried over.** The "Appliances + cooking kWh stubbed at 0" deferral above still applies — Modelling's post-package bill understates by the same unregulated-electricity load until those fields land on `SapResult`. Baseline and Modelling share the gap, so baseline-vs-post savings remain consistent.
|
||||||
|
|
|
||||||
66
docs/adr/0015-mappers-own-cert-normalization.md
Normal file
66
docs/adr/0015-mappers-own-cert-normalization.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
Status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mappers own cert normalization; `EpcPropertyData` becomes a strict normalized type
|
||||||
|
|
||||||
|
Names a direction that [ADR-0013](0013-calculator-produces-effective-performance-shadow-first.md)
|
||||||
|
already gestured at ("the strict-typing of `EpcPropertyData` that will close most of those gaps is
|
||||||
|
still pending") and that [ADR-0014](0014-bill-derivation-from-real-fuel-rates.md) ran into head-on.
|
||||||
|
Relates to [ADR-0001](0001-two-source-paths.md) (the two source paths). Decided in a
|
||||||
|
`/grill-with-docs` session (2026-06-02). This ADR records a **direction + a tracked piece of work**,
|
||||||
|
not a slice that has landed.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`EpcPropertyData` is the one cert aggregate every downstream stage reads, but it is **loosely
|
||||||
|
typed** — `main_fuel_type: Union[int, str]`, `heat_emitter_type: Union[int, str]`, bare
|
||||||
|
`Optional[int]` codes (`water_heating_fuel`, `secondary_fuel_type`), `str` fallbacks like
|
||||||
|
`'Unknown'` / `'Pre 2013'`. It is filled by **three mappers with different conventions**:
|
||||||
|
|
||||||
|
- the **EPC API** mapper (int codes),
|
||||||
|
- the **Elmhurst** site-notes mapper (string labels, e.g. `'Bulk LPG'`),
|
||||||
|
- **pashub**.
|
||||||
|
|
||||||
|
Because the cert arrives un-normalized, **normalization happens downstream in the calculator**
|
||||||
|
(`domain/sap10_calculator/rdsap/cert_to_inputs.py`): `_main_fuel_code` resolves the union and
|
||||||
|
**strict-raises `MissingMainFuelType`** on a non-int rather than defaulting; `_water_heating_fuel_code`
|
||||||
|
applies the "HW fuel defaults to the main system" rule; CHP/community blends are reassembled. This
|
||||||
|
logic is correct, but it lives in the wrong layer — it is *cert-shape* knowledge, not *physics*.
|
||||||
|
|
||||||
|
The trigger: [ADR-0014](0014-bill-derivation-from-real-fuel-rates.md)'s `BillDerivation` needs the
|
||||||
|
fuel each end use burns. The fuel fields *are* on `EpcPropertyData`, but reading them raw would mean
|
||||||
|
**re-implementing the calculator's normalization** (union resolution, HW→main default, strict-raise,
|
||||||
|
CHP blend) in a second place — and risk the bill pricing the calculator's delivered kWh at a fuel
|
||||||
|
the calculator never used. ADR-0014 therefore resolves fuel **inside the calculator** and emits it as
|
||||||
|
output. That is the right call *given today's loose cert*, but it is a **symptom**: the consumer is
|
||||||
|
paying for normalization that should have happened at the mapper boundary.
|
||||||
|
|
||||||
|
## Decision (direction)
|
||||||
|
|
||||||
|
1. **Normalization is a mapper responsibility.** Each mapper (API / Elmhurst / pashub) transforms its
|
||||||
|
source into a **single normalized shape**, resolving fuel labels→codes, applying defaults, and
|
||||||
|
raising on genuinely-missing required fields — at the boundary, once.
|
||||||
|
2. **`EpcPropertyData` becomes strict.** Replace `Union[int, str]` and raw `Optional[int]` code
|
||||||
|
fields with precise types (enums over SAP code ints; no string fallbacks in the domain object).
|
||||||
|
3. **Downstream consumers stop re-normalizing.** The calculator's `cert_to_inputs` normalization
|
||||||
|
shrinks to physics; a consumer like the bill adapter could then read fuel off a strict
|
||||||
|
`EpcPropertyData` safely (the "read it off the cert" option ADR-0014 rejected becomes sound).
|
||||||
|
|
||||||
|
## Consequences / affected areas
|
||||||
|
|
||||||
|
- **Calculator** — `cert_to_inputs` sheds its fuel/string normalization helpers; strict-raises move
|
||||||
|
to the mappers (the right place to fix a data gap).
|
||||||
|
- **Bill Derivation (ADR-0014)** — calculator-side fuel resolution on `SapResult` is an **interim
|
||||||
|
measure**, explicitly *because* the cert is not yet normalized. When this ADR lands, fuel attribution
|
||||||
|
can move upstream and the `SapResult` fuel-code fields may be retired.
|
||||||
|
- **The three mappers** — each gains normalization responsibility and its own conformance tests
|
||||||
|
(the strict-typing also makes mapper bugs fail loudly at the boundary, not deep in the cascade).
|
||||||
|
- **Reduced divergence risk** — one normalized vocabulary means the bill, the rating, and any future
|
||||||
|
consumer cannot silently disagree about a cert's fuels.
|
||||||
|
|
||||||
|
## Status of the work
|
||||||
|
|
||||||
|
Direction accepted; **not yet implemented**. To be broken into slices and tracked as an issue
|
||||||
|
parented to the Ara backend PRD (`#1128`). Until then, downstream normalization (and ADR-0014's
|
||||||
|
calculator-side fuel resolution) stands as the documented interim.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Package re-scoring over warm-start optimisation, not marginal cascade or full enumeration
|
||||||
|
|
||||||
|
Modelling scores each **Measure Option** once, **independently against the baseline** Effective EPC (deduplicated per distinct **Simulation Overlay**, so identical overlays are scored once). It runs a grouped-knapsack MILP over those per-Option scores to get a *candidate* package, injects any forced **Measure Dependencies** (e.g. ventilation) into that package, composes the selected + injected overlays into one throwaway `EpcPropertyData`, and **re-scores the whole package on the deterministic SAP10 calculator** for the truthful figure. If the true package SAP undershoots the Scenario goal, it **greedy-adds** the unselected Option with the best residual SAP-per-£ and re-scores, repeating until the target is met or the budget is exhausted.
|
||||||
|
|
||||||
|
The reason for the split is that SAP impact is **sub-additive** — summed independent per-Option scores overestimate the combined effect, so the MILP optimum is a *signal*, not the truth. Because the calculator is deterministic and fast (ADR-0009), accuracy is bought by re-scoring the chosen package, not by making the optimiser's per-measure inputs accurate. The optimiser only has to rank measures well enough to seed a near-right package; the calculator supplies the real number.
|
||||||
|
|
||||||
|
We rejected two alternatives:
|
||||||
|
|
||||||
|
- **Marginal cascade scores** (the legacy approach): score measure *N* assuming measures `1..N-1` are present. These telescope to the true total *only if every measure is selected*; the optimiser dropping a middle measure invalidates every downstream marginal. It adds the cascade's complexity for an accuracy the package re-score already provides.
|
||||||
|
- **Full package enumeration / ML-scoring the cross-product** (the path ADR-0005 §14 anticipated): combinatorial in `#Recommendations × #Options`. With realistic option counts (wall × roof × floor × heating-bundle × PV × …) the cross-product is intractable. The warm-start + re-score + repair loop reaches a truthful, near-optimal package without ever materialising it.
|
||||||
|
|
||||||
|
This resolves the open question deferred in **ADR-0005 §14**.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Calculator calls per Property per **Scenario** ≈ `(# distinct Simulation Overlays)` for the per-Option pass `+` `(a few package re-scores)` in the repair loop — **bounded, never the cross-product**. The Option-dedup-by-Overlay invariant is what keeps the per-Option pass cheap.
|
||||||
|
- A forced **Measure Dependency** must be injected into the package **before** the re-score, so its real SAP contribution — *negative* for ventilation — lands in the truthful figure and in the undershoot/repair decision. (The legacy bug was adding ventilation as a cost-only line *after* scoring, which silently overstated the package and undershot the real target.)
|
||||||
|
- The optimiser is a clean grouped knapsack: pick ≤1 Option per Recommendation, groups disjoint, **no cross-group mutual-exclusion constraints** — the Recommendation partition (no two Recommendations write the same `(building part, field)`) makes selected overlays collision-free by construction.
|
||||||
|
- Greedy repair can overspend relative to a global re-optimise. Accepted for bounded calculator calls and simplicity; re-solving the MILP with the corrected package score fed back as a constraint is the fallback if greedy proves too loose in practice.
|
||||||
|
- Per-Option scores are *approximate by design* (independent-vs-baseline) and must never be persisted or surfaced as a measure's "true" impact — only the package re-score is truthful. Measure-level impact shown to users is derived from the final scored package, not from step A.
|
||||||
|
- **Three distinct scoring roles, each with one job:** (1) per-Option independent-vs-baseline → optimiser *input* (approximate signal, never surfaced); (2) whole-package re-score → truthful *package total*; (3) **final-package marginal cascade** → per-measure *attribution* for display. Role 3 runs only on the *selected* set, applied in **best-practice prescribed order** (walls → roof → ventilation → … per the legacy `Recommendations` class), so `attribution(mᵢ) = score(m₁..mᵢ) − score(m₁..mᵢ₋₁)`; the marginals **telescope exactly to the package total** (role 2) with no residual. The "drop a middle measure" inaccuracy cannot occur because the actual final set is scored, not a hypothetical. The selected package is the cascade unit; ordering within it follows the best-practice sequence.
|
||||||
|
- **The package-scoring primitive is reusable.** "Compose selected overlays → throwaway `EpcPropertyData` → calculator" serves both the optimiser's package re-score (role 2) and a future endpoint that re-scores a *user-assembled* plan live (the FE toggling Rolled-over Options on/off). Because the calculator is fast, live re-score is the **accurate** path the moment a user deviates from the optimiser's selection. Note the trap this avoids: summing stored per-measure figures across a user-edited selection re-introduces the sub-additivity overestimate — a user-edited plan must be re-scored as a package, never summed from stored attributions.
|
||||||
|
|
||||||
|
## Amendment (2026-06-03): the optimiser objective is **least-cost-to-target**, not maximum gain
|
||||||
|
|
||||||
|
The original decision above got the **warm-start objective wrong**. It framed the grouped knapsack as *maximise SAP gain subject to budget* and the target as a *floor* the repair tops up to. The rebuild faithfully implemented that — and it is the wrong objective. The legacy `StrategicOptimiser.solve()` (`recommendations/optimiser/StrategicOptimiser.py`, **Case 1**) is the intended behaviour, and it is the opposite primary objective:
|
||||||
|
|
||||||
|
> **min cost** subject to `gain ≥ target` **and** `cost ≤ budget`; only if that is infeasible, **max gain** subject to `cost ≤ budget`.
|
||||||
|
|
||||||
|
For an **Increasing EPC** goal the objective is therefore **least-cost-to-target** — the cheapest package that reaches the goal band. This is the common case (most users want "reach band C as cheaply as possible," not "spend the budget for maximum SAP").
|
||||||
|
|
||||||
|
- **No budget** → cheapest package that reaches the target, no spend cap (legacy Case 3).
|
||||||
|
- **Budget, target reachable** → cheapest package that reaches the target band; it **stops at the target and does not overshoot** into a higher band, leaving surplus budget unspent (the "don't overshoot" property falls out of cost-minimisation — you stop at the cheapest package in band C, so you never climb into B). The within-band headroom is *not* maximised — least cost wins, e.g. SAP 70 @ £2k is chosen over SAP 75 @ £3k.
|
||||||
|
- **Budget, target unreachable** → fall back to **maximum improvement within budget** (best effort below target). "Unreachable" is judged on the **true re-scored** SAP after repair, not the signal.
|
||||||
|
- Goals **other than Increasing EPC** set no target and stay max-gain-within-budget (a separate deferred front).
|
||||||
|
|
||||||
|
**What is unchanged:** the warm-start-on-signal → inject dependencies → re-score-for-truth → greedy-repair structure, the three scoring roles, and the dependency-injection rule all stand. We **keep** the signal-based warm-start (and re-score+repair) rather than exhaustively re-scoring every candidate package, for the same scalability reason the original rejected full enumeration — the cross-product is tiny at fabric-only scale today but explodes as heating/PV/windows land. Only the warm-start's *selection rule* changes (min-cost-to-target instead of max-gain), plus the two points below.
|
||||||
|
|
||||||
|
**Target predicate.** Reaching the target is `sap_continuous ≥ band_floor` (e.g. ≥ 69.0 for C) — the continuous band floor, the conservative choice (it sits ~0.5 SAP above the rounding threshold of 68.5, so the rounded SAP lands safely in band). The legacy `allow_slack` buffer is **not** carried over: it existed to hedge the MILP's approximate summed gains, a hedge our re-score + repair already provides. Combined with the "recommend slightly more than land short" preference, the conservative floor + repair-to-true-target reliably hit the band, often with a little headroom, while the *recommended* cost remains a safe over-estimate.
|
||||||
|
|
||||||
|
**Ventilation-aware selection.** Because a forced Measure Dependency (ventilation) carries a real cost (~£900) and a negative SAP (typically −1 to −3, occasionally −5), the warm-start must **price the dependency it will trigger**, not just inject it afterwards. So the dependency is folded into each candidate during selection (via the same `_inject`, with the ventilation Option carrying a real negative role-1 signal instead of a `0.0` placeholder) — otherwise the min-cost selection (i) ignores the £900 a wall drags in, so a wall-free package that reaches target can be cheaper than the "least-cost" pick, and (ii) at large negative ventilation can select a small-gain wall whose mandatory ventilation makes it net-negative, which repair cannot un-pick. **Enforcement is now in two places:** *presence* — `_inject` on the final selected set on every path (warm-start, each repair step, max-gain fallback), guaranteeing ventilation whenever a trigger is present; *awareness* — the same `_inject` folded into candidate evaluation so the objective prices it. Presence was always guaranteed by ADR-0016; awareness is the new part.
|
||||||
|
|
||||||
|
**The budget is a hard envelope — ventilation is *not* forced over it.** This supersedes an earlier decision that a forced dependency was "injected regardless of budget." Now that selection prices the dependency, the budget constraint applies to the **augmented** (measure + its triggered ventilation) cost: a wall that fits the budget alone but whose mandatory ventilation would exceed it is **dropped, not forced over budget**. The safety invariant is untouched (we never recommend an insulated wall without ventilation) — the choice at the boundary is "do both and overspend" vs "do neither," and we do neither. A wall you can't afford to ventilate is a wall you can't afford; blowing the user's stated budget for a compliance measure is the worse surprise. The consequence: if a property's only route to the target is a wall it cannot afford to ventilate, the optimiser returns a below-target best-effort package (or nothing) rather than an over-budget one.
|
||||||
|
|
||||||
|
This supersedes the original framing of the warm-start objective (lines above describing "maximise gain … undershoots the goal") and the "re-solving the MILP" fallback note; the rest of ADR-0016 stands.
|
||||||
41
docs/adr/0017-plan-persistence-evolve-live-tables.md
Normal file
41
docs/adr/0017-plan-persistence-evolve-live-tables.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Plan persistence — evolve the live tables, no Plan Phase
|
||||||
|
|
||||||
|
**Status: Accepted.** Decided in a `/grill-with-docs` session (2026-06-03) scoping the #1157 Plan-persistence schema. Builds on [ADR-0011](0011-composable-stage-orchestrators.md) / [ADR-0012](0012-unit-of-work-per-stage-batch-transaction.md) (stage orchestrators, one Unit of Work per batch), [ADR-0016](0016-package-rescore-over-warm-start-optimisation.md) (the three scoring roles), and [ADR-0005](0005-multi-phase-scenarios-per-phase-recompute.md) (multi-phase deferred).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Modelling stage must persist a **Plan** per Property per **Scenario**. Unlike the rest of the rebuild, the output tables already exist in the **live product**: `plan`, `recommendation`, `plan_recommendations` (an m2m join), and `scenario` — SQLAlchemy `Base` models in `backend/app/db/models/recommendations.py`, which the live FE reads. This is **schema evolution on a running product**, not greenfield. Wholesale table changes are expensive and risky.
|
||||||
|
|
||||||
|
The rebuild's persistence convention is SQLModel `table=True` rows in `infrastructure/postgres/`, written through repos bound to a `UnitOfWork`, with the ephemeral-Postgres tests building the schema via `SQLModel.metadata.create_all`. The established way it already touches live tables is a **SQLModel mirror pointing at the same physical table** (`task_table.py` → `tasks`, `product_table.py` → `material`, `property_table.py` → `property`); the legacy `Base` model stays for the live app and the physical table is the shared contract.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **Reuse the live `plan` and `recommendation` tables** via SQLModel mirrors in `infrastructure/postgres/`, written through a new `PlanRepository` on the Unit of Work. No new parallel tables. The legacy SQLAlchemy models remain for the live app's reads.
|
||||||
|
- **Add `recommendation.plan_id`** (FK → `plan.id`, `ON DELETE CASCADE`). New writes link each measure to its Plan directly; the **`plan_recommendations` m2m is retired for new writes** (its many-to-many made deletes pathologically slow). The m2m table is left in place until the last legacy reader is cut over.
|
||||||
|
- **A persisted `recommendation` row is a Plan Measure** — the one selected **Measure Option** with its **role-3 (final-package cascade) attributed impact** and its **Cost**. A **Recommendation** (the candidate, multi-Option, no stored impact) is never persisted as output. (See `CONTEXT.md`: Plan Measure vs Recommendation.)
|
||||||
|
- **Post-retrofit figures stay flat on `plan`** (the legacy columns). **No `plan_phase` table and no `phase` column** — multi-phase is deferred (ADR-0005).
|
||||||
|
- **Idempotent replace per `(property_id, scenario_id)`** (ADR-0012): a re-run deletes the matching `plan` rows — cascading to their `recommendation` rows via `plan_id` — then inserts fresh. One batch commit, never per-property.
|
||||||
|
- **`plan.is_default` derives from `scenario.is_default`** so exactly one default plan exists per Property even across many Scenarios. **`recommendation.default = True`** for every persisted Plan Measure (only selected measures are persisted today).
|
||||||
|
- **Units match the live column contract:** the calculator emits CO₂ in **kg**; the live `co2_equivalent_savings` / `post_co2_emissions` columns are **tonnes**, so divide by 1000 on the way in. The CO₂ baseline for the saving comes from the **same calculator** (`PackageScorer.score(epc, [])`), keeping baseline and post self-consistent.
|
||||||
|
|
||||||
|
## Considered and rejected
|
||||||
|
|
||||||
|
- **Greenfield clean tables for Plans** — rejected: the live FE already reads `plan`/`recommendation`, and there is live data. A parallel table would fork the read model.
|
||||||
|
- **Keep the `plan_recommendations` m2m** — rejected: the join's cascade delete is the known performance killer this change exists to remove.
|
||||||
|
- **JSONB blob for the package** — rejected: the FE queries per-measure columns; flat typed columns are the existing contract.
|
||||||
|
|
||||||
|
## Amendment (2026-06-03) — retire `plan_recommendations`, consolidate the models
|
||||||
|
|
||||||
|
The original decision *retired the m2m for new writes* but left it in place. This amendment **drops it** and **consolidates the model definitions**, decided in a `/grill-with-docs` session:
|
||||||
|
|
||||||
|
- **`plan_recommendations` is dropped.** All readers (`portfolio_functions`, `Outputs`, `export/property_scenarios`) and writers are cut onto `recommendation.plan_id`. The m2m is one-to-many in practice (a measure is never shared across Plans), so a single FK models it faithfully. The cross-repo expand/contract sequence (add → backfill → dual-write → cut reads → drop) is specified in [docs/migrations/recommendation-plan-id.md](../migrations/recommendation-plan-id.md); the two load-bearing rules are **backfill before any read cuts over** and **dual-write the m2m until all reads are off it** (the FE reads via Drizzle directly, so the repos cannot deploy atomically).
|
||||||
|
- **One model per physical table, in `infrastructure/postgres/modelling/`.** The drift hazard the original ADR accepted (two ORM definitions of `plan`/`recommendation`) is resolved by **consolidating the whole `backend/app/db/models/recommendations.py` cluster** (`plan`, `recommendation`, `recommendation_materials`, `scenario`, `installed_measure`, the `PlanType`/`MeasureType` enums) into single **SQLModel** `…Row` definitions in a new `infrastructure/postgres/modelling/` subpackage, carrying full legacy column parity plus `recommendation.plan_id`. `backend/app/db/models/recommendations.py` becomes a **re-export shim** (the established `epc_property.py` pattern), aliasing the legacy names to the canonical `…Row` classes so `backend/` callers keep working. The rebuild's partial `PlanRow`/`RecommendationRow`/`ScenarioRow` mirrors are absorbed into these.
|
||||||
|
- **Scope:** `backend/` + the rebuild only. The `etl/` and `sfr/` reporting scripts that read the m2m are deferred to a later pass.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Two ORM definitions of `plan`/`recommendation`** coexist (legacy SQLAlchemy + new SQLModel mirror), a drift hazard — mitigated by this being the established mirror pattern and the physical table being the single contract. Retiring the legacy models is later, separate work.
|
||||||
|
- The **FE owns the Drizzle migration** adding `recommendation.plan_id` (+ index) and, eventually, dropping `plan_recommendations`. Documented in `docs/migrations/recommendation-plan-id.md`.
|
||||||
|
- **Unselected alternatives** (the "swap-in" UX) will later be persisted as `recommendation` rows with `default = False` linked via `plan_id` — this schema is forward-compatible. The open question is *what impact figure* such a row carries: it cannot hold a role-3 attribution (it is not in the package), and ADR-0016 forbids surfacing the role-1 independent signal as truth. **Deferred** as an ADR-0016 question.
|
||||||
|
- **Energy / bill columns** (`plan.post_energy_consumption`, `plan.energy_consumption_savings`, `plan.post_energy_bill`, `plan.energy_bill_savings`, `recommendation.kwh_savings`, `recommendation.energy_cost_savings`) are **delivered/billed kWh**, not the calculator's primary energy. They are populated by a later **Bill Derivation slice that re-runs bills on the post-package EPC**; NULL until then.
|
||||||
|
- The **#1157 tracer persists only** SAP (`post_sap_points`, `recommendation.sap_points`), CO₂ in tonnes (`post_co2_emissions`, `co2_savings`, `recommendation.co2_equivalent_savings`), cost (`estimated_cost`, `cost_of_works`, `contingency_cost`), and the derived `post_epc_rating`. Valuation, `plan_type`, U-values, heat demand, labour, and the energy/bill cluster are left NULL for later slices.
|
||||||
40
docs/adr/0018-valuation-uplift-percentage-primary.md
Normal file
40
docs/adr/0018-valuation-uplift-percentage-primary.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Valuation Uplift is percentage-primary
|
||||||
|
|
||||||
|
The Modelling rebuild needs a financial-uplift output (the increase in a Property's
|
||||||
|
market value from a retrofit). The legacy model (`backend/ml_models/Valuation.py`) is
|
||||||
|
**value-primary**: it starts from a current market value and returns absolute pounds.
|
||||||
|
But that current value — a **Property Valuation** — is sourced from a customer upload
|
||||||
|
(or a ~93-entry hardcoded demo stub) and is **absent for the overwhelming majority of
|
||||||
|
Properties**, including every property in an EPC-only scale corpus. A value-primary
|
||||||
|
model therefore produces nothing for almost all inputs.
|
||||||
|
|
||||||
|
We model **Valuation Uplift** as **percentage-primary** instead: the uplift is computed
|
||||||
|
purely from the **EPC Band** jump (current → target) and is always returned as a
|
||||||
|
percentage; the absolute £ form (`lower/upper/average_value`, `post_retrofit_value`) is
|
||||||
|
derived **only when a Property Valuation is supplied**, otherwise left `None`. This means
|
||||||
|
every Plan gets an inspectable uplift even with no market value, and it cleanly separates
|
||||||
|
the two concepts the word "valuation" was blurring — the externally-sourced **Property
|
||||||
|
Valuation** (a Baseline attribute) from the plan-conditional **Valuation Uplift** (a Plan
|
||||||
|
output). The domain function lives in `domain/modelling/valuation.py` (Modelling is the
|
||||||
|
consumer that knows the target band; relocatable to a neutral package later, as
|
||||||
|
`domain/billing/` was, if Baseline takes ownership of Property Valuation).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The percentage uplift compounds the legacy's four hardcoded broker tables
|
||||||
|
(MoneySupermarket, Lloyds, Knight Frank, Rightmove), taking min/max/average across the
|
||||||
|
sources that cover the band step. These 2022-era figures are ported verbatim as
|
||||||
|
committed reference data; they are a provenance snapshot, not a live source.
|
||||||
|
- The **2× ROI cap** (uplift ≤ twice the retrofit cost) is a £ comparison, so it can only
|
||||||
|
bite once a Property Valuation supplies the £ form; the bare percentages are uncapped.
|
||||||
|
- The model is a pure function of the before/after **EPC Band** — it does **not** use the
|
||||||
|
continuous SAP score, so it needs no precision work beyond the band the Plan already
|
||||||
|
computes.
|
||||||
|
|
||||||
|
## Deferred (not in this phase)
|
||||||
|
|
||||||
|
- **Property Valuation sourcing** — the upload-CSV ingestion slice, the Property field +
|
||||||
|
persisted column, and the decision to retire or keep the demo `UPRN_VALUE_LOOKUP` stub.
|
||||||
|
Where it persists (Baseline/performance table vs. a separate valuation table) is open.
|
||||||
|
- **Per-measure `property_valuation_increase`** and **`rental_yield_increase`** — the
|
||||||
|
legacy path never populated either; uplift is a plan-level figure for now.
|
||||||
33
docs/adr/0019-wall-insulation-eligibility.md
Normal file
33
docs/adr/0019-wall-insulation-eligibility.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Wall Insulation Eligibility (cavity vs IWI vs EWI)
|
||||||
|
|
||||||
|
The solid-wall Recommendation Generator must decide, per Property, which wall-insulation Option(s) to offer. We decided eligibility is fixed first by **wall construction**, then narrowed by **planning status**, and that **External (EWI)** and **Internal (IWI)** wall insulation are two competing **Measure Options** under one "Main wall" **Recommendation** (the Optimiser picks at most one), consistent with ADR-0016 and the "cavity-fill vs EWI" exclusivity already described in CONTEXT.md.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**By construction** (keyed on the `wall_construction` code, which is consistent across the API and Elmhurst paths for codes 1-5; the wall *description* is empty on the Elmhurst ingestion path so it can't be the primary signal — it's a fallback for the ambiguous codes (cob 7) and for refining the as-built trigger on the API path).
|
||||||
|
|
||||||
|
**System-built** is keyed on `wall_construction == 6` (`WALL_SYSTEM_BUILT`; the Elmhurst `SY System build` label). This code is currently *overloaded*: `B Basement wall` also maps to 6 (`BASEMENT_WALL_CONSTRUCTION_CODE`, `mapper.py:2100`), so the generator additionally guards on `main_wall_is_basement` — a basement wall is never solid-wall-insulation-suitable and is excluded regardless of construction. Because `main_wall_is_basement` is presently derived as `wall_construction == 6`, *every* code-6 wall is treated as basement today, so the system-built branch is inert until the calculator disambiguates system-built from basement (target: MAIN `wall_construction == 6` with `main_wall_is_basement` False — tracked in Hestia-Homes/Model#1177). The strict-xfail pin `test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after` is the tripwire for that fix. Note `wall_construction == 8` is **Park home** (`PH`) on the Elmhurst path, *not* system-built — do not key system-built on 8.
|
||||||
|
|
||||||
|
| Construction | Cavity fill | IWI | EWI |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cavity | ✅ only | ❌ | ❌ |
|
||||||
|
| Solid brick | — | ✅ | ✅ |
|
||||||
|
| System built | — | ✅ | ✅ |
|
||||||
|
| Timber frame | — | ✅ | ❌ (not constructable) |
|
||||||
|
| Cob / Granite-whinstone / Sandstone-limestone | — | ❌ | ❌ |
|
||||||
|
|
||||||
|
**Planning gates (on top), using three *distinct* flags (see ADR-0020):**
|
||||||
|
- **Conservation area** or **Flat** → remove **EWI** (external-appearance / whole-block constraints); IWI still offered.
|
||||||
|
- **Listed** or **Heritage** → remove **both** EWI and IWI (protected fabric).
|
||||||
|
|
||||||
|
A Recommendation is produced only when the wall is genuinely uninsulated (description contains "no insulation"), at a fixed **100 mm** insulation depth.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Mirror the legacy `is_suitable_for_solid_insulation` / `ewi_valid` rules verbatim.** Rejected in part: legacy collapsed all three planning flags into one `restricted_measures` boolean that blocked EWI only. We keep the flags distinct so listed/heritage can block IWI too — a deliberate strengthening.
|
||||||
|
- **Offer solid-wall insulation on cob/stone** (the calculator *can* model it — Elmhurst produces valid after-certs). Rejected: recommending standard EWI/IWI on breathable cob/rubble-stone fabric risks trapping moisture; we do not auto-suggest it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Cob and both stone types get **no** wall-insulation recommendation at all, even though the SAP calculator scores them fine — this is a deliberate building-pathology safeguard, not a gap.
|
||||||
|
- The conservation/listed/heritage gate depends on Property data not yet ingested (ADR-0020); until that lands the gate is an explicit generator input defaulting to "unrestricted", so the offline generator over-offers EWI in the interim. Not production-exposed.
|
||||||
27
docs/adr/0020-conservation-status-as-property-attributes.md
Normal file
27
docs/adr/0020-conservation-status-as-property-attributes.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Conservation / Listed / Heritage as distinct Property attributes
|
||||||
|
|
||||||
|
Wall Insulation Eligibility (ADR-0019) — and later Solar-PV and Windows generators — gate on a Property's planning status. That status is **not** on the EPC and **not** in the OS Open-UPRN parquet the geospatial Repo reads today (it only yields coordinates); legacy derives it by spatial-joining separate conservation/listed/heritage layers (`OpenUprnClient.set_spatial_data` → `property_spatial` table). We decided to model it as **three distinct boolean Property attributes** — `in_conservation_area`, `is_listed`, `is_heritage` — resolved through the **geospatial layer** during Ingestion and read off the **Property** (not the EPC), because they describe the building's location/protection, not its energy fabric.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Keep the three flags **separate**, not legacy's collapsed `restricted_measures` boolean — they gate different Measure Options (conservation blocks EWI only; listed/heritage block both — ADR-0019).
|
||||||
|
- Surface them via the geospatial Repo (a `GeospatialRepository` method returning a planning-status record, alongside `coordinates_for`), persist onto the Property in Ingestion, and pass them into the generator as an explicit input.
|
||||||
|
- Build this as the **final integrating slice** of the solid-wall feature (build order A), after the calculator mechanics and generator are pinned — it also unlocks the PV/Windows gates.
|
||||||
|
|
||||||
|
## Source (decided)
|
||||||
|
|
||||||
|
The conservation/listed/heritage flags are **co-located with longitude/latitude in the same S3 partition** the geospatial Repo already reads — so we **extend the existing `GeospatialS3Repository`** to surface those extra columns alongside the coordinates, rather than porting a separate spatial-join or reading the legacy `property_spatial` table. A further deep-dive into the exact S3 columns/shape precedes slice 3.
|
||||||
|
|
||||||
|
## Persistence (decided, slice 3c)
|
||||||
|
|
||||||
|
The OS Open-UPRN reference set is **tens of millions of rows** — too large to host in Postgres — so it lives in S3 and is resolved on demand. The flags must nonetheless reach the front-end (the FE displays them on the `property_details_spatial` view so a user can see *why* a Property did or did not get a given measure), and the FE reads Postgres, not S3. So Ingestion follows a **write-through cache**: fetch the spatial reference row from S3, use the coordinates to drive the Solar fetch, and **persist the whole row (coordinates + three flags) into the existing `property_details_spatial` table, keyed by UPRN** (one shared row per UPRN, not per Property — `uprn` is unique; cf. legacy `bulk_upsert_property_spatial`'s `on_conflict_do_update`). Modelling reads the flags back **off the Property** (`PropertyPostgresRepository` hydrates `Property.planning_restrictions` from that table by UPRN) — the stage boundary stays repo-mediated (ADR-0011); Modelling never touches S3.
|
||||||
|
|
||||||
|
This is the resolution of the earlier "persist onto the Property" wording: the flags are a Property *attribute* in the domain sense (an enrichment hydrated into the aggregate, exactly like the EPC is hydrated from `epc_property`), persisted in a **backend-written reference table**, not as columns on the FE-owned `property` row.
|
||||||
|
|
||||||
|
**Unknown default — resolved to *unrestricted* (allow EWI).** When S3 has no row for a UPRN (or the cache has none yet), the Property hydrates to `PlanningRestrictions()` — all flags `False`. This matches what legacy actually does (`OpenUprnClient.empty_spatial_df` sets all three flags `False`; the nearest-UPRN proxy keeps the flags and only nulls coordinates), so the Consequences note below — which read legacy as conservatively *blocking* on missing data — was mistaken about legacy and is superseded: we do **not** suppress a valid measure on absent evidence. UPRN is now a **required** identifier (it stitches the datasets together), so "Property with no UPRN" is an edge handled by the same unrestricted fallback rather than a designed-for case.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Generators that need planning status take it as an input or read it off the Property; it never lands on `EpcPropertyData`.
|
||||||
|
- Until this slice ships, the ADR-0019 gate defaults to "unrestricted" (offline only).
|
||||||
|
- Mirrors legacy's conservative stance where missing data implied restriction — the source slice should decide the "unknown" default explicitly (block EWI vs allow) rather than silently allowing.
|
||||||
39
docs/adr/0021-roof-insulation-eligibility.md
Normal file
39
docs/adr/0021-roof-insulation-eligibility.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Roof Insulation Eligibility (loft vs sloping-ceiling vs flat-roof vs room-in-roof)
|
||||||
|
|
||||||
|
The roof Recommendation Generator must decide, per Property, which roof-insulation Measure to offer. Unlike walls (ADR-0019), where External and Internal are two *competing* Options under one Recommendation, a roof of a given type takes **exactly one** applicable Measure — there is no menu to choose between. We fix eligibility by **roof type**, detected from the roof's construction description.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One dispatching generator, one Measure per roof.** `recommend_roof_insulation` inspects the MAIN building part's roof and emits a single "Roof" Recommendation carrying the one applicable Measure Option — folding the existing `recommend_loft_insulation` in as the loft branch. The measures are mutually exclusive *by roof type*, so the dispatch makes that exclusivity structural (no cross-guards, unlike the legacy `is_sloping_ceiling_appropriate` / `is_loft_insulation_appropriate` tangle).
|
||||||
|
|
||||||
|
| Roof type (`roof_construction_type` substring) | Measure | Uninsulated trigger | Overlay `roof_insulation_thickness` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `sloping ceiling` | `sloping_ceiling_insulation` | `roof_insulation_thickness` 0 | → **100 mm** |
|
||||||
|
| `flat` | `flat_roof_insulation` | `roof_insulation_thickness` None | → **200 mm** |
|
||||||
|
| `loft` / `thatch` | `loft_insulation` *(existing)* | `roof_insulation_thickness` 0 | → **300 mm** |
|
||||||
|
| `no access` | none (can't reach the void) | — | — |
|
||||||
|
| room-in-roof (`sap_room_in_roof` present) | **deferred** | — | — |
|
||||||
|
|
||||||
|
**All three measures write the same overlay field — `roof_insulation_thickness`** (verified against the Elmhurst before→after certs: sloping 0→100, flat None→200, thatch-loft 0→300). They differ only by detected roof type and recommended depth. The uninsulated trigger is therefore `roof_insulation_thickness ∈ {0, None}` (the Elmhurst mapper resolves "As Built" to 0 for pitched/sloping but None for flat). Note: `flat_roof_insulation_thickness` is **not** used on the Elmhurst path (it stays None even after a flat-roof measure) — it is an API-path field; reconciling that is part of the #1178 parity work. `roof_insulation_location` carries the human label (`Sloping ceiling insulation` / `Flat roof insulation` / `Joists`) as a corroborating signal.
|
||||||
|
|
||||||
|
**Detection keys on `roof_construction_type` (the string), not the `roof_construction` int.** This *inverts* the wall decision (ADR-0019 keys walls on the int because the Elmhurst wall *description* is empty) for a concrete reason: for roofs it is the **int** that the Elmhurst path leaves `None` (it only sets `roof_construction_type` via `_strip_code(roof.roof_type)`), while the string is populated on **both** paths — and the calculator already dispatches roof type the same way (`"sloping ceiling" in roof_construction_type.lower()`, `heat_transmission.py:811`). Keying the generator on the same field keeps generator and calculator reading one source. Populating `roof_construction` on the Elmhurst path for full cross-mapper parity is tracked separately in **Hestia-Homes/Model#1178** (incl. the unknown gov code for thatch); the generator is not blocked on it.
|
||||||
|
|
||||||
|
**Dispatch order matters** (substring matching): **sloping → flat → no-access → loft/thatch**. `"no access to loft"` contains both `"loft"` and `"access to loft"`, so the no-access branch must be tested before the loft branch, else a no-access roof would wrongly match loft.
|
||||||
|
|
||||||
|
**Thatch is not excluded.** A `Pitched (thatch)` roof takes **loft (joist) insulation** — the before→after Elmhurst cert shows `Insulation: None → Joists, 300 mm`. The thatch *covering* doesn't block insulating the loft floor beneath it; a breathability exclusion would only bite if we tried to insulate a thatched roof *at the rafters*, which is not in scope.
|
||||||
|
|
||||||
|
**Room-in-roof is a distinct, deferred Measure.** An RR (`sap_room_in_roof` present) is a habitable room in the roof space: its ceiling joists are the room's floor, so loft/joist insulation is physically impossible — it is insulated at its own slopes / stud walls / flat ceiling, and the **quantity is the estimated rafter area, not the part's floor area** (`roof_area`). Dispatch checks RR first and, finding one, emits nothing for now. Deferred pending retrofit-specialist before/after examples — soon to close.
|
||||||
|
|
||||||
|
**MAIN building part only.** Like every other rebuild generator (wall / loft / floor), the roof generator operates on the MAIN part. Multi-roof dwellings (MAIN + extensions) are out of scope here and land when extensions are tackled across all generators.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Separate generators per measure** (`recommend_sloping_ceiling`, `recommend_flat_roof`, alongside `recommend_loft_insulation`). Rejected: each would need cross-guards so only one fires on a given roof — reintroducing the interacting-conditions complexity the legacy `RoofRecommendations` carries. A single dispatcher makes one-measure-per-roof structural.
|
||||||
|
- **Key detection on the `roof_construction` int** (consistent with walls). Rejected for now: the Elmhurst path leaves the int `None`, so it can't drive detection for the very fixtures we pin. Fixing that is #1178; until then the string is the only cross-path signal.
|
||||||
|
- **Exclude thatch** (as we exclude cob/stone walls). Rejected: the cert evidence shows thatched roofs take loft insulation; the breathability concern is about rafter-level insulation of the covering, which we don't offer.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Substring detection is looser than an enum; the match terms and their order are load-bearing and pinned against real fixtures. Closing #1178 lets a later revision switch to the int if desired.
|
||||||
|
- A room-in-roof property gets **no** roof Measure until the deferred RR work lands — a known temporary gap, not a silent drop.
|
||||||
|
- Recommended depths differ by Measure (loft 300 mm, flat 200 mm, sloping 100 mm), each pinned to the Elmhurst re-lodged after-cert at 1e-4.
|
||||||
37
docs/adr/0022-glazing-eligibility-and-overlay.md
Normal file
37
docs/adr/0022-glazing-eligibility-and-overlay.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Glazing Eligibility, Overlay, and Pricing
|
||||||
|
|
||||||
|
The glazing Recommendation Generator offers a windows upgrade. Three decisions are load-bearing and non-obvious: how planning status picks the Measure, how the Simulation Overlay models "replace the windows" (which is *not* what you'd guess from how Elmhurst works), and how the upgrade is priced.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Single dispatching generator, one planning-picked Measure, all single-glazed windows together.** `recommend_glazing(epc, products, restrictions)` emits one "Windows" Recommendation with a **single** Measure Option:
|
||||||
|
|
||||||
|
- **Scope:** only **single-glazed** windows (`glazing_type == 1`, SAP10.2 Table U2 "Single"; the Elmhurst mapper sends `"Single"`/`"Single glazing"` → 1 and the API passes int 1, so it's path-consistent). Already-double/secondary/triple windows are left untouched. No single-glazed windows → no Recommendation (this subsumes legacy's "skip if already well-glazed").
|
||||||
|
- **Planning hard-picks the Measure** (not competing Options for the Optimiser, unlike wall EWI/IWI in ADR-0019): unrestricted → `double_glazing`; any of conservation-area / listed / heritage → `secondary_glazing` (an internal second pane — the external units can't be replaced on a protected/over-looked building). Reuses the `PlanningRestrictions` value object from the wall work.
|
||||||
|
- **All single-glazed windows in one overlay**, upgraded as a block — the Optimiser takes or leaves the whole glazing job, matching how it's quoted/installed.
|
||||||
|
|
||||||
|
**The overlay writes *lodged U-values and g-values directly*, because our calculator consumes them as inputs — it does not derive them from the glazing type.** This is the surprising part. Elmhurst *derives* a window's U-value from its glazing type as a UI convenience, then lodges the resulting U and g as manufacturer data on the cert. Our calculator, when every window carries a per-window `WindowTransmissionDetails.u_value` (the manufacturer-lodged case — `heat_transmission.py:490`), reads **each window's lodged `u_value` directly** for heat loss and its lodged `solar_transmittance` for gains (`solar_gains.py:300`); `glazing_type` is *not* used for U or g in that path. So changing `glazing_type` alone would move nothing. The `WindowOverlay` therefore sets three fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WindowOverlay: # all-optional partial of one SapWindow
|
||||||
|
glazing_type: Optional[int] = None # → SapWindow.glazing_type (drives the §5 daylight g_L only)
|
||||||
|
u_value: Optional[float] = None # → WindowTransmissionDetails.u_value (heat loss)
|
||||||
|
solar_transmittance: Optional[float] = None # → WindowTransmissionDetails.solar_transmittance (gains)
|
||||||
|
```
|
||||||
|
|
||||||
|
`EpcSimulation` gains a `windows: Mapping[int, WindowOverlay]` surface keyed by `sap_windows` index; `apply_simulations` folds `windows[i]` onto `sap_windows[i]`, routing `u_value`/`solar_transmittance` into its `WindowTransmissionDetails` (creating one if absent). `glazing_type` is still set so the §5 daylight/lighting factor (`internal_gains.py:544`, which *does* key on the code) matches the after-cert at 1e-4. Target values are pinned from the Elmhurst before→after cert (cert 001431: double → U 4.80→1.40, g 0.85→0.72; secondary → the lodged "Secondary glazing" spec).
|
||||||
|
|
||||||
|
**Pricing: flat average price per window × count of single-glazed windows.** We now have per-window areas, but no size-varying prices, so size is ignored: `Cost.total = (number of single-glazed sap_windows entries) × product.unit_cost_per_m2`, reusing that field as a per-window price exactly as `mechanical_ventilation` reuses it per-unit. Double vs secondary price differently for free, via their distinct `measure_type` catalogue rows. One `sap_windows` entry counts as one window (deterministic; a grouped-row undercount is a catalogue calibration concern, not a model one).
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Set `glazing_type` only and let the calculator derive U/g.** Rejected: our calculator reads the lodged per-window U/g directly when present (the manufacturer-lodged case), so a glazing-type-only overlay would leave the SAP unchanged. We must write the U/g.
|
||||||
|
- **Offer double and secondary as competing Options** (like EWI/IWI). Rejected: planning *determines* the Measure, so there's no choice to optimise — a single planning-picked Option is honest.
|
||||||
|
- **Per-window Recommendations / area-based pricing.** Rejected: glazing is quoted and installed as a whole-job block, and we have no size-varying prices — a flat per-window average over the single-glazed count is the faithful model.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `EpcSimulation` grows a third overlay surface (`windows`), and the applicator gains its first **nested** write (into `WindowTransmissionDetails`) — deeper than the flat building-part fold, but required because that's where the cascade reads U/g.
|
||||||
|
- Cascade pins depend on the glazing-label mapper coverage (cert 001431 lodges several unmapped glazing labels — `"Secondary glazing"`, `"Secondary glazing - Normal emissivity"`, `"Triple pre 2002"`, truncated `"Double…"`/`"Triple…"` variants); those must be mapped before the before→after cert parses.
|
||||||
|
- Because planning hard-picks the Measure, the glazing generator needs the Property's `PlanningRestrictions` threaded in — the same input the solid-wall generator already takes.
|
||||||
42
docs/adr/0023-lighting-eligibility-and-overlay.md
Normal file
42
docs/adr/0023-lighting-eligibility-and-overlay.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Lighting Eligibility, Overlay, and Pricing
|
||||||
|
|
||||||
|
The lighting Recommendation Generator offers an LED upgrade. Three decisions are load-bearing and non-obvious: that it converts **all** non-LED bulbs to LED (not the legacy "fill to low-energy"), that its Simulation Overlay is the first **whole-dwelling, top-level** surface, and that it is a **free Optimiser candidate** rather than a forced dependency like ventilation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One dispatching generator, one Measure, all non-LED bulbs to LED.** `recommend_lighting(epc, products)` emits one "Lighting" Recommendation with a **single** Measure Option:
|
||||||
|
|
||||||
|
- **Scope:** the dwelling's four lodged fixed-lighting bulb counts — `led_`, `cfl_`, `incandescent_`, `low_energy_fixed_lighting_bulbs_count` (the last is RdSAP "low energy, LED/CFL unknown", LEL). The Measure converts **every non-LED bulb** (incandescent + CFL + LEL) to **LED**. Trigger: `incandescent + cfl + low_energy_unknown > 0`. When that sum is zero — already all-LED, **or** no bulb counts lodged (the calculator's L5b/L8c fallback case, where we have no inventory to size against) — no Recommendation is offered.
|
||||||
|
- **All the way to LED, not "low energy".** SAP 10.2 RdSAP §12-1 rates lamp efficacy LED **100** > LEL **80** > CFL **55** > incandescent **11.2** lm/W, so converting *every* non-LED type — including CFL and LEL — strictly improves efficacy and lighting energy (Appendix L, worksheet line (232)). The legacy generator only filled outlets to "low energy" and treated CFL as already-efficient; we go to LED for the larger, honest SAP gain.
|
||||||
|
- **Free Optimiser candidate**, run in `_candidate_recommendations` alongside the fabric measures — **not** a forced Measure Dependency. Ventilation is forced precisely because it only ever *costs* SAP; an LED upgrade *improves* SAP at low cost, so the Optimiser should be free to keep or leave it for least-cost-to-target. (Validated on the real cert: 20 incandescent → 20 LED drops lighting (232) from 783.7 → 232.7 kWh/yr.)
|
||||||
|
|
||||||
|
**The overlay is the first whole-dwelling, top-level surface.** Unlike walls/roofs/floors (per `SapBuildingPart`) or glazing (per `sap_windows` index), the bulb counts live **top-level on `EpcPropertyData`**. `EpcSimulation` gains `lighting: Optional[LightingOverlay]` (the 4th overlay surface, after building parts, windows, ventilation):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LightingOverlay: # all-optional partial; counts are absolute
|
||||||
|
led_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
cfl_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
incandescent_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
The field names match the EPC exactly so the applicator folds by `setattr` (as `_fold_ventilation` does), writing **directly onto the result `EpcPropertyData`** — no nested object, simpler than ventilation. The counts are **absolute target states** (like `wall_insulation_thickness=100`): the generator reads the baseline, sums `total`, and emits `led=total, cfl=0, incandescent=0, low_energy=0`. This handles both scenarios uniformly — "zero existing LEDs" (`led 0→total`) and "some existing LEDs" (`led tops up to total`).
|
||||||
|
|
||||||
|
**Pricing: flat per-bulb price × count of non-LED bulbs.** `Cost.total = (incandescent + cfl + low_energy_unknown) × product.unit_cost_per_m2`, reusing that field as a per-bulb price exactly as glazing reuses it per-window and ventilation per-unit; `contingency_rate = 0.26` (the legacy `low_energy_lighting` rate). LEL bulbs are priced too, since they're converted.
|
||||||
|
|
||||||
|
**`measure_type = "low_energy_lighting"`.** Although the Measure installs LED specifically, `measure_type` is a cross-cutting catalogue classification that `MEASURE_MAP` / `Funding` and reporting key on, so we keep the established legacy name and put "LED" in the Option **description** and the all-LED overlay — not in a new `led_lighting` type the rest of the system wouldn't recognise.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Fill to 100% "low energy" (legacy behaviour), leaving CFL as-is.** Rejected: the calculator rates LED above CFL (100 vs 55 lm/W), so going all-LED is a strictly larger, truthful SAP gain — and the real Elmhurst after-cert lodges LED, not a CFL/LEL mix.
|
||||||
|
- **A forced Measure Dependency (like ventilation).** Rejected: lighting *improves* SAP, so there is a genuine cost/benefit choice for the Optimiser — a free candidate is honest; a forced injection is not.
|
||||||
|
- **`measure_type = "led_lighting"`.** Rejected: precise but divergent — `MEASURE_MAP`/`Funding` and legacy reporting key on `low_energy_lighting`; the precision lives in the description instead.
|
||||||
|
- **A leaner overlay (single `all_led` flag the applicator expands).** Rejected: the four absolute counts mirror the EPC by name, keep the generic by-name fold, and stay greppable — consistent with every other overlay carrying real EPC fields.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `EpcSimulation` grows its first **whole-dwelling, top-level** overlay surface (`lighting`) — the applicator's fold writes onto the `EpcPropertyData` directly rather than a nested object or a targeted part/window.
|
||||||
|
- Lighting is wired into the free candidate pool (`_candidate_recommendations`), priced in the catalogue and contingency table under `low_energy_lighting`, and added to the `_GENERATOR_MEASURE_TYPES` forcing test.
|
||||||
|
- Validation uses real Elmhurst before/after certs (two scenarios: "zero existing LEDs" and "some existing LEDs") as 1e-4 cascade pins. Lighting changes only bulb counts → Appendix L (232), with **no fabric coupling** (contrast glazing's draught-proofing/frame-factor), so the pins are expected to close cleanly with no xfail.
|
||||||
|
- A genuinely all-incandescent dwelling that lodged **zero** bulb counts (a data gap) gets no Recommendation — a data-completeness limitation, not a modelling choice, since we refuse to fabricate an outlet count.
|
||||||
52
docs/adr/0024-heating-eligibility-and-overlay.md
Normal file
52
docs/adr/0024-heating-eligibility-and-overlay.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Heating & Hot-Water Eligibility, Bundles, and Overlay
|
||||||
|
|
||||||
|
The heating Recommendation Generator offers a whole-system replacement. Four decisions are load-bearing and non-obvious: that **hot water, controls, fuel, and meter are folded into the heating bundle** (not offered as separate or complementary measures), that each bundle is a **fixed, real, contractor-installable end-state** (not a derived ideal), that **eligibility encodes only physical/planning installability** (the Optimiser owns the economics — so the legacy ASHP built-form/floor-area rule is dropped), and that the **Simulation Overlay is the deepest surface yet**, spanning five locations across `EpcPropertyData`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One "Heating & Hot Water" Recommendation, competing whole-system bundles, the Optimiser picks at most one.** `recommend_heating(epc, products, restrictions=PlanningRestrictions())` emits a single Recommendation whose mutually-exclusive Measure Options are competing system replacements:
|
||||||
|
|
||||||
|
- `high_heat_retention_storage_heaters`
|
||||||
|
- `air_source_heat_pump`
|
||||||
|
- `boiler_upgrade` (deferred — see below)
|
||||||
|
|
||||||
|
`measure_type` keeps the legacy `MEASURE_MAP["heating"]` names so `Funding`/reporting key on them; the precise spec lives in each Option's description and overlay. The Recommendation is a **free Optimiser candidate** (in `_candidate_recommendations`), not a forced Measure Dependency — a bundle can lower SAP / raise cost for some baselines, and the Optimiser simply won't pick it.
|
||||||
|
|
||||||
|
**Hot water, controls, fuel, and meter fold into each bundle — never separate competing or complementary measures.** A heating system *implies* its hot-water arrangement: an ASHP brings a HWP cylinder + electric HW; HHR storage brings an off-peak immersion cylinder. Historically heating and hot water were assessed separately, which double-counted (recommend a heating upgrade that already changes HW, then *also* recommend a complementary HW upgrade). Each bundle's Option is therefore the **whole system change at once**: main heating + controls + fuel + meter + the implied HW. Standalone HW-only measures (cylinder thermostat, tank insulation for a dwelling whose main heating is already fine) and **secondary heating** (low-efficiency mounted heaters — residents resist removal, so an isolated concern) are **separate future generators**, not part of this bundle.
|
||||||
|
|
||||||
|
**Each bundle is a fixed, real, contractor-installable end-state.** Like `loft_insulation`=300 mm, each bundle encodes one representative target system as constants — chosen because Domna installs these specific products with its contractors, not as a theoretical ideal:
|
||||||
|
|
||||||
|
- **HHR storage** → `sap_main_heating_code=409` (Table 4a) + control 2404 + off-peak meter + electric immersion cylinder; resolves efficiency by SAP code (no PCDB index).
|
||||||
|
- **ASHP** → a fixed representative heat-pump **PCDB index** (101413) + `main_heating_category=4` + control 2210 + HWP cylinder; the calculator resolves SCOP from the PCDB heat-pump record (there is no generic-SCOP path), so a real index is required.
|
||||||
|
|
||||||
|
`Product` stays **cost-only** — it prices the Option from the materials table; it does not carry the system identity. Promoting the catalogue to drive *which* product is a clean future change, not needed for the tracer.
|
||||||
|
|
||||||
|
**Eligibility encodes only physical/legal installability; the Optimiser owns the economics.** Because the Optimiser scores every offered bundle through SAP 10.2 and picks the most cost-effective package to the target, eligibility must *not* re-gate on economic proxies (double-gating drops options the Optimiser would correctly weigh). It encodes only what the Optimiser cannot see — can the system physically and legally be installed:
|
||||||
|
|
||||||
|
- **ASHP** (research-grounded, replacing the legacy rule): offer ⇔ `property_type ∈ {House, Bungalow}` ∧ not listed ∧ not heritage ∧ not already ASHP/GSHP. **Flats/maisonettes** are not auto-offered an individual air-to-water ASHP (siting / lease / MCS-020 need a survey). A **conservation area** does **not** exclude ASHP (offered with a planning caveat) — unlike glazing, where it downgrades the measure. Floor area, fuel, fabric, and terraced/enclosed built form are **not** gates — the legacy `built_form ∉ {enclosed terrace}` ∧ `floor_area > 120 m²` rule is dropped (no authoritative basis; MCS-020 siting/noise is geometry the EPC cannot supply).
|
||||||
|
- **HHR storage**: off-gas (`not mains_gas`) or currently electric / room heaters; not community heating, not already HHR/ASHP/GSHP. Legacy keyed these on `clean_description` string sections; the rebuild translates them to structured predicates (`main_fuel_type`, `sap_main_heating_code`, `mains_gas`), grounded against the example certs.
|
||||||
|
|
||||||
|
**The Simulation Overlay is the deepest surface yet — five locations.** A flat `HeatingOverlay` carries the target system identity; `_fold_heating` routes each non-`None` field to its home (mirroring how `_fold_window` writes flat fields into nested `WindowTransmissionDetails`). It writes **absolute target states** (replacing the system regardless of the before), across:
|
||||||
|
|
||||||
|
1. `main_heating_details[0]` — `main_fuel_type`, `sap_main_heating_code` **or** `main_heating_index_number`+`main_heating_category`, `main_heating_control`
|
||||||
|
2. `sap_heating` (top-level) — `water_heating_code`, `water_heating_fuel`, `cylinder_size` / `_insulation_type` / `_insulation_thickness_mm`
|
||||||
|
3. `EpcPropertyData` (top-level) — `has_hot_water_cylinder`
|
||||||
|
4. `sap_energy_source` — `meter_type`, `mains_gas`
|
||||||
|
|
||||||
|
Only `main_heating_details[0]` (the primary system) is targeted; **dual-heating** dwellings (multiple `main_heating_details`) are out of scope for the tracer.
|
||||||
|
|
||||||
|
**Build order: HHRSH → ASHP → boiler.** HHRSH has clean before/after pairs across several base systems (electric storage / no-system / wrong-HW / room-heaters → one common HHR after) and is built first; ASHP follows; `boiler_upgrade` is deferred until a sound before/after example exists.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Separate heating and hot-water Recommendations (legacy).** Rejected: it double-counts, because a heating system change already determines the HW arrangement. HW folds into the bundle.
|
||||||
|
- **Catalogue-driven system identity (extend `Product` with PCDB index / SAP code / controls).** Rejected for now: a large `Product` extension that diverges from the fixed-target pattern the other five generators use. A fixed representative product per bundle is simpler and matches how `loft`/`glazing` hardcode their targets; catalogue-driving is a clean later promotion.
|
||||||
|
- **Porting the legacy ASHP eligibility rule (built form + 120 m² floor area).** Rejected: research found no authoritative basis — MCS-020 siting/noise depends on geometry the EPC cannot supply, and the rule contradicts its own cited evidence (EST "suitable for all property types"). The Optimiser now owns the economics the rule was proxying.
|
||||||
|
- **Treating the heating bundle as a forced Measure Dependency (like ventilation).** Rejected: ventilation only ever costs SAP, so it is forced; a heating replacement is a genuine cost/benefit choice the Optimiser should make freely.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `EpcSimulation` grows its 5th overlay surface, `heating: Optional[HeatingOverlay]`, and the applicator gains `_fold_heating` — the first fold writing across `main_heating_details[0]`, `sap_heating`, top-level `EpcPropertyData`, and `sap_energy_source` at once.
|
||||||
|
- Heating is wired into the free candidate pool, priced in the catalogue + contingency table under the legacy `MEASURE_MAP["heating"]` names, and added to `_GENERATOR_MEASURE_TYPES` + `harness/report.py::_triggers_for`.
|
||||||
|
- Validation uses real Elmhurst before/after certs as 1e-4 cascade pins; the same HHRSH overlay is pinned against several base-system befores → one common after, exercising the absolute-target design. Fuel-switching (gas/LPG → electricity) is the most error-prone part for the cascade and is the focus of the pins.
|
||||||
|
- **Deferred (named gaps):** `boiler_upgrade` (pending a sound example); secondary heating (separate generator); standalone HW-only measures (separate generator — the "smaller alternative to a big upgrade" avenue); dual-heating dwellings; flats / communal-or-air-to-air ASHP; survey "caveat" flags on Options (conservation-area / terraced ASHP) — our Option model has no caveat field yet.
|
||||||
46
docs/adr/0025-ashp-bundle-costing.md
Normal file
46
docs/adr/0025-ashp-bundle-costing.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# ASHP Bundle Costing — Composite, Dwelling-Sized
|
||||||
|
|
||||||
|
The air-source-heat-pump bundle (ADR-0024) is the first Measure Option whose cost is **not** a single catalogue scalar. A real ASHP retrofit ranges ~£12k–£21k depending on the existing system, pump size, and — dominantly — whether a wet distribution system already exists. The Optimiser picks least-cost-to-band, so a flat number systematically mis-ranks ASHP. We therefore **compose the cost per dwelling** from the real Southern Housing Group rate sheet (the `HEAT PUMPS` tab, line items ECOHT01–68), interpreting the dwelling to select and sum the applicable priced lines.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The ASHP bundle cost is a composite, computed per dwelling at generation time:**
|
||||||
|
|
||||||
|
```
|
||||||
|
decommission(existing system, property size) # by system type × beds band
|
||||||
|
+ heat_pump(kW size band) # sized from heat loss
|
||||||
|
+ cylinder (fixed £2,382.60) # one cylinder per install
|
||||||
|
+ distribution: # the dominant lever
|
||||||
|
no reusable wet system → full band(n_radiators)
|
||||||
|
reusable wet system → £168 flush + 0.5 × band(n_radiators)
|
||||||
|
→ Cost(total = sum, contingency_rate = 0.25)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Products` owns the catalogue math; the modelling layer owns the dwelling interpretation.** A new rich domain collection over `Product` — `Products` — exposes per-measure cost methods (`ashp_bundle_cost`) that filter the relevant priced rows and sum them from a small typed `AshpCostInputs`. It stays free of `EpcPropertyData` and the `Sap10Calculator`. The dwelling→inputs interpretation (sizing, proxies, reuse detection) lives in modelling, which may depend on the calculator. `ProductRepository` remains the IO port; `Products` is the behaviour it yields. `Product` stays cost-only (ADR-0024 unchanged); the ECOHT rate lines land in the committed costs file as structured rows (category + qualifier), faithful to the rate sheet and the future materials table.
|
||||||
|
|
||||||
|
**Dwelling interpretation rules (each a documented proxy, not a survey):**
|
||||||
|
|
||||||
|
- **Pump size** = `floor_area × 0.05 kW/m²` (design heat loss in kW), rounded **up** to the next install band {5, 8, 11, 15, 16} kW. The more accurate `avg(HLC) × 24.2 K / 1000` is **deferred**: the generators run before the `PackageScorer`, so no `SapResult` exists at cost time, and threading one through every generator's call site is real wiring complexity for a *minor* lever (the whole 5→16 kW band spans only ~£1.7k). The floor-area proxy lands the same band for most dwellings and keeps the cost interpreter free of the calculator. HLC sizing is the natural upgrade when the SAP product itself becomes dwelling-aware (which already runs the calculator). The cost product is sized to the dwelling **even though the SAP product is fixed at the Vaillant aroTHERM plus 5 kW** — a deliberate, documented inconsistency (see Consequences).
|
||||||
|
- **Existing system** from `main_fuel_type` + `sap_main_heating_code`: gas/oil/LPG/electric-storage map to their decommission lines; **no system → £0**; electric room/panel heaters → electric-storage line; anything else → gas line (£720) as a representative default — **never a strict-raise**, because raising would wrongly block ASHP eligibility for a real dwelling.
|
||||||
|
- **Property-size band** (1–2 vs 3–4 bed, which only changes the electric-storage decommission line, a £270 swing): **floor area ≤ 75 m²** ⇒ 1–2 bed. Floor area is always present, unlike `habitable_rooms_count`.
|
||||||
|
- **Reusable wet system** = an existing gas/oil/LPG boiler with radiator emitters. With one, the ASHP reuses the pipework but a meaningful subset of radiators is upsized for the lower flow temperature (MCS-007, ≤ 55 °C) — so reuse is **not** free: `£168 flush + 0.5 × full distribution`. Without one (electric/none/warm-air), a **full** new wet distribution is priced.
|
||||||
|
- **Radiator count** = `clamp(habitable_rooms_count + 3, 4, 12)` (RdSAP excludes kitchen/hall/bathroom from habitable rooms); fallback `floor_area ÷ 13 m²`.
|
||||||
|
- **Cylinder** = a single fixed line (£2,382.60); the cylinder-size spread on the sheet is £188 (noise).
|
||||||
|
- **Extras** (ECOHT53–68: socket relocation, trenching, heat meter, Hive, …) are **excluded** from the base composite — unpredictable from EPC data and exactly what **Contingency** absorbs. Controls are already inside the pump line.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Single fixed representative cost (the loft/glazing pattern).** Rejected: those costs are genuinely ~uniform; ASHP's varies ~£12k–£21k per dwelling and the variance changes Optimiser selection. A flat number would systematically over- or under-recommend ASHP.
|
||||||
|
- **Extend `Product` with a cost formula / line items.** Rejected: the `Product`-bloat ADR-0024 already declined; a heat pump's cost composition is not a catalogue scalar.
|
||||||
|
- **Inline the composition in `recommend_heating`.** Rejected: pulls sizing/proxy/reuse logic into the generator, which is meant to be thin detection + bundle assembly; hard to test in isolation.
|
||||||
|
- **`Products.ashp_bundle_cost(epc)` doing sizing too.** Rejected: inverts the layering (the priced catalogue depending on the SAP calculator and EPC). The catalogue math is kept pure and table-driven; dwelling physics stays in modelling.
|
||||||
|
- **Pricing reuse as a £168 power-flush only.** Rejected after research: MCS reality is that existing radiators emit ~42% at 45 °C flow and a subset gets upsized, so flush-only is unrealistically optimistic.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A new `Products` collection + `AshpCostInputs` + an `ashp_bundle_cost` method; the ASHP rate lines added to the committed costs file as structured rows. `Product`, `recommend_heating`'s detection, and the other five generators are unchanged (they keep `.get` + unit×area; `Products` grows methods per measure later — no speculative six-way refactor).
|
||||||
|
- **SAP/cost product-size mismatch:** the cost is sized to the dwelling while the SAP simulation uses the fixed 5 kW Vaillant. Bounded and documented — the pump-band cost spread is only ~£1.7k across 5→16 kW, and the size estimate built here is exactly what a future dwelling-aware SAP product (picking the Vaillant model in the right band for *both* SAP and cost) would reuse. That reconciliation is deferred, not designed away.
|
||||||
|
- **The 0.5 reuse-distribution fraction is the headline uncertainty** — a single named constant (`_REUSE_DISTRIBUTION_FRACTION`) to recalibrate when real reuse-job costs or survey data arrive.
|
||||||
|
- Realistic costs (~£15k–£21k vs the £12k placeholder) make ASHP win **less** often; this is why the held product-swap and its 5 broken integration tests land together with this costing, against stable Optimiser behaviour rather than churning twice.
|
||||||
|
- Brand is **cost-neutral** (Daikin/Mitsubishi/Vaillant/Samsung/Grant priced identically), so the Vaillant SAP choice carries no cost penalty.
|
||||||
|
- All proxies (pump size, beds, radiator count, reuse fraction) are whole-dwelling estimates standing in for a survey; each is documented at its call site as such.
|
||||||
62
docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md
Normal file
62
docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Solar PV — Eligibility, Sizing, Overlay, and Costing
|
||||||
|
|
||||||
|
The solar Recommendation Generator offers competing whole-array PV Options built from **real Google Solar imagery**, not an estimate. Unlike the heating bundles (ADR-0024/0025), the SAP scoring side is already mature (the calculator does Appendix M β-split, G4 diverter, SEG export, batteries, monthly E_PV); this ADR fixes the *recommendation* side: where the array config comes from, how it's sized, the new PV Overlay surface, and the composite cost. The bulk production path is **API-sourced** data; the Elmhurst before/after Summaries are overlay→SAP cascade pins, not the production input.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One "Solar PV" Recommendation, competing whole-array Options, the Optimiser picks at most one.** `recommend_solar(epc, products, solar_potential, restrictions)` emits a single Recommendation whose Options are **up to five conservatively-sized array configs × {no battery, battery}** (≤ 10 Options). A **free Optimiser candidate** — it raises SAP, but the Optimiser owns whether and at what size to install it.
|
||||||
|
|
||||||
|
**The array config comes from a typed `SolarPotential`, never the EPC.** The EPC's `photovoltaic_arrays` is the dwelling's *existing* PV (empty for a non-PV dwelling); the thing we install is the **Google Solar potential**, fetched by Ingestion (raw `buildingInsights` JSON persisted as JSONB, never re-fetched) and projected into a strictly-typed `SolarPotential` that Modelling reads and threads into `recommend_solar` (mirroring how `planning_restrictions` is threaded). The Solar-API JSON → `SolarPotential` mapping is its own validated boundary.
|
||||||
|
|
||||||
|
**Sizing: a five-config spread, conservatively capped.** Google returns a ladder of `solarPanelConfigs` (increasing panels). We filter to a conservative feasible set — **drop north-facing segments** (azimuth ∈ [−30°, 30°]) and cap usable panels at **~70% of `maxArrayPanelsCount`** (imagery misses obstructions; MCS wants a ~0.3 m edge setback) — then, if more than five remain, sample **five spanning min→max by energy generation** so the Optimiser gets a genuine size/cost choice. Energy generation is the size-suitability proxy.
|
||||||
|
|
||||||
|
**The PV Overlay is a new (sixth) surface.** A flat `SolarOverlay`; `_fold_solar` writes onto `sap_energy_source`:
|
||||||
|
- `photovoltaic_arrays` — **one `PhotovoltaicArray` per roof segment**: `peak_power = panelsCount × panelCapacityWatts / 1000`; `orientation` = `azimuthDegrees` bucketed to the SAP octant (1=N…5=S…8=NW); `pitch` = `pitchDegrees` snapped to the RdSAP enum {0°→1, 30°→2, 45°→3, 60°→4, 90°→5}; `overshading` = the generation-calibrated code (below).
|
||||||
|
- `pv_diverter_present = True` — **folded in, conditional on a hot-water cylinder** (App G4 routes surplus PV to the cylinder immersion; a combi has nothing to divert to).
|
||||||
|
- `pv_connection` = connected to the dwelling's meter.
|
||||||
|
- **`is_dwelling_export_capable = True`** — set absolutely; an export meter is *ensured* post-install (drives the SEG export credit), regardless of the before.
|
||||||
|
- battery variant — `pv_batteries` at a single fixed representative capacity (**5 kWh**).
|
||||||
|
|
||||||
|
**Overshading is derived from Google's expected generation, not just peak power.** Google's `yearlyEnergyDcKwh` per segment already encodes real orientation, tilt and shading from imagery. Per segment we compute the effective shading factor and snap it to the RdSAP bucket:
|
||||||
|
```
|
||||||
|
G_ac = yearlyEnergyDcKwh × 0.955 # inverter DC→AC (GoogleSolarApi rate)
|
||||||
|
E_unshaded = 0.8 × kWp × S(orientation, pitch) # SAP App M/U, ZPV = 1 (reuse calculator S)
|
||||||
|
ZPV_target = G_ac / E_unshaded # orientation+tilt cancel → residual ≈ shading
|
||||||
|
overshading = nearest bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35}, clamped:
|
||||||
|
≥0.90→1 · 0.65–0.90→2 · 0.425–0.65→3 · <0.425→4 ; ZPV_target>1.0 → 1
|
||||||
|
```
|
||||||
|
Because both numerator (Google) and denominator (SAP `S`) already account for orientation/tilt, the ratio isolates shading; the calibration makes SAP's Appendix-M output track Google's imagery-derived generation with overshading as the only free lever. `ZPV_target` absorbs minor model slack (it is *effective* shading, which is what we want). The four cutpoints are documented constants, re-validated against Google example responses; RdSAP has **no "Severe" bucket** (the calculator's map is 1–4).
|
||||||
|
|
||||||
|
**Eligibility encodes physical/legal installability only.** Offer ⇔ house/bungalow ∧ not listed ∧ not heritage ∧ no existing PV ∧ a feasible `SolarPotential`. **A conservation area does NOT block PV** (offered, installed sympathetically) — so the gate is `not restrictions.blocks_internal` (listed/heritage), the *same* predicate as ASHP, **not** `blocks_external`. Counter-intuitive for an external measure, but it matches the legacy and planning reality (panels routinely get consent in conservation areas on non-prominent roofs). Flats/maisonettes need building-level shared-roof coordination → deferred.
|
||||||
|
|
||||||
|
**Costing: a composite per-dwelling cost from the EA-rate schedule, one price point.**
|
||||||
|
```
|
||||||
|
PV bundle = pv_system(kWp band) # ECOPV07-13 EA rate, nearest kWp
|
||||||
|
+ scaffolding(£900 + £450 × (elevations − 1)) # default 2 elevations
|
||||||
|
+ enabling base (EICR £150 + DNO £50 + consumer unit £330)
|
||||||
|
+ [diverter £980 if cylinder]
|
||||||
|
+ [battery £2,000 if the with-battery variant]
|
||||||
|
→ Cost(total, contingency_rate = 0.15)
|
||||||
|
```
|
||||||
|
The **EA-rate** column is canonical; the PM (Domna) column adds principal contracting, priced separately. Conditional extras (bird protection, GSM, isolator) are absorbed by contingency, not itemised. Composed via the `Products` collection's `solar_bundle_cost`, mirroring ASHP (ADR-0025).
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **One optimally-sized array (take-it-or-leave-it).** Rejected: PV size is a genuine cost/benefit dial the Optimiser should own (CONTEXT already models PV as competing kWp Options); a single array removes that choice.
|
||||||
|
- **Read the potential array off the EPC's `photovoltaic_arrays`.** Rejected: conflates *existing* PV with *potential* PV. The potential comes from the Solar API as a separate typed input.
|
||||||
|
- **Default every array to "no shading."** Rejected in favour of generation-calibrated overshading — the Solar API carries real expected generation, so we use it rather than assume.
|
||||||
|
- **Derive shading from raw `sunshineQuantiles ÷ maxSunshineHoursPerYear`.** Rejected: the max is the best-oriented point, so the ratio conflates shading with orientation. Dividing Google's generation by SAP's own `S` cancels orientation/tilt cleanly.
|
||||||
|
- **Full-roof / 80% coverage (legacy).** Rejected as too optimistic — imagery misses obstructions; 70% + north-exclusion is the conservative cap.
|
||||||
|
- **Multiple product price points (legacy).** Rejected: it exploded the option count. One price point per config from the new rate sheet.
|
||||||
|
- **The PM (Domna) price column.** Rejected as the install cost: it bundles principal contracting, a separately-priced concern.
|
||||||
|
- **Blocking PV in conservation areas (`blocks_external`).** Rejected: legacy and planning practice allow sympathetic conservation-area installs; only listed/heritage block.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A new **`SolarPotential`** domain type + a strictly-typed Google `buildingInsights` projection (per the API doc); the existing `SolarRepository`/JSONB store already anticipates it. `ModellingOrchestrator` reads it and threads it into `recommend_solar`.
|
||||||
|
- `EpcSimulation` grows its **sixth** overlay surface (`solar: Optional[SolarOverlay]`) + `_fold_solar` onto `sap_energy_source` (`photovoltaic_arrays`, `pv_diverter_present`, `pv_connection`, `is_dwelling_export_capable`, `pv_batteries`).
|
||||||
|
- `Products` gains `solar_bundle_cost`; the EA rate lines land in the committed costs file (ADR-0025 pattern). PV priced under the legacy `MEASURE_MAP` name `solar_pv` (contingency 0.15).
|
||||||
|
- **Flagged estimates** (replace from the DB / cleaner data): the **£2,000 battery** and **5 kWh** capacity are not on the rate cards; the overshading cutpoints await calibration against Google example responses.
|
||||||
|
- A **cylinder `"No Insulation"` mapper gap** blocks parsing the example certs → fix as a slice (maps to `cylinder_insulation_type = None`; the API path hits it too).
|
||||||
|
- Validation: Elmhurst before/after Summaries pin the overlay→calculator cascade across **orientation / pitch / overshading** (the varied test vectors); a **before with no export meter** is wanted to pin the export-capable flip; the Solar-API→`SolarPotential`→overshading mapping is pinned against Google example responses separately.
|
||||||
|
- **Deferred (named gaps):** building-level (shared-roof) flats; existing-PV top-up; the sympathetic / non-street-facing siting caveat (no caveat field, no reliable street geometry); size-to-dwelling battery; `sunshineQuantiles` cutpoint calibration; the financial-analysis / ROI data Google also returns (not needed for SAP).
|
||||||
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.
|
||||||
|
|
@ -37,26 +37,32 @@ Produced by **Bill Derivation**: the calculator's **delivered** kWh per end use
|
||||||
Per-section kWh is *delivered fuel* (demand ÷ efficiency — what the household pays for), distinct
|
Per-section kWh is *delivered fuel* (demand ÷ efficiency — what the household pays for), distinct
|
||||||
from the recorded-demand `space_heating_kwh`/`water_heating_kwh` above which it supersedes.
|
from the recorded-demand `space_heating_kwh`/`water_heating_kwh` above which it supersedes.
|
||||||
|
|
||||||
|
All columns below are **nullable** (every one is `Optional[float]`, default `None`) and **FE-owned
|
||||||
|
(Drizzle)**. The `bill_` prefix is deliberate: it keeps the per-section columns from clashing with
|
||||||
|
the recorded-demand `space_heating_kwh` / `water_heating_kwh` above. The whole block is `None` for
|
||||||
|
one row together when no calculator ran (the stub path produced no `SapResult` to price); a section
|
||||||
|
absent from the bill leaves its two columns `None` (not `0` — it was not billed). `to_domain` uses
|
||||||
|
`bill_total_annual_bill_gbp IS NOT NULL` as the discriminator for "a bill was persisted".
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `fuel_rates_period` | text | which Fuel Rates snapshot priced this bill (e.g. `"2026-04 to 2026-06"`) — provenance |
|
| `bill_heating_kwh` | float, nullable | delivered fuel kWh (main + main-2 + secondary heating) |
|
||||||
| `heating_kwh` | float | delivered fuel kWh (main + secondary heating) |
|
| `bill_heating_cost_gbp` | float, nullable | priced at the heating fuel's current rate |
|
||||||
| `heating_cost_gbp` | float | priced at the heating fuel's current rate |
|
| `bill_hot_water_kwh` | float, nullable | |
|
||||||
| `hot_water_kwh` | float | |
|
| `bill_hot_water_cost_gbp` | float, nullable | |
|
||||||
| `hot_water_cost_gbp` | float | |
|
| `bill_lighting_kwh` | float, nullable | |
|
||||||
| `lighting_kwh` | float | |
|
| `bill_lighting_cost_gbp` | float, nullable | |
|
||||||
| `lighting_cost_gbp` | float | |
|
| `bill_appliances_kwh` | float, nullable | unregulated load — `None` until the appliances field lands on `SapResult` |
|
||||||
| `appliances_kwh` | float | unregulated load — **0 until the appliances/cooking fields land on `SapResult`** (ADR-0014 TODO) |
|
| `bill_appliances_cost_gbp` | float, nullable | |
|
||||||
| `appliances_cost_gbp` | float | |
|
| `bill_cooking_kwh` | float, nullable | unregulated load — `None` until `SapResult` carries it |
|
||||||
| `cooking_kwh` | float | unregulated load — 0 until `SapResult` carries it |
|
| `bill_cooking_cost_gbp` | float, nullable | |
|
||||||
| `cooking_cost_gbp` | float | |
|
| `bill_pumps_fans_kwh` | float, nullable | |
|
||||||
| `pumps_fans_kwh` | float | |
|
| `bill_pumps_fans_cost_gbp` | float, nullable | |
|
||||||
| `pumps_fans_cost_gbp` | float | |
|
| `bill_cooling_kwh` | float, nullable | mostly absent in UK homes; carried for completeness as it affects the bill |
|
||||||
| `cooling_kwh` | float | mostly 0 in UK homes; carried for completeness as it affects the bill |
|
| `bill_cooling_cost_gbp` | float, nullable | |
|
||||||
| `cooling_cost_gbp` | float | |
|
| `bill_standing_charges_gbp` | float, nullable | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
|
||||||
| `standing_charges_gbp` | float | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
|
| `bill_seg_credit_gbp` | float, nullable | SEG export credit on PV (subtracted) |
|
||||||
| `seg_credit_gbp` | float | SEG export credit on PV (subtracted) |
|
| `bill_total_annual_bill_gbp` | float, nullable | Σ section costs + standing charges − SEG; the not-null discriminator for a persisted bill |
|
||||||
| `total_annual_bill_gbp` | float | Σ section costs + standing charges − SEG |
|
|
||||||
|
|
||||||
The calculator is **load-bearing** (ADR-0013 amendment): for `sap_version < 10.2` the `effective_*`
|
The calculator is **load-bearing** (ADR-0013 amendment): for `sap_version < 10.2` the `effective_*`
|
||||||
columns hold the calculator's output (so `effective_* != lodged_*` legitimately); at/above 10.2 they
|
columns hold the calculator's output (so `effective_* != lodged_*` legitimately); at/above 10.2 they
|
||||||
|
|
@ -65,7 +71,8 @@ batch rather than persisting a wrong row.
|
||||||
|
|
||||||
### Population timing
|
### Population timing
|
||||||
|
|
||||||
The bill columns are **defined now so the FE can create them**, but are populated only once the
|
The bill columns are now **populated**: the `PropertyBaselineOrchestrator` reads the current Fuel
|
||||||
`SapResult` → `EnergyBreakdown` adapter + `BillDerivation` wiring land (gated on the appliances /
|
Rates snapshot, builds a `BillDerivation`, and prices every scored property's `SapResult` →
|
||||||
cooking `SapResult` fields). Until then the SQLModel mirror in `infrastructure/postgres/` adds these
|
`EnergyBreakdown` into a `Bill` that `from_domain` flattens onto these columns. They stay `None`
|
||||||
columns as nullable; the Drizzle migration can create them nullable in parallel.
|
together only on the stub (no-calculator) path. The appliances / cooking sections remain `None`
|
||||||
|
until those fields land on `SapResult`. The Drizzle migration creates all `bill_*` columns nullable.
|
||||||
|
|
|
||||||
45
docs/migrations/recommendation-material-id.md
Normal file
45
docs/migrations/recommendation-material-id.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Retire `recommendation_materials` — reference the Product by `recommendation.material_id`
|
||||||
|
|
||||||
|
**Context:** Modelling-stage rebuild. A Plan Measure installs a single **Product**, so the per-material `recommendation_materials` child table (depth / quantity / quantity_unit / estimated_cost per row) is replaced by a single **`recommendation.material_id`** on the row and then **dropped**. Same motivation and shape as the [`plan_recommendations` retirement](./recommendation-plan-id.md): the child table's cascade-delete + indexes are a known performance killer on large deletes. The `plan`/`recommendation`/`recommendation_materials` tables are read directly by the Drizzle FE and written by both the legacy `engine.py` path and the rebuild, so this is an **expand/contract migration on a live, two-repo schema**. The **DB migrations are FE-owned (Drizzle)**; this doc pins the ordering so the repos stay in step. See [ADR-0017](../adr/0017-plan-persistence-evolve-live-tables.md).
|
||||||
|
|
||||||
|
## Cardinality
|
||||||
|
|
||||||
|
`recommendation_materials` is **one-to-many in practice** (one recommendation → its material lines), but for the four modelled fabric measures each Option installs exactly **one** Product, so a single `recommendation.material_id` models reality faithfully. A future *bundle* Option that genuinely needs multiple Products (e.g. boiler + cylinder insulation) is out of scope and revisited when those measures land — it is a new decision, not a regression of this one.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Expand half landed in the backend** (this branch): the `ModellingOrchestrator` now threads the catalogue id `Product.id → MeasureOption.material_id → PlanMeasure.material_id → recommendation.material_id`, and `RecommendationModel` declares the column. The repo SQLModel is a **read-only mirror** — it does not migrate the live DB.
|
||||||
|
|
||||||
|
**The contraction is the owner's, starting next (with its own ADR):** cut the legacy writers (`recommendations_functions.upload_recommendations` / `bulk_upload_recommendations_and_materials`) off `recommendation_materials`, backfill `material_id`, drop the child table, and decide the disposition of `depth` / `quantity` / `quantity_unit` (kept-for-reference vs dropped — see the grilling notes; `quantity` has reference value).
|
||||||
|
|
||||||
|
## Sequence (expand → backfill → migrate reads → contract)
|
||||||
|
|
||||||
|
The hard rule: **add the `material_id` column live before the backend that writes it deploys** (else the rebuild's `recommendation` INSERT fails on an unknown column).
|
||||||
|
|
||||||
|
| # | Step | Owner | Safe because |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Add `recommendation.material_id`** — `bigint`, indexed, **nullable**, no FK constraint (mirror convention; the live FK to `material` is the FE's call) | FE (Drizzle) | additive; legacy rows keep `NULL` |
|
||||||
|
| 2 | **Deploy the rebuild backend** (writes `material_id` from the catalogue) | backend | column exists from (1); nullable so unbilled / JSON-catalogue measures write `NULL` |
|
||||||
|
| 3 | **Backfill** `material_id` from `recommendation_materials` (single-material rows) | FE (Drizzle data migration) | every existing measure gets its Product before any read cuts over |
|
||||||
|
| 4 | **Cut FE reads** off `recommendation_materials` onto `material_id` | FE | backfill (3) means no NULLs for single-material measures |
|
||||||
|
| 5 | **Stop writing `recommendation_materials`** (legacy writers) | backend | no reader uses it after (4) |
|
||||||
|
| 6 | **Drop `recommendation_materials`** + remove the `RecommendationMaterialModel` mirror | FE (Drizzle) + backend | unreferenced after (5) |
|
||||||
|
|
||||||
|
### Backfill SQL sketch (step 3)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE recommendation r
|
||||||
|
SET material_id = rm.material_id
|
||||||
|
FROM recommendation_materials rm
|
||||||
|
WHERE rm.recommendation_id = r.id
|
||||||
|
AND r.material_id IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Guard before dropping the child table: assert no recommendation maps to more than one material (the modelled fabric measures never produce this; worth checking on real data before the drop):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT recommendation_id, count(*)
|
||||||
|
FROM recommendation_materials
|
||||||
|
GROUP BY recommendation_id
|
||||||
|
HAVING count(*) > 1;
|
||||||
|
```
|
||||||
62
docs/migrations/recommendation-plan-id.md
Normal file
62
docs/migrations/recommendation-plan-id.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Retire `plan_recommendations` — link measures by `recommendation.plan_id`
|
||||||
|
|
||||||
|
**Context:** Modelling-stage rebuild. The `ModellingOrchestrator` persists a **Plan** and its selected **Plan Measures** (rows of the live `recommendation` table). A measure belongs to exactly one Plan, so the `plan_recommendations` many-to-many is replaced by a direct **`recommendation.plan_id`** FK and then **dropped**. The m2m's cascade delete is the known performance killer this change removes (see [ADR-0017](../adr/0017-plan-persistence-evolve-live-tables.md)).
|
||||||
|
|
||||||
|
The plan/recommendation/scenario tables are read **directly by the Drizzle FE** and written by both the legacy `engine.py` path and the rebuild. So this is an expand/contract migration on a live, **two-repo** (Python backend + Drizzle FE) schema. The **DB migrations are FE-owned (Drizzle)**; this doc is their spec and pins the ordering so the repos stay in step.
|
||||||
|
|
||||||
|
## Cardinality
|
||||||
|
|
||||||
|
`plan_recommendations` is **one-to-many in practice, never many-to-many**: both writers (`upload_recommendations`, `bulk_upload_recommendations_and_materials`) create *fresh* `recommendation` rows per Plan and link each to a single `plan_id`. A recommendation is never shared across Plans, so a single `recommendation.plan_id` FK models reality faithfully and the backfill is a clean 1:1.
|
||||||
|
|
||||||
|
## Sequence (expand → backfill → migrate reads → contract)
|
||||||
|
|
||||||
|
The two hard rules: **backfill before any reader cuts to `plan_id`** (else every historical Plan — all `plan_id = NULL`, linked only via the m2m — vanishes from the FE), and **dual-write the m2m through the transition** (so backend and FE reads can each cut to `plan_id` independently, in any order, with zero breakage; the m2m write is removed only at the end).
|
||||||
|
|
||||||
|
| # | Step | Owner | Safe because |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Add `recommendation.plan_id`** — `bigint`, FK → `plan.id`, **`ON DELETE CASCADE`**, indexed, **nullable** | FE (Drizzle) | additive; legacy rows keep `NULL` |
|
||||||
|
| 2 | **Backfill** `plan_id` from the m2m (see SQL below) | FE (Drizzle data migration) | every existing measure gets its Plan before any read cuts over |
|
||||||
|
| 3 | **Dual-write**: writers set `plan_id` **and** keep writing the m2m | backend | both old (m2m) and new (`plan_id`) readers work |
|
||||||
|
| 4 | **Cut reads to `plan_id`** — backend (`portfolio_functions`, `Outputs`, `export/property_scenarios`) **and** the Drizzle FE | backend + FE | backfill (2) means no NULLs; dual-write (3) means order between repos is free |
|
||||||
|
| 5 | **Stop writing the m2m** | backend | no reader uses it after (4) |
|
||||||
|
| 6 | **Drop `plan_recommendations`** | FE (Drizzle) + backend (remove model) | unreferenced after (5) |
|
||||||
|
|
||||||
|
### Backfill SQL (step 2)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE recommendation r
|
||||||
|
SET plan_id = pr.plan_id
|
||||||
|
FROM plan_recommendations pr
|
||||||
|
WHERE pr.recommendation_id = r.id
|
||||||
|
AND r.plan_id IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Guard before dropping the m2m: assert no recommendation maps to more than one Plan (a data anomaly the writers can't produce, but worth checking on real data):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT recommendation_id, count(*)
|
||||||
|
FROM plan_recommendations
|
||||||
|
GROUP BY recommendation_id
|
||||||
|
HAVING count(*) > 1;
|
||||||
|
-- expect zero rows
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1 — column definition
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `plan_id` | bigint, FK → `plan.id`, **`ON DELETE CASCADE`**, indexed, nullable | the Plan this measure belongs to. Nullable during transition; every new write sets it. |
|
||||||
|
|
||||||
|
- **Index `plan_id`** — the rebuild's idempotent replace deletes a Plan and relies on the cascade to remove its measures; reads fetch a Plan's measures by `plan_id`.
|
||||||
|
- **`ON DELETE CASCADE`** makes "delete the Plan → its measures go too" a single statement, replacing the m2m cleanup.
|
||||||
|
|
||||||
|
## This repo's part (all of steps 3–6, gated on 1+2 being live)
|
||||||
|
|
||||||
|
The user's instruction is to implement the backend end-to-end **as if the FE has already applied steps 1 and 2** (the `plan_id` column exists and is backfilled). Concretely, in `backend/` + the rebuild:
|
||||||
|
|
||||||
|
- The plan/recommendation/scenario/installed-measure models are **consolidated into `infrastructure/postgres/modelling/`** as single SQLModel definitions (`…Row`), `recommendation` carrying `plan_id`; `backend/app/db/models/recommendations.py` becomes a re-export shim (ADR-0017 amendment).
|
||||||
|
- Writers set `plan_id`; readers join on `plan_id`; the m2m write/cleanup and the `PlanRecommendations` model are removed.
|
||||||
|
|
||||||
|
## Not changed here
|
||||||
|
|
||||||
|
No new contingency columns (per-measure contingency stays summed into `plan.contingency_cost`); no `phase` column (multi-phase deferred, ADR-0005). The `etl/` and `sfr/` reporting scripts that read the m2m are **out of scope** — handled in a later pass.
|
||||||
0
domain/billing/__init__.py
Normal file
0
domain/billing/__init__.py
Normal file
135
domain/billing/bill.py
Normal file
135
domain/billing/bill.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from domain.fuel_rates.fuel import Fuel
|
||||||
|
from domain.billing.sap_fuel import sap_code_to_fuel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from domain.sap10_calculator.calculator import SapResult
|
||||||
|
|
||||||
|
|
||||||
|
class BillSection(Enum):
|
||||||
|
"""A user-meaningful slice of the annual energy bill — the calculator's raw
|
||||||
|
end uses folded into the sections the UI shows (ADR-0014)."""
|
||||||
|
|
||||||
|
HEATING = "HEATING"
|
||||||
|
HOT_WATER = "HOT_WATER"
|
||||||
|
LIGHTING = "LIGHTING"
|
||||||
|
APPLIANCES = "APPLIANCES"
|
||||||
|
COOKING = "COOKING"
|
||||||
|
PUMPS_FANS = "PUMPS_FANS"
|
||||||
|
COOLING = "COOLING"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnergyLine:
|
||||||
|
"""One section's delivered energy on one fuel. A section may have more than
|
||||||
|
one line (e.g. gas main heating + electric secondary heating)."""
|
||||||
|
|
||||||
|
section: BillSection
|
||||||
|
fuel: Fuel
|
||||||
|
kwh: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnergyBreakdown:
|
||||||
|
"""A Property's delivered energy per end use, the input to Bill Derivation.
|
||||||
|
``exported_kwh`` is PV generation exported to the grid, credited at the SEG
|
||||||
|
rate."""
|
||||||
|
|
||||||
|
lines: Sequence[EnergyLine]
|
||||||
|
exported_kwh: float = 0.0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sap_result(cls, result: "SapResult") -> "EnergyBreakdown":
|
||||||
|
"""Fold a calculator `SapResult`'s per-end-use delivered kWh into billable
|
||||||
|
`EnergyLine`s (ADR-0014). Heating (main / main-2 / secondary) and hot water
|
||||||
|
are billed at their resolved fuel (`sap_code_to_fuel`); lighting / pumps-
|
||||||
|
fans / appliances / cooking / cooling are electricity by construction. A
|
||||||
|
line is emitted only when its kWh is positive; PV export carries to
|
||||||
|
`exported_kwh` for the SEG credit. The `from_*` factory mirrors
|
||||||
|
`Performance.from_sap_result`; living on the target keeps the calculator
|
||||||
|
free of any `property_baseline` dependency."""
|
||||||
|
candidates = [
|
||||||
|
_fuelled_line(
|
||||||
|
BillSection.HEATING,
|
||||||
|
result.main_heating_fuel_code,
|
||||||
|
result.main_heating_fuel_kwh_per_yr,
|
||||||
|
),
|
||||||
|
_fuelled_line(
|
||||||
|
BillSection.HEATING,
|
||||||
|
result.main_2_heating_fuel_code,
|
||||||
|
result.main_2_heating_fuel_kwh_per_yr,
|
||||||
|
),
|
||||||
|
_fuelled_line(
|
||||||
|
BillSection.HEATING,
|
||||||
|
result.secondary_heating_fuel_code,
|
||||||
|
result.secondary_heating_fuel_kwh_per_yr,
|
||||||
|
),
|
||||||
|
_fuelled_line(
|
||||||
|
BillSection.HOT_WATER,
|
||||||
|
result.hot_water_fuel_code,
|
||||||
|
result.hot_water_kwh_per_yr,
|
||||||
|
),
|
||||||
|
_electric_line(BillSection.LIGHTING, result.lighting_kwh_per_yr),
|
||||||
|
_electric_line(BillSection.PUMPS_FANS, result.pumps_fans_kwh_per_yr),
|
||||||
|
_electric_line(BillSection.APPLIANCES, result.appliances_kwh_per_yr),
|
||||||
|
_electric_line(BillSection.COOKING, result.cooking_kwh_per_yr),
|
||||||
|
_electric_line(BillSection.COOLING, result.space_cooling_fuel_kwh_per_yr),
|
||||||
|
]
|
||||||
|
return cls(
|
||||||
|
lines=[line for line in candidates if line is not None],
|
||||||
|
exported_kwh=result.pv_exported_kwh_per_yr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fuelled_line(
|
||||||
|
section: BillSection, fuel_code: Optional[int], kwh: float
|
||||||
|
) -> Optional[EnergyLine]:
|
||||||
|
"""An `EnergyLine` for a fuelled end use, or None when it has no energy. A
|
||||||
|
positive kWh with no resolved fuel code is a data gap — raise rather than
|
||||||
|
bill it at a default (mirrors the calculator's strict-raise discipline)."""
|
||||||
|
if kwh <= 0:
|
||||||
|
return None
|
||||||
|
if fuel_code is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{section.value} has {kwh} kWh but no fuel code on the SapResult; "
|
||||||
|
"cannot attribute a billing fuel"
|
||||||
|
)
|
||||||
|
return EnergyLine(section=section, fuel=sap_code_to_fuel(fuel_code), kwh=kwh)
|
||||||
|
|
||||||
|
|
||||||
|
def _electric_line(section: BillSection, kwh: float) -> Optional[EnergyLine]:
|
||||||
|
"""An electricity `EnergyLine` for an electric end use, or None when zero."""
|
||||||
|
if kwh <= 0:
|
||||||
|
return None
|
||||||
|
return EnergyLine(section=section, fuel=Fuel.ELECTRICITY, kwh=kwh)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BillSectionCost:
|
||||||
|
"""One section's rolled-up delivered kWh and annual cost (£)."""
|
||||||
|
|
||||||
|
kwh: float
|
||||||
|
cost_gbp: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Bill:
|
||||||
|
"""A Property's annual energy bill, composed per section plus the per-meter
|
||||||
|
standing charges and the SEG export credit, and the total (ADR-0014)."""
|
||||||
|
|
||||||
|
sections: Mapping[BillSection, BillSectionCost]
|
||||||
|
standing_charges_gbp: float
|
||||||
|
seg_credit_gbp: float
|
||||||
|
total_gbp: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_consumption_kwh(self) -> float:
|
||||||
|
"""Total delivered energy (kWh) across the billed sections. Standing
|
||||||
|
charges and the SEG credit are £, not energy, so they don't count."""
|
||||||
|
return sum((section.kwh for section in self.sections.values()), 0.0)
|
||||||
|
|
@ -5,7 +5,7 @@ from typing import Final
|
||||||
|
|
||||||
from domain.fuel_rates.fuel import Fuel
|
from domain.fuel_rates.fuel import Fuel
|
||||||
from domain.fuel_rates.fuel_rates import FuelRates
|
from domain.fuel_rates.fuel_rates import FuelRates
|
||||||
from domain.property_baseline.bill import (
|
from domain.billing.bill import (
|
||||||
Bill,
|
Bill,
|
||||||
BillSection,
|
BillSection,
|
||||||
BillSectionCost,
|
BillSectionCost,
|
||||||
62
domain/building_geometry.py
Normal file
62
domain/building_geometry.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Building geometry derived purely from an EpcPropertyData.
|
||||||
|
|
||||||
|
Reusable outside the SAP calculator (e.g. for Modelling cost quantities).
|
||||||
|
Today this re-derives the heat-loss wall area; the calculator computes the
|
||||||
|
same quantity inline (`heat_transmission._part_geometry`). A later, calculator-
|
||||||
|
branch-coordinated refactor should DRY the two onto this module so there is a
|
||||||
|
single source of truth. See the project memory on calculator geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gross_heat_loss_wall_area(
|
||||||
|
epc: EpcPropertyData, identifier: BuildingPartIdentifier
|
||||||
|
) -> float:
|
||||||
|
"""Gross external wall area of one building part, in m^2: the sum over its
|
||||||
|
storeys of heat-loss perimeter x room height. This is the heat-loss area
|
||||||
|
(party walls are excluded — they are not on the heat-loss perimeter); it is
|
||||||
|
not netted of window/door openings.
|
||||||
|
"""
|
||||||
|
part = next(
|
||||||
|
candidate
|
||||||
|
for candidate in epc.sap_building_parts
|
||||||
|
if candidate.identifier is identifier
|
||||||
|
)
|
||||||
|
area = sum(
|
||||||
|
fd.heat_loss_perimeter_m * fd.room_height_m
|
||||||
|
for fd in part.sap_floor_dimensions
|
||||||
|
)
|
||||||
|
return round(area, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def roof_area(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> float:
|
||||||
|
"""Roof area of one building part, in m^2. Per RdSAP10 §3.8 the roof area is
|
||||||
|
the greatest of the part's per-storey floor areas (not the top-floor area,
|
||||||
|
which can be smaller)."""
|
||||||
|
part = next(
|
||||||
|
candidate
|
||||||
|
for candidate in epc.sap_building_parts
|
||||||
|
if candidate.identifier is identifier
|
||||||
|
)
|
||||||
|
return round(
|
||||||
|
max(fd.total_floor_area_m2 for fd in part.sap_floor_dimensions), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ground_floor_area(
|
||||||
|
epc: EpcPropertyData, identifier: BuildingPartIdentifier
|
||||||
|
) -> float:
|
||||||
|
"""Ground-floor area of one building part, in m^2 — the area of its lowest
|
||||||
|
floor (``floor == 0``), the surface a ground-floor insulation measure
|
||||||
|
treats."""
|
||||||
|
part = next(
|
||||||
|
candidate
|
||||||
|
for candidate in epc.sap_building_parts
|
||||||
|
if candidate.identifier is identifier
|
||||||
|
)
|
||||||
|
ground = next(fd for fd in part.sap_floor_dimensions if fd.floor == 0)
|
||||||
|
return round(ground.total_floor_area_m2, 2)
|
||||||
35
domain/geospatial/planning_restrictions.py
Normal file
35
domain/geospatial/planning_restrictions.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""A Property's planning protections, resolved from geospatial reference data.
|
||||||
|
|
||||||
|
Three distinct flags (never the legacy collapsed `restricted_measures` boolean
|
||||||
|
— ADR-0020): a conservation area, a listed building, a heritage building. They
|
||||||
|
gate retrofit measures differently — a conservation area blocks external work
|
||||||
|
only, while listed/heritage protect the fabric itself — so the
|
||||||
|
measure-specific interpretation (`blocks_external` / `blocks_internal`) lives
|
||||||
|
here as derived queries. Sourced onto the Property from the geospatial layer
|
||||||
|
(co-located with the coordinates); defaults to unrestricted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlanningRestrictions:
|
||||||
|
"""The planning protections on a Property that gate wall insulation
|
||||||
|
(ADR-0019). Defaults to unrestricted."""
|
||||||
|
|
||||||
|
in_conservation_area: bool = False
|
||||||
|
is_listed: bool = False
|
||||||
|
is_heritage: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocks_external(self) -> bool:
|
||||||
|
"""External wall insulation is blocked by any protection (it alters the
|
||||||
|
external appearance / protected fabric)."""
|
||||||
|
return self.in_conservation_area or self.is_listed or self.is_heritage
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocks_internal(self) -> bool:
|
||||||
|
"""Internal wall insulation is blocked only where the fabric itself is
|
||||||
|
protected — a listed or heritage building, not a plain conservation
|
||||||
|
area."""
|
||||||
|
return self.is_listed or self.is_heritage
|
||||||
25
domain/geospatial/spatial_reference.py
Normal file
25
domain/geospatial/spatial_reference.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""One UPRN's row of Ordnance Survey spatial reference data.
|
||||||
|
|
||||||
|
Bundles the two things the geospatial partition co-locates against a UPRN — the
|
||||||
|
coordinates (which drive the Solar fetch) and the planning protections (which
|
||||||
|
gate wall insulation, ADR-0019/ADR-0020) — so Ingestion resolves them in a
|
||||||
|
single reference lookup and persists them together as a write-through cache
|
||||||
|
(`property_details_spatial`). Coordinates are Optional because the legacy
|
||||||
|
nearest-UPRN proxy fallback yields the flags with the coordinates nulled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from domain.geospatial.coordinates import Coordinates
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SpatialReference:
|
||||||
|
"""A Property's resolved spatial reference data, keyed by UPRN."""
|
||||||
|
|
||||||
|
coordinates: Optional[Coordinates]
|
||||||
|
restrictions: PlanningRestrictions
|
||||||
0
domain/modelling/__init__.py
Normal file
0
domain/modelling/__init__.py
Normal file
26
domain/modelling/ashp_rates.json
Normal file
26
domain/modelling/ashp_rates.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"_source": "Southern Housing Group ASHP rates (HEAT PUMPS tab, ECOHT01-68); see ADR-0025. Fully-loaded supply+install rates in GBP.",
|
||||||
|
"decommission": {
|
||||||
|
"electric_storage_small": 570.0,
|
||||||
|
"electric_storage_large": 840.0,
|
||||||
|
"gas": 720.0,
|
||||||
|
"oil": 720.0,
|
||||||
|
"lpg": 960.0
|
||||||
|
},
|
||||||
|
"heat_pump_bands": [[5.0, 9720.0], [8.0, 9840.0], [11.0, 10200.0], [15.0, 10680.0]],
|
||||||
|
"heat_pump_top_price": 11400.0,
|
||||||
|
"cylinder": 2382.60,
|
||||||
|
"distribution_by_radiators": {
|
||||||
|
"4": 2220.0,
|
||||||
|
"5": 2550.0,
|
||||||
|
"6": 3084.0,
|
||||||
|
"7": 3618.0,
|
||||||
|
"8": 4152.0,
|
||||||
|
"9": 4680.0,
|
||||||
|
"10": 5220.0,
|
||||||
|
"11": 5754.0,
|
||||||
|
"12": 6288.0
|
||||||
|
},
|
||||||
|
"distribution_flush": 168.0,
|
||||||
|
"reuse_distribution_fraction": 0.5
|
||||||
|
}
|
||||||
42
domain/modelling/considered_measures.py
Normal file
42
domain/modelling/considered_measures.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Restricting a modelling run to a chosen set of measure types.
|
||||||
|
|
||||||
|
The allowlist a run "considers" — mirroring the legacy engine's `inclusions`
|
||||||
|
(`backend/app/plan/schemas.py`). It filters the candidate Recommendations at the
|
||||||
|
Option level so a multi-option Recommendation (e.g. Heating & Hot Water competing
|
||||||
|
HHRSH against an ASHP bundle) is kept with only its allowed Options; a
|
||||||
|
Recommendation left with no allowed Option is dropped. The Optimiser still
|
||||||
|
freely chooses among what survives — including choosing nothing.
|
||||||
|
|
||||||
|
A `None` allowlist means "consider every modelled measure" (the unrestricted
|
||||||
|
default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Recommendation
|
||||||
|
|
||||||
|
|
||||||
|
def restrict_to_considered_measures(
|
||||||
|
recommendations: Iterable[Recommendation],
|
||||||
|
considered_measures: Optional[frozenset[MeasureType]],
|
||||||
|
) -> list[Recommendation]:
|
||||||
|
"""Keep only the Options whose measure type is in ``considered_measures``,
|
||||||
|
dropping any Recommendation left with none. ``None`` keeps everything."""
|
||||||
|
if considered_measures is None:
|
||||||
|
return list(recommendations)
|
||||||
|
restricted: list[Recommendation] = []
|
||||||
|
for recommendation in recommendations:
|
||||||
|
kept = tuple(
|
||||||
|
option
|
||||||
|
for option in recommendation.options
|
||||||
|
if option.measure_type in considered_measures
|
||||||
|
)
|
||||||
|
if kept:
|
||||||
|
restricted.append(
|
||||||
|
Recommendation(surface=recommendation.surface, options=kept)
|
||||||
|
)
|
||||||
|
return restricted
|
||||||
42
domain/modelling/contingencies.py
Normal file
42
domain/modelling/contingencies.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Per-Measure-Type contingency rates.
|
||||||
|
|
||||||
|
The one cost component carried separately from a Product's fully-loaded total
|
||||||
|
(CONTEXT.md). Mirrors the legacy `recommendations/Costs.py::Costs.CONTINGENCIES`;
|
||||||
|
extended as each measure type lands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONTINGENCY_RATES: dict[str, float] = {
|
||||||
|
"cavity_wall_insulation": 0.10,
|
||||||
|
"loft_insulation": 0.10,
|
||||||
|
"sloping_ceiling_insulation": 0.10,
|
||||||
|
"flat_roof_insulation": 0.10,
|
||||||
|
"suspended_floor_insulation": 0.20,
|
||||||
|
"solid_floor_insulation": 0.26,
|
||||||
|
"mechanical_ventilation": 0.26,
|
||||||
|
"external_wall_insulation": 0.26,
|
||||||
|
"internal_wall_insulation": 0.26,
|
||||||
|
"double_glazing": 0.15,
|
||||||
|
"secondary_glazing": 0.15,
|
||||||
|
"low_energy_lighting": 0.26,
|
||||||
|
"high_heat_retention_storage_heaters": 0.10,
|
||||||
|
"air_source_heat_pump": 0.25,
|
||||||
|
"gas_boiler_upgrade": 0.26,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contingency_rate(measure_type: str) -> float:
|
||||||
|
"""Return the contingency rate for a Measure Type, raising if unknown
|
||||||
|
(strict — do not silently default, per the repo's strict-raise convention)."""
|
||||||
|
try:
|
||||||
|
return _CONTINGENCY_RATES[measure_type]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"no contingency rate configured for measure type {measure_type!r}"
|
||||||
|
) from exc
|
||||||
0
domain/modelling/generators/__init__.py
Normal file
0
domain/modelling/generators/__init__.py
Normal file
93
domain/modelling/generators/floor_recommendation.py
Normal file
93
domain/modelling/generators/floor_recommendation.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""The floor Recommendation Generator.
|
||||||
|
|
||||||
|
Detects an uninsulated ground floor and its construction (suspended timber vs
|
||||||
|
solid) and emits a Recommendation whose single Measure Option carries the
|
||||||
|
matching insulation Simulation Overlay and a priced Cost. A floor is one
|
||||||
|
construction, so — like a cavity wall — there is one Option, chosen by
|
||||||
|
detection. No scoring, no persistence (ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
SapBuildingPart,
|
||||||
|
)
|
||||||
|
from domain.building_geometry import ground_floor_area
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
# Recommended ground-floor insulation depth (mm).
|
||||||
|
_RECOMMENDED_FLOOR_THICKNESS_MM = 100
|
||||||
|
# Insulating an as-built floor re-lodges its insulation as retro-fitted. The
|
||||||
|
# calculator keys on this for a suspended timber floor's sealed/unsealed
|
||||||
|
# determination (cert_to_inputs: "retro" + no U-value → sealed), so the
|
||||||
|
# overlay must set it or the suspended-floor cascade leaves a ~1.4 SAP gap
|
||||||
|
# (see test_elmhurst_cascade_pins).
|
||||||
|
_RETROFITTED_INSULATION = "Retro-fitted"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool:
|
||||||
|
"""A lodged floor-insulation thickness of nothing / blank / zero is an
|
||||||
|
uninsulated floor; any positive thickness is already insulated."""
|
||||||
|
if thickness is None:
|
||||||
|
return True
|
||||||
|
if isinstance(thickness, int):
|
||||||
|
return thickness == 0
|
||||||
|
return thickness.strip() in ("", "0")
|
||||||
|
|
||||||
|
|
||||||
|
def _floor_measure_type(construction_type: Optional[str]) -> Optional[MeasureType]:
|
||||||
|
"""Map the lodged floor construction to the insulation Measure Type, or
|
||||||
|
None when the construction is not a treatable suspended/solid floor."""
|
||||||
|
text = (construction_type or "").lower()
|
||||||
|
if "suspended" in text:
|
||||||
|
return MeasureType.SUSPENDED_FLOOR_INSULATION
|
||||||
|
if "solid" in text:
|
||||||
|
return MeasureType.SOLID_FLOOR_INSULATION
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_floor_insulation(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a ground-floor insulation Recommendation for the main part's
|
||||||
|
uninsulated ground floor, else None."""
|
||||||
|
main: SapBuildingPart = next(
|
||||||
|
part
|
||||||
|
for part in epc.sap_building_parts
|
||||||
|
if part.identifier is BuildingPartIdentifier.MAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _is_uninsulated(main.floor_insulation_thickness):
|
||||||
|
return None
|
||||||
|
|
||||||
|
measure_type = _floor_measure_type(main.floor_construction_type)
|
||||||
|
if measure_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
product = products.get(measure_type)
|
||||||
|
area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
|
||||||
|
cost = Cost(
|
||||||
|
total=area * product.unit_cost_per_m2,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
option = MeasureOption(
|
||||||
|
measure_type=measure_type,
|
||||||
|
description="Ground-floor insulation",
|
||||||
|
overlay=EpcSimulation(
|
||||||
|
building_parts={
|
||||||
|
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
|
||||||
|
floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM,
|
||||||
|
floor_insulation_type_str=_RETROFITTED_INSULATION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Ground floor", options=(option,))
|
||||||
117
domain/modelling/generators/glazing_recommendation.py
Normal file
117
domain/modelling/generators/glazing_recommendation.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""The glazing Recommendation Generator (double / secondary glazing).
|
||||||
|
|
||||||
|
Detects a dwelling's single-glazed windows and emits one "Windows"
|
||||||
|
Recommendation carrying a single, planning-picked Measure Option (ADR-0022).
|
||||||
|
Unlike the wall generator's competing EWI/IWI Options, the Property's
|
||||||
|
`PlanningRestrictions` *hard-picks* the Measure: an unrestricted dwelling gets
|
||||||
|
`double_glazing`; any conservation/listed/heritage protection (i.e.
|
||||||
|
`blocks_external`) forces `secondary_glazing` — an internal second pane that
|
||||||
|
leaves the protected external units untouched.
|
||||||
|
|
||||||
|
All single-glazed windows are upgraded together in one overlay. The overlay
|
||||||
|
writes each window's lodged `u_value` and `solar_transmittance` (not just
|
||||||
|
`glazing_type`), because our calculator reads those per-window values directly
|
||||||
|
from `WindowTransmissionDetails` rather than deriving them from the glazing
|
||||||
|
type (`heat_transmission.py:490`, `solar_gains.py:300`); `glazing_type` is set
|
||||||
|
too, for the §5 daylight factor. The target values are pinned from cert 001431's
|
||||||
|
before→after re-lodgement. Detection + pricing only; impact is produced later by
|
||||||
|
scoring (ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final, Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
|
from domain.modelling.simulation import EpcSimulation, WindowOverlay
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
# Single-glazing codes — the only windows this generator upgrades. Code 1 is
|
||||||
|
# bare "Single"/"Single glazing"; code 15 is "single glazing, known data" (a
|
||||||
|
# single pane with manufacturer U/g lodged — same g_L=0.90 as code 1, per
|
||||||
|
# RdSAP-21). Both are single-glazed and must be detected, or a cert that lodges
|
||||||
|
# manufacturer data on its single panes (e.g. 001431 windows 12-13) is missed.
|
||||||
|
_SINGLE_GLAZED_CODES: Final[frozenset[int]] = frozenset({1, 15})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _GlazingTarget:
|
||||||
|
"""The planning-picked Measure and the per-window values its overlay lodges,
|
||||||
|
pinned from cert 001431's before→after (`glazing_type`, `u_value`,
|
||||||
|
`solar_transmittance` — SAP10.2 Table U2 code, then heat-loss U and solar g).
|
||||||
|
"""
|
||||||
|
|
||||||
|
measure_type: MeasureType
|
||||||
|
description: str
|
||||||
|
glazing_type: int
|
||||||
|
u_value: float
|
||||||
|
solar_transmittance: float
|
||||||
|
|
||||||
|
|
||||||
|
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
|
||||||
|
# U 4.80→1.40, g 0.85→0.72).
|
||||||
|
_DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
|
||||||
|
measure_type=MeasureType.DOUBLE_GLAZING,
|
||||||
|
description="Replace the single-glazed windows with double glazing",
|
||||||
|
glazing_type=5,
|
||||||
|
u_value=1.40,
|
||||||
|
solar_transmittance=0.72,
|
||||||
|
)
|
||||||
|
# Protected (conservation/listed/heritage): fit an internal secondary pane
|
||||||
|
# (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges;
|
||||||
|
# U→2.90, g unchanged at 0.85 — the existing outer single pane still drives
|
||||||
|
# solar gain). The external units can't be replaced on a protected/over-looked
|
||||||
|
# building, so this is the planning-picked Measure.
|
||||||
|
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
|
||||||
|
measure_type=MeasureType.SECONDARY_GLAZING,
|
||||||
|
description="Fit secondary glazing to the single-glazed windows",
|
||||||
|
glazing_type=11,
|
||||||
|
u_value=2.90,
|
||||||
|
solar_transmittance=0.85,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_glazing(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
products: ProductRepository,
|
||||||
|
restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a glazing Recommendation upgrading every single-glazed window — its
|
||||||
|
single planning-picked Option (double glazing, or secondary glazing where a
|
||||||
|
planning protection blocks replacing the external units) — else None when
|
||||||
|
the dwelling has no single-glazed windows."""
|
||||||
|
single_indices = tuple(
|
||||||
|
index
|
||||||
|
for index, window in enumerate(epc.sap_windows)
|
||||||
|
if window.glazing_type in _SINGLE_GLAZED_CODES
|
||||||
|
)
|
||||||
|
if not single_indices:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target: _GlazingTarget = _SECONDARY if restrictions.blocks_external else _DOUBLE
|
||||||
|
product = products.get(target.measure_type)
|
||||||
|
|
||||||
|
overlay = EpcSimulation(
|
||||||
|
windows={
|
||||||
|
index: WindowOverlay(
|
||||||
|
glazing_type=target.glazing_type,
|
||||||
|
u_value=target.u_value,
|
||||||
|
solar_transmittance=target.solar_transmittance,
|
||||||
|
)
|
||||||
|
for index in single_indices
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cost = Cost(
|
||||||
|
total=len(single_indices) * product.unit_cost_per_m2,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
option = MeasureOption(
|
||||||
|
measure_type=target.measure_type,
|
||||||
|
description=target.description,
|
||||||
|
overlay=overlay,
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Windows", options=(option,))
|
||||||
715
domain/modelling/generators/heating_recommendation.py
Normal file
715
domain/modelling/generators/heating_recommendation.py
Normal file
|
|
@ -0,0 +1,715 @@
|
||||||
|
"""The heating Recommendation Generator.
|
||||||
|
|
||||||
|
Detects a dwelling whose heating system should be replaced and emits one
|
||||||
|
"Heating & Hot Water" Recommendation of competing whole-system bundles — the
|
||||||
|
Optimiser picks at most one (ADR-0024). Each bundle is a whole-system change:
|
||||||
|
main heating + controls + fuel + meter + the implied hot water, folded into one
|
||||||
|
Measure Option's `HeatingOverlay`. Hot water is never a separate competing
|
||||||
|
measure; the legacy heating-vs-HW split double-counted.
|
||||||
|
|
||||||
|
This slice covers the high-heat-retention storage (HHRSH) bundle; the ASHP and
|
||||||
|
boiler bundles land in later slices. Detection + pricing only — impact is
|
||||||
|
produced by scoring (ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
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"
|
||||||
|
|
||||||
|
_HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
|
||||||
|
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
|
||||||
|
_GAS_BOILER_UPGRADE_MEASURE_TYPE = MeasureType.GAS_BOILER_UPGRADE
|
||||||
|
_SYSTEM_TUNE_UP_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP
|
||||||
|
_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP_ZONED
|
||||||
|
|
||||||
|
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
|
||||||
|
_ELECTRICITY_FUEL = 30
|
||||||
|
# Table 4a SAP main-heating code for high-heat-retention storage heaters; an
|
||||||
|
# existing HHR system lodges this already, so it is not re-recommended.
|
||||||
|
_HHR_STORAGE_SAP_CODE = 409
|
||||||
|
# RdSAP main_heating_category for a heat pump (Table 4a) — an existing heat pump
|
||||||
|
# is never downgraded to storage heaters.
|
||||||
|
_HEAT_PUMP_CATEGORY = 4
|
||||||
|
|
||||||
|
# The HHRSH bundle's absolute end-state (ADR-0024): high-heat-retention storage
|
||||||
|
# heaters (Table 4a code 409) on a dual off-peak meter, with an off-peak
|
||||||
|
# electric immersion hot-water cylinder. Pinned against the relodged after-cert
|
||||||
|
# in the cascade tests; `mains_gas` and the heat emitter are unchanged by this
|
||||||
|
# measure, so they are not written.
|
||||||
|
_HHR_STORAGE_OVERLAY = HeatingOverlay(
|
||||||
|
main_fuel_type=_ELECTRICITY_FUEL,
|
||||||
|
sap_main_heating_code=_HHR_STORAGE_SAP_CODE,
|
||||||
|
main_heating_control=2404,
|
||||||
|
water_heating_code=903,
|
||||||
|
water_heating_fuel=_ELECTRICITY_FUEL,
|
||||||
|
cylinder_size=2,
|
||||||
|
cylinder_insulation_type=1,
|
||||||
|
cylinder_insulation_thickness_mm=120,
|
||||||
|
cylinder_thermostat="Y",
|
||||||
|
has_hot_water_cylinder=True,
|
||||||
|
meter_type="Dual",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Representative heat-pump products Domna installs (one per brand we hold
|
||||||
|
# contractor install rates for), as PCDB Table 362 indices — the catalogue we
|
||||||
|
# may simulate the ASHP bundle with. Each is a valid, currently-available,
|
||||||
|
# ~5 kW air-to-water unit providing space + water heating, chosen for high SAP
|
||||||
|
# 10.2 Appendix N efficiency (space η at the dwelling's PSR, with a healthy
|
||||||
|
# water η — many high-space records collapse on water and were rejected):
|
||||||
|
#
|
||||||
|
# Vaillant 110257 aroTHERM plus 5 kW space ~402% / water ~288%
|
||||||
|
# Mitsubishi 104570 Ecodan PUZ-WM50VHA 5.0 kW space ~368% / water ~288%
|
||||||
|
# Daikin 105008 Altherma ERGA04DVA 5.5 kW space ~376% / water ~288%
|
||||||
|
# Samsung 108774 AE050CXYDEK 5 kW (R290) space ~394% / water ~309%
|
||||||
|
# Grant 103768 AERONA3 HPID6R32 4.8 kW space ~395% / water ~332%
|
||||||
|
#
|
||||||
|
# We fix the Vaillant for the tracer: it is widely available for install and a
|
||||||
|
# strong all-round SAP performer. (Promoting this to a per-dwelling choice is a
|
||||||
|
# clean future change — see the sizing note below.)
|
||||||
|
_VAILLANT_AROTHERM_PLUS_5KW_PCDB = 110257
|
||||||
|
|
||||||
|
# NOTE (sizing): the bundle installs ONE fixed ~5 kW product regardless of the
|
||||||
|
# dwelling. SAP 10.2 Appendix N reads heat-pump efficiency at the dwelling's PSR
|
||||||
|
# (= pump max output / design heat loss), so a fixed output is a deliberate
|
||||||
|
# simplification: a 5 kW unit lands at a good PSR (~0.8-1.0) for modest
|
||||||
|
# dwellings but is undersized for high-heat-loss ones (low PSR → lower space
|
||||||
|
# efficiency), leaving SAP on the table. Sizing the pump to the dwelling (and
|
||||||
|
# selecting the matching PCDB record) is future work — it also feeds the
|
||||||
|
# size-banded ASHP costing.
|
||||||
|
|
||||||
|
# The ASHP bundle's absolute end-state (ADR-0024): the fixed, representative,
|
||||||
|
# contractor-installable heat pump above (RdSAP category 4) with time-and-
|
||||||
|
# temperature-zone control (2210), a heat-pump hot-water cylinder, a single
|
||||||
|
# (non off-peak) meter, and the dwelling switched off mains gas. The index is
|
||||||
|
# the efficiency anchor — the applicator clears any stale `sap_main_heating_code`
|
||||||
|
# when an index is set, so the calculator resolves the heat pump's SCOP from the
|
||||||
|
# PCDB record. Pinned against the relodged after-cert.
|
||||||
|
_ASHP_OVERLAY = HeatingOverlay(
|
||||||
|
main_fuel_type=_ELECTRICITY_FUEL,
|
||||||
|
main_heating_control=2210,
|
||||||
|
main_heating_index_number=_VAILLANT_AROTHERM_PLUS_5KW_PCDB,
|
||||||
|
main_heating_category=_HEAT_PUMP_CATEGORY,
|
||||||
|
# Hot water from the main heat-pump system via the new cylinder (code 901,
|
||||||
|
# "from main system"). Set absolutely so a combi (909/611) or electric
|
||||||
|
# (903/908) before is reset to the fixed HP end-state, not just the case
|
||||||
|
# where the before already lodged 901.
|
||||||
|
water_heating_code=901,
|
||||||
|
water_heating_fuel=_ELECTRICITY_FUEL,
|
||||||
|
cylinder_size=4,
|
||||||
|
cylinder_insulation_type=1,
|
||||||
|
cylinder_insulation_thickness_mm=50,
|
||||||
|
cylinder_thermostat="Y",
|
||||||
|
has_hot_water_cylinder=True,
|
||||||
|
meter_type="Single",
|
||||||
|
mains_gas=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler
|
||||||
|
# with a modern gas condensing boiler. Validated against Elmhurst before/after
|
||||||
|
# re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas
|
||||||
|
# directly, and a non-gas wet boiler (oil/LPG/solid) ->gas ONLY where a mains-gas
|
||||||
|
# connection is present (electric boilers are left alone; electrification is the
|
||||||
|
# national target). The end-state is a Table 4b SAP code (not a PCDB index): code
|
||||||
|
# 102 for a regular boiler heating a hot-water cylinder, code 104 for a combi
|
||||||
|
# (no cylinder, a later slice). The calculator derives the condensing-boiler
|
||||||
|
# seasonal efficiency from the code, so no efficiency input is needed. ---
|
||||||
|
|
||||||
|
# Mains-gas main/water fuel code (Elmhurst -> SAP10 Table 12).
|
||||||
|
_MAINS_GAS_FUEL = 26
|
||||||
|
# Table 4a heat-emitter code for radiators (the wet-distribution end-state).
|
||||||
|
_RADIATOR_EMITTER = 1
|
||||||
|
# Table 4b SAP main-heating codes for the new gas condensing boiler: code 102
|
||||||
|
# for a regular boiler heating a cylinder, code 104 for a combi (no cylinder).
|
||||||
|
_REGULAR_GAS_BOILER_SAP_CODE = 102
|
||||||
|
_COMBI_GAS_BOILER_SAP_CODE = 104
|
||||||
|
# Water-heating code 901 — hot water from the main heating system.
|
||||||
|
_WATER_FROM_MAIN_SYSTEM_CODE = 901
|
||||||
|
# Elmhurst boiler flue type for the new condensing boiler (room-sealed/balanced);
|
||||||
|
# every relodged after lodges type 2. SAP-inert, written for end-state fidelity.
|
||||||
|
_CONDENSING_BOILER_FLUE_TYPE = 2
|
||||||
|
|
||||||
|
# Controls upgrade (SAP 10.2 Table 4e Group 1, PDF p.172): bring an inadequate
|
||||||
|
# boiler control up to full programmer + room thermostat + TRVs (code 2106).
|
||||||
|
# "Inadequate" = the Group-1 codes whose description carries NO room thermostat
|
||||||
|
# (2101 no control, 2102 programmer-only, 2107/2108/2109 programmer+TRVs without
|
||||||
|
# a room thermostat, 2111 TRVs and bypass) — these lack boiler interlock (Table
|
||||||
|
# 4c(2) / footnote c)), so adding a room thermostat is a genuine improvement.
|
||||||
|
# Controls with a room thermostat (2103/2104/2105/2106/2113) or better time-and-
|
||||||
|
# temperature zone control (2110/2112) are left unchanged — never downgraded.
|
||||||
|
_FULL_BOILER_CONTROL = 2106
|
||||||
|
_INADEQUATE_BOILER_CONTROL_CODES: frozenset[int] = frozenset(
|
||||||
|
{2101, 2102, 2107, 2108, 2109, 2111}
|
||||||
|
)
|
||||||
|
|
||||||
|
# System tune-up control end-states (SAP 10.2 Table 4e Group 1): the two best
|
||||||
|
# competing control upgrades offered while KEEPING the existing boiler —
|
||||||
|
# "standard" (programmer + room thermostat + TRVs, code 2106) and "zone"
|
||||||
|
# (time-and-temperature zone control, code 2110, type 3). Zone gives more SAP
|
||||||
|
# uplift for more cost, so the Optimiser steps to it when its extra SAP is
|
||||||
|
# needed (ADR-0024).
|
||||||
|
_STANDARD_CONTROL = _FULL_BOILER_CONTROL # 2106
|
||||||
|
_ZONE_CONTROL = 2110
|
||||||
|
# Controls already providing standard (2106) or better — a standard tune-up
|
||||||
|
# would be a no-op or a downgrade, so it is not offered to these.
|
||||||
|
_STANDARD_OR_BETTER_CONTROL_CODES: frozenset[int] = frozenset({2106, 2110, 2112})
|
||||||
|
# Controls already providing zone control (type 3) — a zone tune-up is not
|
||||||
|
# offered to these.
|
||||||
|
_ZONE_CONTROL_CODES: frozenset[int] = frozenset({2110, 2112})
|
||||||
|
|
||||||
|
# Wet-boiler SAP main_heating_code ranges (SAP 10.2 Table 4a + 4b): gas/oil
|
||||||
|
# boilers 101-141, solid-fuel boilers 151-161, electric boilers 191-196 (held
|
||||||
|
# locally so the generator does not depend on the calculator's internals,
|
||||||
|
# mirroring `domain/sap10_calculator/rdsap/cert_to_inputs.py`). Electric boilers
|
||||||
|
# are a wet system but are deliberately not upgraded to gas.
|
||||||
|
_WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = (
|
||||||
|
range(101, 142),
|
||||||
|
range(151, 162),
|
||||||
|
range(191, 197),
|
||||||
|
)
|
||||||
|
_ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197)
|
||||||
|
|
||||||
|
# Cylinder jacket end-state (from the after-cert): an 80 mm jacket
|
||||||
|
# (`cylinder_insulation_type=2`). The jacket is added only when the existing
|
||||||
|
# cylinder is below this thickness — bringing every cylinder up to 80 mm and
|
||||||
|
# never downgrading a better-insulated one.
|
||||||
|
_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
|
||||||
|
# pricing itself lives on `Products`. ---
|
||||||
|
|
||||||
|
# A dwelling at or below this floor area is treated as a 1-2 bed property (only
|
||||||
|
# affects the electric-storage decommission line — a £270 swing).
|
||||||
|
_SMALL_PROPERTY_MAX_M2 = 75.0
|
||||||
|
# Design heat loss proxy: industry rule of thumb ~50 W per m2 of floor area.
|
||||||
|
# The cost pump-size band is a minor lever, so this floor-area proxy is used in
|
||||||
|
# preference to the calculator's HLC (ADR-0025).
|
||||||
|
_KW_PER_M2 = 0.05
|
||||||
|
# Radiators ~= habitable rooms + kitchen + hall + bathroom (RdSAP excludes the
|
||||||
|
# latter three from habitable rooms); fallback ~1 radiator per 13 m2.
|
||||||
|
_RADIATOR_ROOM_OFFSET = 3
|
||||||
|
_RADIATOR_M2_PER_RADIATOR = 13.0
|
||||||
|
# main_fuel_type codes (gov API enum and/or Table 12) by fuel. Classification
|
||||||
|
# keys on the heating *fuel*, NOT the `mains_gas` flag — that flag means gas is
|
||||||
|
# available at the property, which is True even for electrically-heated dwellings
|
||||||
|
# on a gas street (every 001431 electric fixture lodges mains_gas=True).
|
||||||
|
_GAS_FUEL_CODES = frozenset({26, 1})
|
||||||
|
_OIL_FUEL_CODES = frozenset({28, 4, 71, 73, 75, 76})
|
||||||
|
_LPG_FUEL_CODES = frozenset({27, 2, 3, 5, 9})
|
||||||
|
|
||||||
|
|
||||||
|
def ashp_cost_inputs(epc: EpcPropertyData) -> AshpCostInputs:
|
||||||
|
"""Read an `EpcPropertyData` into the typed inputs `Products.ashp_bundle_cost`
|
||||||
|
needs: the existing system, property-size band, design heat loss (floor-area
|
||||||
|
proxy), radiator count, and whether a wet system can be reused (ADR-0025)."""
|
||||||
|
system: AshpExistingSystem = _existing_system(epc)
|
||||||
|
floor_area: float = epc.total_floor_area_m2
|
||||||
|
return AshpCostInputs(
|
||||||
|
existing_system=system,
|
||||||
|
is_small_property=floor_area <= _SMALL_PROPERTY_MAX_M2,
|
||||||
|
design_heat_loss_kw=floor_area * _KW_PER_M2,
|
||||||
|
radiator_count=_radiator_count(epc),
|
||||||
|
has_reusable_wet_system=system
|
||||||
|
in (AshpExistingSystem.GAS, AshpExistingSystem.OIL, AshpExistingSystem.LPG),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_system(epc: EpcPropertyData) -> AshpExistingSystem:
|
||||||
|
"""Classify the dwelling's pre-retrofit system for decommission + reuse,
|
||||||
|
keyed on the heating *fuel code* (not the misleading `mains_gas` flag).
|
||||||
|
Electricity, gas, oil and LPG map to their categories; a dwelling with no
|
||||||
|
lodged main system to NONE; anything unrecognised to OTHER (which prices the
|
||||||
|
gas-line decommission fallback). The storage-vs-other-electric split is
|
||||||
|
deliberately not made — both price the same decommission line (ADR-0025)."""
|
||||||
|
details: list[MainHeatingDetail] = epc.sap_heating.main_heating_details
|
||||||
|
if not details:
|
||||||
|
return AshpExistingSystem.NONE
|
||||||
|
fuel = details[0].main_fuel_type
|
||||||
|
if fuel == _ELECTRICITY_FUEL:
|
||||||
|
return AshpExistingSystem.ELECTRIC_STORAGE
|
||||||
|
if fuel in _GAS_FUEL_CODES:
|
||||||
|
return AshpExistingSystem.GAS
|
||||||
|
if fuel in _OIL_FUEL_CODES:
|
||||||
|
return AshpExistingSystem.OIL
|
||||||
|
if fuel in _LPG_FUEL_CODES:
|
||||||
|
return AshpExistingSystem.LPG
|
||||||
|
return AshpExistingSystem.OTHER
|
||||||
|
|
||||||
|
|
||||||
|
def _radiator_count(epc: EpcPropertyData) -> int:
|
||||||
|
"""Estimate radiators from habitable rooms (+ kitchen/hall/bathroom), or
|
||||||
|
from floor area when the room count is missing (ADR-0025). Products clamps
|
||||||
|
to its distribution table bounds."""
|
||||||
|
habitable: int = epc.habitable_rooms_count
|
||||||
|
if habitable > 0:
|
||||||
|
return habitable + _RADIATOR_ROOM_OFFSET
|
||||||
|
return round(epc.total_floor_area_m2 / _RADIATOR_M2_PER_RADIATOR)
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_heating(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
products: ProductRepository,
|
||||||
|
restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a "Heating & Hot Water" Recommendation of competing whole-system
|
||||||
|
bundles for the dwelling, else None when no bundle is eligible. ASHP is
|
||||||
|
additionally gated by the Property's planning protections (ADR-0024)."""
|
||||||
|
options: list[MeasureOption] = []
|
||||||
|
|
||||||
|
hhr_option = _hhr_storage_option(epc, products)
|
||||||
|
if hhr_option is not None:
|
||||||
|
options.append(hhr_option)
|
||||||
|
|
||||||
|
ashp_option = _ashp_option(epc, products, restrictions)
|
||||||
|
if ashp_option is not None:
|
||||||
|
options.append(ashp_option)
|
||||||
|
|
||||||
|
boiler_option = _boiler_upgrade_option(epc, products)
|
||||||
|
if boiler_option is not None:
|
||||||
|
options.append(boiler_option)
|
||||||
|
|
||||||
|
options.extend(_system_tune_up_options(epc, products))
|
||||||
|
|
||||||
|
if not options:
|
||||||
|
return None
|
||||||
|
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
|
||||||
|
|
||||||
|
|
||||||
|
def _system_tune_up_options(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> list[MeasureOption]:
|
||||||
|
"""The system tune-up options: keep the existing wet boiler but install
|
||||||
|
better heating controls (standard 2106 and/or zone 2110, as competing
|
||||||
|
options) and fix the cylinder (jacket when under-insulated, thermostat when
|
||||||
|
absent). Each control option is offered only when it genuinely improves the
|
||||||
|
existing controls — never a downgrade or a no-op (ADR-0024)."""
|
||||||
|
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||||
|
code: Optional[int] = main.sap_main_heating_code
|
||||||
|
if code is None or not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
||||||
|
return []
|
||||||
|
control = main.main_heating_control
|
||||||
|
control_code: Optional[int] = control if isinstance(control, int) else None
|
||||||
|
|
||||||
|
options: list[MeasureOption] = []
|
||||||
|
if control_code not in _STANDARD_OR_BETTER_CONTROL_CODES:
|
||||||
|
options.append(
|
||||||
|
_tune_up_option(
|
||||||
|
epc,
|
||||||
|
products,
|
||||||
|
measure_type=_SYSTEM_TUNE_UP_MEASURE_TYPE,
|
||||||
|
control=_STANDARD_CONTROL,
|
||||||
|
description=(
|
||||||
|
"Tune up the heating: install a programmer, room thermostat "
|
||||||
|
"and TRVs and insulate and thermostat the hot-water cylinder"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if control_code not in _ZONE_CONTROL_CODES:
|
||||||
|
options.append(
|
||||||
|
_tune_up_option(
|
||||||
|
epc,
|
||||||
|
products,
|
||||||
|
measure_type=_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE,
|
||||||
|
control=_ZONE_CONTROL,
|
||||||
|
description=(
|
||||||
|
"Tune up the heating: install time-and-temperature zone "
|
||||||
|
"control and insulate and thermostat the hot-water cylinder"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _tune_up_option(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
products: ProductRepository,
|
||||||
|
*,
|
||||||
|
measure_type: MeasureType,
|
||||||
|
control: int,
|
||||||
|
description: str,
|
||||||
|
) -> MeasureOption:
|
||||||
|
"""One tune-up Option: the existing boiler is kept; only the heating control
|
||||||
|
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,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tune_up_overlay(epc: EpcPropertyData, control: int) -> HeatingOverlay:
|
||||||
|
"""Build a tune-up end-state: set the heating control to ``control`` and
|
||||||
|
apply the conditional cylinder fixes (an 80 mm jacket when under-insulated, a
|
||||||
|
thermostat when absent) — only when the dwelling has a cylinder. The boiler,
|
||||||
|
fuel and meter are left unchanged (the boiler is kept)."""
|
||||||
|
sap_heating = epc.sap_heating
|
||||||
|
jacket_type: Optional[int] = None
|
||||||
|
jacket_thickness_mm: Optional[int] = None
|
||||||
|
thermostat: Optional[str] = None
|
||||||
|
if epc.has_hot_water_cylinder:
|
||||||
|
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
|
||||||
|
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
|
||||||
|
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
|
||||||
|
if sap_heating.cylinder_thermostat != "Y":
|
||||||
|
thermostat = "Y"
|
||||||
|
return HeatingOverlay(
|
||||||
|
main_heating_control=control,
|
||||||
|
cylinder_insulation_type=jacket_type,
|
||||||
|
cylinder_insulation_thickness_mm=jacket_thickness_mm,
|
||||||
|
cylinder_thermostat=thermostat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _boiler_upgrade_option(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[MeasureOption]:
|
||||||
|
"""The gas-condensing-boiler upgrade for a dwelling with an existing wet
|
||||||
|
boiler: a combi (Table 4b code 104) where there is no cylinder, or a regular
|
||||||
|
boiler (code 102) heating the existing cylinder where there is one. Both
|
||||||
|
upgrade inadequate controls and the cylinder variant adds the conditional
|
||||||
|
cylinder fixes (a jacket when under-insulated, a thermostat when absent). One
|
||||||
|
Option per dwelling — a dwelling has a cylinder or it does not — offered only
|
||||||
|
where a mains-gas connection makes the gas end-state installable (ADR-0024
|
||||||
|
revised)."""
|
||||||
|
if not _boiler_upgrade_eligible(epc):
|
||||||
|
return None
|
||||||
|
has_cylinder: bool = epc.has_hot_water_cylinder
|
||||||
|
overlay: HeatingOverlay = (
|
||||||
|
_boiler_cylinder_overlay(epc) if has_cylinder else _boiler_combi_overlay(epc)
|
||||||
|
)
|
||||||
|
description: str = (
|
||||||
|
"Replace the boiler with a gas condensing boiler and insulate and "
|
||||||
|
"thermostat the hot-water cylinder"
|
||||||
|
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,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
|
||||||
|
"""Whether a dwelling's existing wet boiler can be upgraded to a gas
|
||||||
|
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. 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:
|
||||||
|
return False
|
||||||
|
if not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
||||||
|
return False
|
||||||
|
if code in _ELECTRIC_BOILER_SAP_CODE_RANGE:
|
||||||
|
return False
|
||||||
|
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:
|
||||||
|
"""Build the per-dwelling combi end-state: a gas condensing combi (Table 4b
|
||||||
|
code 104, fanned flue) on radiators with hot water from the boiler, plus a
|
||||||
|
controls upgrade when the existing controls are inadequate. No cylinder, so
|
||||||
|
no cylinder fields are touched."""
|
||||||
|
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||||
|
return HeatingOverlay(
|
||||||
|
main_fuel_type=_MAINS_GAS_FUEL,
|
||||||
|
heat_emitter_type=_RADIATOR_EMITTER,
|
||||||
|
sap_main_heating_code=_COMBI_GAS_BOILER_SAP_CODE,
|
||||||
|
fan_flue_present=True,
|
||||||
|
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
|
||||||
|
main_heating_control=_upgraded_boiler_control(main),
|
||||||
|
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
|
||||||
|
water_heating_fuel=_MAINS_GAS_FUEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
|
||||||
|
"""Build the per-dwelling boiler-with-cylinder end-state: a regular gas
|
||||||
|
condensing boiler on radiators, hot water from the main system, a controls
|
||||||
|
upgrade when the existing controls are inadequate, and the conditional
|
||||||
|
cylinder fixes — an 80 mm jacket only when the cylinder is under-insulated, a
|
||||||
|
thermostat only when one is absent. The existing cylinder size and meter are
|
||||||
|
left unchanged."""
|
||||||
|
sap_heating = epc.sap_heating
|
||||||
|
main: MainHeatingDetail = sap_heating.main_heating_details[0]
|
||||||
|
jacket_type: Optional[int] = None
|
||||||
|
jacket_thickness_mm: Optional[int] = None
|
||||||
|
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
|
||||||
|
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
|
||||||
|
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
|
||||||
|
thermostat: Optional[str] = (
|
||||||
|
"Y" if sap_heating.cylinder_thermostat != "Y" else None
|
||||||
|
)
|
||||||
|
return HeatingOverlay(
|
||||||
|
main_fuel_type=_MAINS_GAS_FUEL,
|
||||||
|
heat_emitter_type=_RADIATOR_EMITTER,
|
||||||
|
sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE,
|
||||||
|
fan_flue_present=True,
|
||||||
|
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
|
||||||
|
main_heating_control=_upgraded_boiler_control(main),
|
||||||
|
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
|
||||||
|
water_heating_fuel=_MAINS_GAS_FUEL,
|
||||||
|
cylinder_insulation_type=jacket_type,
|
||||||
|
cylinder_insulation_thickness_mm=jacket_thickness_mm,
|
||||||
|
cylinder_thermostat=thermostat,
|
||||||
|
has_hot_water_cylinder=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool:
|
||||||
|
"""Whether a hot-water cylinder is below the 80 mm jacket end-state (an
|
||||||
|
un-jacketed cylinder lodges no thickness)."""
|
||||||
|
return thickness_mm is None or thickness_mm < _MIN_CYLINDER_INSULATION_MM
|
||||||
|
|
||||||
|
|
||||||
|
def _upgraded_boiler_control(main: MainHeatingDetail) -> Optional[int]:
|
||||||
|
"""The full-controls code (2106) when the existing boiler control is
|
||||||
|
inadequate (lacks a room thermostat — SAP 10.2 Table 4e Group 1), else
|
||||||
|
``None`` to leave a room-thermostatted or better control unchanged. So the
|
||||||
|
overlay only ever moves controls where it genuinely improves them."""
|
||||||
|
control = main.main_heating_control
|
||||||
|
code: Optional[int] = control if isinstance(control, int) else None
|
||||||
|
if code is None and isinstance(control, str) and control.isdigit():
|
||||||
|
code = int(control)
|
||||||
|
if code in _INADEQUATE_BOILER_CONTROL_CODES:
|
||||||
|
return _FULL_BOILER_CONTROL
|
||||||
|
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,
|
||||||
|
restrictions: PlanningRestrictions,
|
||||||
|
) -> Optional[MeasureOption]:
|
||||||
|
"""The air-source heat-pump bundle, offered for any non-flat house/bungalow
|
||||||
|
that is not listed/heritage and not already a heat pump."""
|
||||||
|
if not _ashp_eligible(epc, restrictions):
|
||||||
|
return None
|
||||||
|
# Cost is composed per-dwelling from the rate sheet (ADR-0025), not the
|
||||||
|
# single catalogue scalar; the catalogue row is still read for its id.
|
||||||
|
product = products.get(_ASHP_MEASURE_TYPE)
|
||||||
|
cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc))
|
||||||
|
return MeasureOption(
|
||||||
|
measure_type=_ASHP_MEASURE_TYPE,
|
||||||
|
description=(
|
||||||
|
"Replace the heating with an air-source heat pump, time-and-"
|
||||||
|
"temperature-zone controls and a heat-pump hot-water cylinder"
|
||||||
|
),
|
||||||
|
overlay=EpcSimulation(heating=_ASHP_OVERLAY),
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ashp_eligible(epc: EpcPropertyData, restrictions: PlanningRestrictions) -> bool:
|
||||||
|
"""ASHP suits any non-flat house/bungalow that is not already a heat pump and
|
||||||
|
is not fabric-protected. Eligibility encodes only physical/planning
|
||||||
|
installability — the Optimiser owns the economics (ADR-0024), so floor area,
|
||||||
|
built form, fuel, and fabric are deliberately not gates. A conservation area
|
||||||
|
does not exclude ASHP (offered with a planning caveat); a listed/heritage
|
||||||
|
protection (`blocks_internal`) does."""
|
||||||
|
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||||
|
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
|
||||||
|
return False
|
||||||
|
if restrictions.blocks_internal:
|
||||||
|
return False
|
||||||
|
return _is_house_or_bungalow(epc)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_house_or_bungalow(epc: EpcPropertyData) -> bool:
|
||||||
|
"""Whether the dwelling is a house or bungalow (not a flat/maisonette). The
|
||||||
|
Elmhurst path lodges the name; the API path a stringified RdSAP code
|
||||||
|
(`PROPERTY_TYPE_LOOKUP`: 0 House, 1 Bungalow, 2 Flat, 3 Maisonette)."""
|
||||||
|
raw: str = (epc.property_type or "").strip()
|
||||||
|
if raw.lower() in ("house", "bungalow"):
|
||||||
|
return True
|
||||||
|
if raw.isdigit():
|
||||||
|
return PROPERTY_TYPE_LOOKUP.get(int(raw)) in ("House", "Bungalow")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hhr_storage_option(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[MeasureOption]:
|
||||||
|
"""The high-heat-retention storage bundle, offered for an electrically-heated
|
||||||
|
(or off-gas) dwelling that is not already HHR or a heat pump."""
|
||||||
|
if not _hhr_storage_eligible(epc):
|
||||||
|
return None
|
||||||
|
product = products.get(_HHR_STORAGE_MEASURE_TYPE)
|
||||||
|
return MeasureOption(
|
||||||
|
measure_type=_HHR_STORAGE_MEASURE_TYPE,
|
||||||
|
description=(
|
||||||
|
"Replace the heating with high heat retention storage heaters on an "
|
||||||
|
"off-peak tariff, with an off-peak electric hot-water cylinder"
|
||||||
|
),
|
||||||
|
overlay=EpcSimulation(heating=_HHR_STORAGE_OVERLAY),
|
||||||
|
cost=Cost(
|
||||||
|
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
||||||
|
),
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hhr_storage_eligible(epc: EpcPropertyData) -> bool:
|
||||||
|
"""HHR storage suits an electrically-heated or off-gas dwelling, unless it is
|
||||||
|
already HHR or a heat pump (translated from legacy `HeatingRecommender.
|
||||||
|
is_high_heat_retention_valid`, which keyed on description strings)."""
|
||||||
|
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||||
|
if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE:
|
||||||
|
return False
|
||||||
|
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
|
||||||
|
return False
|
||||||
|
off_gas: bool = not epc.sap_energy_source.mains_gas
|
||||||
|
electric_main: bool = main.main_fuel_type == _ELECTRICITY_FUEL
|
||||||
|
return electric_main or off_gas
|
||||||
64
domain/modelling/generators/lighting_recommendation.py
Normal file
64
domain/modelling/generators/lighting_recommendation.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""The lighting Recommendation Generator (LED upgrade).
|
||||||
|
|
||||||
|
Detects a dwelling's non-LED fixed-lighting bulbs and emits one "Lighting"
|
||||||
|
Recommendation whose single Option converts **every** bulb to LED (ADR-0023).
|
||||||
|
SAP 10.2 RdSAP §12-1 rates lamp efficacy LED > low-energy-unknown > CFL >
|
||||||
|
incandescent, so converting every non-LED type — incandescent, CFL, and the
|
||||||
|
"low energy, type unknown" (LEL) bulbs alike — strictly improves the Appendix L
|
||||||
|
lighting energy (worksheet line (232)).
|
||||||
|
|
||||||
|
Unlike the fabric generators this is a **whole-dwelling** Measure: its overlay
|
||||||
|
writes the four top-level bulb counts directly (`led = total`, the rest 0). It
|
||||||
|
is a free Optimiser candidate — an LED upgrade improves SAP at low cost, so the
|
||||||
|
Optimiser keeps or leaves it for least-cost-to-target (contrast ventilation's
|
||||||
|
forced dependency). Detection + pricing only; impact is produced later by
|
||||||
|
scoring (ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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, LightingOverlay
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
_LIGHTING_MEASURE_TYPE: Final[MeasureType] = MeasureType.LOW_ENERGY_LIGHTING
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_lighting(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a lighting Recommendation upgrading every non-LED bulb to LED — its
|
||||||
|
single Option — else None when the dwelling has no non-LED bulbs (already
|
||||||
|
all-LED, or no bulb counts lodged)."""
|
||||||
|
led: int = epc.led_fixed_lighting_bulbs_count or 0
|
||||||
|
cfl: int = epc.cfl_fixed_lighting_bulbs_count or 0
|
||||||
|
incandescent: int = epc.incandescent_fixed_lighting_bulbs_count or 0
|
||||||
|
low_energy: int = epc.low_energy_fixed_lighting_bulbs_count or 0
|
||||||
|
|
||||||
|
non_led: int = cfl + incandescent + low_energy
|
||||||
|
if non_led == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
product = products.get(_LIGHTING_MEASURE_TYPE)
|
||||||
|
overlay = EpcSimulation(
|
||||||
|
lighting=LightingOverlay(
|
||||||
|
led_fixed_lighting_bulbs_count=led + non_led,
|
||||||
|
cfl_fixed_lighting_bulbs_count=0,
|
||||||
|
incandescent_fixed_lighting_bulbs_count=0,
|
||||||
|
low_energy_fixed_lighting_bulbs_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cost = Cost(
|
||||||
|
total=non_led * product.unit_cost_per_m2,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
option = MeasureOption(
|
||||||
|
measure_type=_LIGHTING_MEASURE_TYPE,
|
||||||
|
description="Replace all non-LED bulbs with LED",
|
||||||
|
overlay=overlay,
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Lighting", options=(option,))
|
||||||
139
domain/modelling/generators/roof_recommendation.py
Normal file
139
domain/modelling/generators/roof_recommendation.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""The roof Recommendation Generator.
|
||||||
|
|
||||||
|
Dispatches the MAIN roof to its single applicable insulation Measure by roof
|
||||||
|
type (ADR-0021): a sloping ceiling (rafters, 100 mm), or — the fallback — a
|
||||||
|
loft / thatched / unlodged pitched roof (joists, 300 mm); a no-access roof gets
|
||||||
|
nothing. Each emits one "Roof" Recommendation whose Option carries the
|
||||||
|
insulation Simulation Overlay (raising `roof_insulation_thickness`) and a priced
|
||||||
|
Cost (roof area x the Product's fully-loaded unit cost, with its contingency).
|
||||||
|
Flat-roof and room-in-roof branches land in later slices. No scoring, no
|
||||||
|
persistence — impact is produced later by scoring (ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
)
|
||||||
|
from domain.building_geometry import roof_area
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
_LOFT_MEASURE_TYPE = MeasureType.LOFT_INSULATION
|
||||||
|
_SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION
|
||||||
|
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The
|
||||||
|
# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs.
|
||||||
|
_ROOF_UNINSULATED_MM = 0
|
||||||
|
# Recommended loft-insulation depth (mm). Elmhurst re-lodges a loft-insulation
|
||||||
|
# measure at 300 mm; pinning the before→after cascade (000490/001431) requires
|
||||||
|
# the overlay to match that depth exactly (see test_elmhurst_cascade_pins).
|
||||||
|
_RECOMMENDED_LOFT_THICKNESS_MM = 300
|
||||||
|
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
|
||||||
|
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
|
||||||
|
_FLAT_ROOF_MEASURE_TYPE = MeasureType.FLAT_ROOF_INSULATION
|
||||||
|
# Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
|
||||||
|
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_roof_insulation(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return the single roof-insulation Recommendation for the MAIN roof,
|
||||||
|
dispatched by roof type (ADR-0021): a sloping ceiling is insulated at the
|
||||||
|
rafters to 100 mm. Returns None when the roof type has no applicable measure
|
||||||
|
or the roof is already insulated."""
|
||||||
|
main = next(
|
||||||
|
part
|
||||||
|
for part in epc.sap_building_parts
|
||||||
|
if part.identifier is BuildingPartIdentifier.MAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Room-in-roof safety guard (ADR-0021): a room-in-roof carries its
|
||||||
|
# insulation on its own sloping/stud/gable surfaces (RdSAP 10 §3.10, Table
|
||||||
|
# 17/18), which the loft/sloping overlay's flat `roof_insulation_thickness`
|
||||||
|
# bump cannot model. Without this guard a RR with an uninsulated loft would
|
||||||
|
# fall through to the loft fallback and mis-recommend loft insulation.
|
||||||
|
# Defer until a dedicated RR branch lands.
|
||||||
|
if main.sap_room_in_roof is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
roof_type: str = (main.roof_construction_type or "").lower()
|
||||||
|
|
||||||
|
# Dispatch by roof type (ADR-0021). Order matters: a sloping ceiling is
|
||||||
|
# tested before the loft fallback, and "no access" before it too, because
|
||||||
|
# "no access to loft" contains "loft". Loft is the fallback — it covers a
|
||||||
|
# plain pitched loft, a thatched roof (the covering doesn't block insulating
|
||||||
|
# the loft floor), and an unlodged roof type (the modal UK case), matching
|
||||||
|
# the pre-dispatcher behaviour of firing on `roof_insulation_thickness == 0`.
|
||||||
|
if "sloping ceiling" in roof_type:
|
||||||
|
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
|
||||||
|
return None
|
||||||
|
return _roof_recommendation(
|
||||||
|
epc,
|
||||||
|
products,
|
||||||
|
measure_type=_SLOPING_CEILING_MEASURE_TYPE,
|
||||||
|
description="Sloping-ceiling insulation (insulate at the rafters)",
|
||||||
|
thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "flat" in roof_type:
|
||||||
|
# A flat roof lodges no thickness when uninsulated ("As Built" → None
|
||||||
|
# on the Elmhurst path); a lodged thickness means it's already done.
|
||||||
|
if main.roof_insulation_thickness is not None:
|
||||||
|
return None
|
||||||
|
return _roof_recommendation(
|
||||||
|
epc,
|
||||||
|
products,
|
||||||
|
measure_type=_FLAT_ROOF_MEASURE_TYPE,
|
||||||
|
description="Flat-roof insulation",
|
||||||
|
thickness_mm=_RECOMMENDED_FLAT_ROOF_THICKNESS_MM,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "no access" in roof_type:
|
||||||
|
return None # the roof void can't be reached to insulate it
|
||||||
|
|
||||||
|
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
|
||||||
|
return None
|
||||||
|
return _roof_recommendation(
|
||||||
|
epc,
|
||||||
|
products,
|
||||||
|
measure_type=_LOFT_MEASURE_TYPE,
|
||||||
|
description="Loft insulation (top up to recommended depth)",
|
||||||
|
thickness_mm=_RECOMMENDED_LOFT_THICKNESS_MM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _roof_recommendation(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
products: ProductRepository,
|
||||||
|
*,
|
||||||
|
measure_type: MeasureType,
|
||||||
|
description: str,
|
||||||
|
thickness_mm: int,
|
||||||
|
) -> Recommendation:
|
||||||
|
"""Build a single-Option "Roof" Recommendation: the measure's insulation
|
||||||
|
overlay (raising `roof_insulation_thickness` to the recommended depth)
|
||||||
|
priced at the roof area."""
|
||||||
|
product = products.get(measure_type)
|
||||||
|
area: float = roof_area(epc, BuildingPartIdentifier.MAIN)
|
||||||
|
cost = Cost(
|
||||||
|
total=area * product.unit_cost_per_m2,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
option = MeasureOption(
|
||||||
|
measure_type=measure_type,
|
||||||
|
description=description,
|
||||||
|
overlay=EpcSimulation(
|
||||||
|
building_parts={
|
||||||
|
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
|
||||||
|
roof_insulation_thickness=thickness_mm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Roof", options=(option,))
|
||||||
|
|
@ -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,))
|
||||||
312
domain/modelling/generators/solar_recommendation.py
Normal file
312
domain/modelling/generators/solar_recommendation.py
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
"""The Solar PV Recommendation Generator (ADR-0026).
|
||||||
|
|
||||||
|
Offers competing whole-array PV Options built from real Google Solar imagery
|
||||||
|
(a typed `SolarPotential`), not an estimate. Unlike the heating bundles, the
|
||||||
|
SAP scoring side is already mature — the calculator does Appendix M β-split,
|
||||||
|
G4 diverter, SEG export, batteries and monthly E_PV — so this generator fixes
|
||||||
|
the *recommendation* side: where the array config comes from, how it is
|
||||||
|
conservatively sized, the new PV Overlay surface, and the composite cost.
|
||||||
|
|
||||||
|
This slice covers the generation-calibrated overshading derivation; config
|
||||||
|
selection, the overlay and `recommend_solar` land in later slices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
EpcPropertyData,
|
||||||
|
PhotovoltaicArray,
|
||||||
|
PvBatteries,
|
||||||
|
PvBattery,
|
||||||
|
)
|
||||||
|
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
|
from domain.modelling.products import Products, SolarCostInputs
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
|
from domain.modelling.simulation import EpcSimulation, SolarOverlay
|
||||||
|
from domain.modelling.solar_potential import (
|
||||||
|
SolarPanelConfiguration,
|
||||||
|
SolarPotential,
|
||||||
|
SolarRoofSegment,
|
||||||
|
)
|
||||||
|
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
|
pv_annual_solar_radiation_kwh_per_m2,
|
||||||
|
)
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
_SOLAR_SURFACE = "Solar PV"
|
||||||
|
_SOLAR_MEASURE_TYPE = MeasureType.SOLAR_PV
|
||||||
|
|
||||||
|
# The fixed, representative battery capacity for the with-battery variant
|
||||||
|
# (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh.
|
||||||
|
_BATTERY_CAPACITY_KWH = 5.0
|
||||||
|
# Watts → kilowatts for peak-power.
|
||||||
|
_WATTS_PER_KW = 1000.0
|
||||||
|
# The dwelling's PV connects to its own meter (the after-cert §19 "Connected to
|
||||||
|
# the dwelling's meter: Yes"). Non-load-bearing for the SAP cascade; carried for
|
||||||
|
# fidelity. 1 = connected, the modal install case.
|
||||||
|
_PV_CONNECTED_TO_DWELLING = 1
|
||||||
|
|
||||||
|
# A roof plane within this many degrees of due north (0°/360°, Google compass
|
||||||
|
# convention) is dropped: it generates little and is not worth panelling. The
|
||||||
|
# legacy `GoogleSolarApi.NORTH_FACING_AZIMUTH_RANGE` used the same ±30° band.
|
||||||
|
_NORTH_AZIMUTH_HALF_WIDTH = 30.0
|
||||||
|
# Cap usable panels at ~70% of Google's maxArrayPanelsCount — imagery misses
|
||||||
|
# obstructions (flues, dormers) and MCS wants a ~0.3 m edge setback, so the
|
||||||
|
# theoretical maximum is optimistic.
|
||||||
|
_USABLE_PANEL_FRACTION = 0.70
|
||||||
|
# At most this many competing configs go to the Optimiser (× battery on/off).
|
||||||
|
_MAX_CONFIGS = 5
|
||||||
|
|
||||||
|
# Google Solar inverter DC→AC efficiency — the canonical rate the legacy
|
||||||
|
# `GoogleSolarApi.dc_to_ac_rate` uses (mid of the 93–98% range); distinct from
|
||||||
|
# the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback.
|
||||||
|
_DC_TO_AC_RATE = 0.955
|
||||||
|
# SAP 10.2 Appendix M PV annual output: E = 0.8 × kWp × S × ZPV. The 0.8 is the
|
||||||
|
# in-system performance factor; back-solving for ZPV isolates the effective
|
||||||
|
# overshading once orientation (S) and size (kWp) are divided out.
|
||||||
|
_SAP_PV_PERFORMANCE_FACTOR = 0.8
|
||||||
|
|
||||||
|
# ADR-0026 overshading cutpoints — the lower bound of each RdSAP bucket's ZPV
|
||||||
|
# midpoint band {1:1.0, 2:0.8, 3:0.5, 4:0.35}: ≥0.90→1, 0.65–0.90→2,
|
||||||
|
# 0.425–0.65→3, <0.425→4. ZPV > 1 (Google beats SAP's unshaded model) clamps
|
||||||
|
# to 1 via the ≥0.90 branch. RdSAP10 has no "Severe" 5th bucket.
|
||||||
|
_OVERSHADING_LOWER_BOUNDS: tuple[tuple[float, int], ...] = (
|
||||||
|
(0.90, 1),
|
||||||
|
(0.65, 2),
|
||||||
|
(0.425, 3),
|
||||||
|
)
|
||||||
|
_OVERSHADING_HEAVY_CODE = 4
|
||||||
|
|
||||||
|
|
||||||
|
def overshading_code_from_zpv(zpv_target: float) -> int:
|
||||||
|
"""Snap a back-solved effective shading factor ZPV to the RdSAP overshading
|
||||||
|
code (1 = very little/none … 4 = heavy), per the ADR-0026 cutpoints."""
|
||||||
|
for lower_bound, code in _OVERSHADING_LOWER_BOUNDS:
|
||||||
|
if zpv_target >= lower_bound:
|
||||||
|
return code
|
||||||
|
return _OVERSHADING_HEAVY_CODE
|
||||||
|
|
||||||
|
|
||||||
|
def segment_overshading_code(
|
||||||
|
segment: SolarRoofSegment, panel_capacity_watts: float
|
||||||
|
) -> int:
|
||||||
|
"""Derive a roof segment's RdSAP overshading code from Google's expected
|
||||||
|
generation (ADR-0026). Google's `yearlyEnergyDcKwh` already encodes real
|
||||||
|
orientation, tilt and shading; dividing its AC equivalent by SAP's own
|
||||||
|
unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and leaves
|
||||||
|
the effective overshading factor ZPV, which snaps to the bucket."""
|
||||||
|
kwp: float = segment.panels_count * panel_capacity_watts / 1000.0
|
||||||
|
s: float = pv_annual_solar_radiation_kwh_per_m2(
|
||||||
|
segment.sap_orientation, segment.sap_pitch_code
|
||||||
|
)
|
||||||
|
unshaded_ac_kwh: float = _SAP_PV_PERFORMANCE_FACTOR * kwp * s
|
||||||
|
if unshaded_ac_kwh <= 0.0:
|
||||||
|
# No panels, or an orientation the calculator scores as zero — nothing
|
||||||
|
# to shade; the modal "no shading" code.
|
||||||
|
return 1
|
||||||
|
generation_ac_kwh: float = segment.yearly_energy_dc_kwh * _DC_TO_AC_RATE
|
||||||
|
zpv_target: float = generation_ac_kwh / unshaded_ac_kwh
|
||||||
|
return overshading_code_from_zpv(zpv_target)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_north_facing(azimuth_degrees: float) -> bool:
|
||||||
|
"""Whether a roof plane faces within 30° of due north (Google compass: 0°/
|
||||||
|
360° = N), handling the 360° wrap."""
|
||||||
|
return (
|
||||||
|
azimuth_degrees <= _NORTH_AZIMUTH_HALF_WIDTH
|
||||||
|
or azimuth_degrees >= 360.0 - _NORTH_AZIMUTH_HALF_WIDTH
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfiguration:
|
||||||
|
"""Trim a configuration to its non-north planes, recomputing the array's
|
||||||
|
panel count and expected generation to the usable subset."""
|
||||||
|
kept: tuple[SolarRoofSegment, ...] = tuple(
|
||||||
|
segment
|
||||||
|
for segment in config.segments
|
||||||
|
if not _is_north_facing(segment.azimuth_degrees)
|
||||||
|
)
|
||||||
|
return SolarPanelConfiguration(
|
||||||
|
panels_count=sum(segment.panels_count for segment in kept),
|
||||||
|
yearly_energy_dc_kwh=sum(segment.yearly_energy_dc_kwh for segment in kept),
|
||||||
|
segments=kept,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def select_conservative_configs(
|
||||||
|
potential: SolarPotential,
|
||||||
|
) -> tuple[SolarPanelConfiguration, ...]:
|
||||||
|
"""Choose up to five conservatively-sized array configs for the Optimiser
|
||||||
|
(ADR-0026): drop north-facing planes, cap usable panels at ~70% of
|
||||||
|
maxArrayPanelsCount, then sample five spanning min→max by expected
|
||||||
|
generation (the size-suitability proxy) so the size/cost choice is genuine.
|
||||||
|
Returns an empty tuple when nothing usable remains."""
|
||||||
|
panel_cap: float = _USABLE_PANEL_FRACTION * potential.max_array_panels_count
|
||||||
|
feasible: list[SolarPanelConfiguration] = [
|
||||||
|
trimmed
|
||||||
|
for config in potential.configurations
|
||||||
|
for trimmed in (_drop_north_segments(config),)
|
||||||
|
if trimmed.segments and trimmed.panels_count <= panel_cap
|
||||||
|
]
|
||||||
|
if not feasible:
|
||||||
|
return ()
|
||||||
|
# Collapse rungs that trimmed to the same usable size (north-drop can make
|
||||||
|
# distinct original rungs coincide), keeping the higher-generation layout —
|
||||||
|
# the Optimiser's dial is panel count (≈ kWp ≈ cost), so duplicates of the
|
||||||
|
# same size add no choice.
|
||||||
|
best_by_size: dict[int, SolarPanelConfiguration] = {}
|
||||||
|
for config in feasible:
|
||||||
|
incumbent = best_by_size.get(config.panels_count)
|
||||||
|
if incumbent is None or config.yearly_energy_dc_kwh > incumbent.yearly_energy_dc_kwh:
|
||||||
|
best_by_size[config.panels_count] = config
|
||||||
|
unique: list[SolarPanelConfiguration] = sorted(
|
||||||
|
best_by_size.values(), key=lambda c: c.yearly_energy_dc_kwh
|
||||||
|
)
|
||||||
|
if len(unique) > _MAX_CONFIGS:
|
||||||
|
last: int = len(unique) - 1
|
||||||
|
sampled_indices: list[int] = sorted(
|
||||||
|
{round(i * last / (_MAX_CONFIGS - 1)) for i in range(_MAX_CONFIGS)}
|
||||||
|
)
|
||||||
|
unique = [unique[index] for index in sampled_indices]
|
||||||
|
return tuple(sorted(unique, key=lambda c: c.panels_count))
|
||||||
|
|
||||||
|
|
||||||
|
def _array_for_segment(
|
||||||
|
segment: SolarRoofSegment, panel_capacity_watts: float
|
||||||
|
) -> PhotovoltaicArray:
|
||||||
|
"""Project a chosen roof segment into a SAP `PhotovoltaicArray`: peak power
|
||||||
|
from its panels, orientation/pitch from its geometry, and the
|
||||||
|
generation-calibrated overshading code (ADR-0026)."""
|
||||||
|
return PhotovoltaicArray(
|
||||||
|
peak_power=segment.panels_count * panel_capacity_watts / _WATTS_PER_KW,
|
||||||
|
pitch=segment.sap_pitch_code,
|
||||||
|
orientation=segment.sap_orientation,
|
||||||
|
overshading=segment_overshading_code(segment, panel_capacity_watts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _solar_overlay(
|
||||||
|
config: SolarPanelConfiguration,
|
||||||
|
panel_capacity_watts: float,
|
||||||
|
has_cylinder: bool,
|
||||||
|
with_battery: bool,
|
||||||
|
) -> SolarOverlay:
|
||||||
|
"""Build the `SolarOverlay` for one array config variant: one
|
||||||
|
`PhotovoltaicArray` per segment, export ensured, a diverter when the
|
||||||
|
dwelling has a cylinder, and a battery for the with-battery variant."""
|
||||||
|
return SolarOverlay(
|
||||||
|
photovoltaic_arrays=[
|
||||||
|
_array_for_segment(segment, panel_capacity_watts)
|
||||||
|
for segment in config.segments
|
||||||
|
],
|
||||||
|
# App G4 routes surplus PV to the cylinder immersion; a combi has nothing
|
||||||
|
# to divert to, so leave the field unset (None) when there is no cylinder.
|
||||||
|
pv_diverter_present=True if has_cylinder else None,
|
||||||
|
pv_connection=_PV_CONNECTED_TO_DWELLING,
|
||||||
|
is_dwelling_export_capable=True,
|
||||||
|
pv_batteries=(
|
||||||
|
PvBatteries(pv_battery=PvBattery(battery_capacity=_BATTERY_CAPACITY_KWH))
|
||||||
|
if with_battery
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _option(
|
||||||
|
config: SolarPanelConfiguration,
|
||||||
|
panel_capacity_watts: float,
|
||||||
|
has_cylinder: bool,
|
||||||
|
with_battery: bool,
|
||||||
|
products: ProductRepository,
|
||||||
|
) -> MeasureOption:
|
||||||
|
"""Assemble one competing Solar PV Measure Option for a config variant."""
|
||||||
|
peak_power_kwp: float = config.panels_count * panel_capacity_watts / _WATTS_PER_KW
|
||||||
|
cost: Cost = Products().solar_bundle_cost(
|
||||||
|
SolarCostInputs(
|
||||||
|
peak_power_kwp=peak_power_kwp,
|
||||||
|
has_cylinder=has_cylinder,
|
||||||
|
has_battery=with_battery,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
battery_suffix: str = " with a 5 kWh battery" if with_battery else ""
|
||||||
|
description: str = (
|
||||||
|
f"Install a {peak_power_kwp:.1f} kWp roof-mounted solar PV array"
|
||||||
|
f"{battery_suffix}, ensuring an export meter"
|
||||||
|
)
|
||||||
|
return MeasureOption(
|
||||||
|
measure_type=_SOLAR_MEASURE_TYPE,
|
||||||
|
description=description,
|
||||||
|
overlay=EpcSimulation(
|
||||||
|
solar=_solar_overlay(
|
||||||
|
config, panel_capacity_watts, has_cylinder, with_battery
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cost=cost,
|
||||||
|
material_id=products.get(_SOLAR_MEASURE_TYPE).id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_solar(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
products: ProductRepository,
|
||||||
|
solar_potential: Optional[SolarPotential],
|
||||||
|
restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a "Solar PV" Recommendation of competing whole-array Options —
|
||||||
|
up to five conservatively-sized configs × {no battery, battery} — for an
|
||||||
|
eligible dwelling with feasible Google solar potential, else None
|
||||||
|
(ADR-0026). A free Optimiser candidate; the Optimiser owns whether and at
|
||||||
|
what size to install it."""
|
||||||
|
if solar_potential is None or not _solar_eligible(epc, restrictions):
|
||||||
|
return None
|
||||||
|
configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs(
|
||||||
|
solar_potential
|
||||||
|
)
|
||||||
|
if not configs:
|
||||||
|
return None
|
||||||
|
has_cylinder: bool = bool(epc.has_hot_water_cylinder)
|
||||||
|
capacity: float = solar_potential.panel_capacity_watts
|
||||||
|
options: list[MeasureOption] = [
|
||||||
|
_option(config, capacity, has_cylinder, with_battery, products)
|
||||||
|
for config in configs
|
||||||
|
for with_battery in (False, True)
|
||||||
|
]
|
||||||
|
return Recommendation(surface=_SOLAR_SURFACE, options=tuple(options))
|
||||||
|
|
||||||
|
|
||||||
|
def _solar_eligible(
|
||||||
|
epc: EpcPropertyData, restrictions: PlanningRestrictions
|
||||||
|
) -> bool:
|
||||||
|
"""Solar PV suits a non-flat house/bungalow that is not fabric-protected and
|
||||||
|
has no existing PV (ADR-0026). Eligibility encodes only physical/legal
|
||||||
|
installability — the Optimiser owns the economics. A conservation area does
|
||||||
|
NOT block PV (offered, installed sympathetically); a listed/heritage
|
||||||
|
protection (`blocks_internal`) does — the same gate as ASHP."""
|
||||||
|
if restrictions.blocks_internal:
|
||||||
|
return False
|
||||||
|
if not _is_house_or_bungalow(epc):
|
||||||
|
return False
|
||||||
|
return not _has_existing_pv(epc)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_existing_pv(epc: EpcPropertyData) -> bool:
|
||||||
|
"""Whether the dwelling already has PV — the *existing* arrays on the EPC
|
||||||
|
(existing-PV top-up is deferred), distinct from the Google potential."""
|
||||||
|
arrays: Optional[list[PhotovoltaicArray]] = epc.sap_energy_source.photovoltaic_arrays
|
||||||
|
return bool(arrays)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_house_or_bungalow(epc: EpcPropertyData) -> bool:
|
||||||
|
"""Whether the dwelling is a house or bungalow (not a flat/maisonette). The
|
||||||
|
Elmhurst path lodges the name; the API path a stringified RdSAP code
|
||||||
|
(`PROPERTY_TYPE_LOOKUP`: 0 House, 1 Bungalow, 2 Flat, 3 Maisonette)."""
|
||||||
|
raw: str = (epc.property_type or "").strip()
|
||||||
|
if raw.lower() in ("house", "bungalow"):
|
||||||
|
return True
|
||||||
|
if raw.isdigit():
|
||||||
|
return PROPERTY_TYPE_LOOKUP.get(int(raw)) in ("House", "Bungalow")
|
||||||
|
return False
|
||||||
168
domain/modelling/generators/solid_wall_recommendation.py
Normal file
168
domain/modelling/generators/solid_wall_recommendation.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""The solid-wall Recommendation Generator (IWI / EWI).
|
||||||
|
|
||||||
|
Detects an uninsulated *solid* (non-cavity) main wall and emits one "Main wall"
|
||||||
|
Recommendation carrying the constructable solid-wall insulation Options —
|
||||||
|
External (EWI) and/or Internal (IWI) — as mutually-exclusive Measure Options
|
||||||
|
the Optimiser chooses between (ADR-0019). A cavity wall is handled by
|
||||||
|
`recommend_cavity_wall`, never here.
|
||||||
|
|
||||||
|
Wall material is keyed on the RdSAP `wall_construction` code (codes 1-5 are
|
||||||
|
consistent across the API and Elmhurst ingestion paths; the wall *description*
|
||||||
|
is empty on the Elmhurst path, so it can't be the primary signal — it is a
|
||||||
|
fallback for the ambiguous higher codes, handled in a later slice). The trigger
|
||||||
|
is the as-built/uninsulated `wall_insulation_type`, mirroring the cavity
|
||||||
|
generator. Detection + pricing only; impact is produced later by scoring
|
||||||
|
(ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Final, Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
)
|
||||||
|
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
||||||
|
from domain.building_geometry import gross_heat_loss_wall_area
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
_EXTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.EXTERNAL_WALL_INSULATION
|
||||||
|
_INTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.INTERNAL_WALL_INSULATION
|
||||||
|
|
||||||
|
# RdSAP `wall_construction` codes (consistent across paths for 1-5).
|
||||||
|
_WALL_SOLID_BRICK: Final[int] = 3
|
||||||
|
_WALL_TIMBER_FRAME: Final[int] = 5
|
||||||
|
# System-built (precast/no-fines concrete): `WALL_SYSTEM_BUILT` in
|
||||||
|
# rdsap_uvalues. NB this is the Elmhurst code (`SY`); the *basement-wall* signal
|
||||||
|
# also lodges as 6 today (`BASEMENT_WALL_CONSTRUCTION_CODE`), so system-built is
|
||||||
|
# disambiguated from basement by `main_wall_is_basement` below — a basement wall
|
||||||
|
# is never solid-wall-insulation-suitable regardless.
|
||||||
|
_WALL_SYSTEM_BUILT: Final[int] = 6
|
||||||
|
# Park home (`PH`, the Elmhurst code-8 wall) — NOT system-built (ADR-0019: "do
|
||||||
|
# not key system-built on 8"). A park home's wall is a proprietary panel system
|
||||||
|
# our EWI/IWI model doesn't represent, so it is never solid-wall-suitable.
|
||||||
|
_WALL_PARK_HOME: Final[int] = 8
|
||||||
|
# `wall_insulation_type`: 4 = as-built / assumed (uninsulated) — the trigger.
|
||||||
|
_WALL_AS_BUILT: Final[int] = 4
|
||||||
|
# `wall_insulation_type` the overlay lodges: 1 = external, 3 = internal.
|
||||||
|
_WALL_INSULATION_EXTERNAL: Final[int] = 1
|
||||||
|
_WALL_INSULATION_INTERNAL: Final[int] = 3
|
||||||
|
# Recommended solid-wall insulation depth (mm); the calculator's λ default
|
||||||
|
# (0.04 W/m·K) matches Elmhurst's lodged thermal conductivity.
|
||||||
|
_SOLID_WALL_INSULATION_MM: Final[int] = 100
|
||||||
|
|
||||||
|
# Which solid-wall Options each construction can take (ADR-0019). Solid brick
|
||||||
|
# and system-built take both; timber-frame takes IWI only (EWI not
|
||||||
|
# constructable). The breathable cob/stone exclusions take neither (never keyed).
|
||||||
|
_CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[MeasureType, ...]]] = {
|
||||||
|
_WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
|
||||||
|
_WALL_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
|
||||||
|
_WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,),
|
||||||
|
}
|
||||||
|
|
||||||
|
_INSULATION_TYPE: Final[dict[str, int]] = {
|
||||||
|
_EXTERNAL_MEASURE_TYPE: _WALL_INSULATION_EXTERNAL,
|
||||||
|
_INTERNAL_MEASURE_TYPE: _WALL_INSULATION_INTERNAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DESCRIPTION: Final[dict[str, str]] = {
|
||||||
|
_EXTERNAL_MEASURE_TYPE: "External wall insulation (insulate the wall externally)",
|
||||||
|
_INTERNAL_MEASURE_TYPE: "Internal wall insulation (insulate the wall internally)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _solid_wall_option(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository, measure_type: MeasureType
|
||||||
|
) -> MeasureOption:
|
||||||
|
"""Build one solid-wall Measure Option: its insulation overlay (100 mm at the
|
||||||
|
External/Internal `wall_insulation_type`) priced at the heat-loss wall area."""
|
||||||
|
product = products.get(measure_type)
|
||||||
|
wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN)
|
||||||
|
cost = Cost(
|
||||||
|
total=wall_area * product.unit_cost_per_m2,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
return MeasureOption(
|
||||||
|
measure_type=measure_type,
|
||||||
|
description=_DESCRIPTION[measure_type],
|
||||||
|
overlay=EpcSimulation(
|
||||||
|
building_parts={
|
||||||
|
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
|
||||||
|
wall_insulation_type=_INSULATION_TYPE[measure_type],
|
||||||
|
wall_insulation_thickness=_SOLID_WALL_INSULATION_MM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_flat(epc: EpcPropertyData) -> bool:
|
||||||
|
"""Whether the dwelling is a flat. The Elmhurst path lodges the name
|
||||||
|
("Flat"); the API path a stringified RdSAP code (`PROPERTY_TYPE_LOOKUP`,
|
||||||
|
where 2 = Flat) — handle both representations."""
|
||||||
|
raw: str = (epc.property_type or "").strip()
|
||||||
|
if raw.lower() == "flat":
|
||||||
|
return True
|
||||||
|
if raw.isdigit():
|
||||||
|
return PROPERTY_TYPE_LOOKUP.get(int(raw)) == "Flat"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(
|
||||||
|
measure_type: str, restrictions: PlanningRestrictions, is_flat: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Whether a planning-gated Option survives (ADR-0019): EWI is removed by any
|
||||||
|
restriction or by the dwelling being a flat; IWI only by a listed/heritage
|
||||||
|
protection."""
|
||||||
|
if measure_type == _EXTERNAL_MEASURE_TYPE:
|
||||||
|
return not (restrictions.blocks_external or is_flat)
|
||||||
|
return not restrictions.blocks_internal
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_solid_wall(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
products: ProductRepository,
|
||||||
|
restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a solid-wall insulation Recommendation for an uninsulated, suitable
|
||||||
|
main wall — its constructable EWI/IWI Options, minus any the Property's
|
||||||
|
planning protections forbid — else None."""
|
||||||
|
main = next(
|
||||||
|
part
|
||||||
|
for part in epc.sap_building_parts
|
||||||
|
if part.identifier is BuildingPartIdentifier.MAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
if main.wall_insulation_type != _WALL_AS_BUILT:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if main.main_wall_is_basement:
|
||||||
|
return None # a (below-ground) basement wall is never EWI/IWI-suitable
|
||||||
|
|
||||||
|
construction: object = main.wall_construction
|
||||||
|
if not isinstance(construction, int):
|
||||||
|
return None # a free-text site-notes construction is not a code we key on
|
||||||
|
if construction == _WALL_PARK_HOME:
|
||||||
|
return None # park home (code 8) — proprietary panel, never EWI/IWI
|
||||||
|
measure_types = _CONSTRUCTABLE_OPTIONS.get(construction)
|
||||||
|
if not measure_types:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_flat: bool = _is_flat(epc)
|
||||||
|
allowed = tuple(
|
||||||
|
measure_type
|
||||||
|
for measure_type in measure_types
|
||||||
|
if _allowed(measure_type, restrictions, is_flat)
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
options = tuple(
|
||||||
|
_solid_wall_option(epc, products, measure_type) for measure_type in allowed
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Main wall", options=options)
|
||||||
68
domain/modelling/generators/ventilation_recommendation.py
Normal file
68
domain/modelling/generators/ventilation_recommendation.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""The ventilation Recommendation Generator.
|
||||||
|
|
||||||
|
Detects a dwelling that lacks adequate mechanical ventilation and emits a
|
||||||
|
Recommendation whose single Measure Option installs decentralised mechanical
|
||||||
|
extract ventilation (MEV), priced per installed unit. Like the wall/roof/floor
|
||||||
|
generators it does detection + pricing and carries no scores (ADR-0016).
|
||||||
|
|
||||||
|
Unlike them it is **not** run by the candidate-pool runner: ventilation is a
|
||||||
|
forced Measure Dependency of fabric insulation (it only ever costs SAP, so the
|
||||||
|
Optimiser would never choose it), so this Recommendation is consumed by
|
||||||
|
``optimisation.measure_dependency`` and injected into the package, never freely
|
||||||
|
selected. The legacy intervention was "mechanical, extract only"; the guard
|
||||||
|
mirrors legacy ``Property.has_ventilation``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import 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, VentilationOverlay
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
_VENTILATION_MEASURE_TYPE = MeasureType.MECHANICAL_VENTILATION
|
||||||
|
|
||||||
|
# The SAP10.2 §2 mechanical-ventilation kind installed: decentralised MEV
|
||||||
|
# ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind
|
||||||
|
# name), the legacy "mechanical, extract only" intervention.
|
||||||
|
_MEV_KIND = "EXTRACT_OR_PIV_OUTSIDE"
|
||||||
|
|
||||||
|
# Best practice installs one MEV unit per wet zone; the legacy recommendation
|
||||||
|
# fits two units per dwelling.
|
||||||
|
_VENTILATION_UNIT_COUNT = 2
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_ventilation(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a mechanical-ventilation Recommendation for a dwelling that is not
|
||||||
|
already mechanically ventilated, else None. The single Option installs MEV
|
||||||
|
and is priced at two fully-loaded units."""
|
||||||
|
if _already_mechanically_ventilated(epc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
product = products.get(_VENTILATION_MEASURE_TYPE)
|
||||||
|
cost = Cost(
|
||||||
|
total=product.unit_cost_per_m2 * _VENTILATION_UNIT_COUNT,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
option = MeasureOption(
|
||||||
|
measure_type=_VENTILATION_MEASURE_TYPE,
|
||||||
|
description=f"Install {_VENTILATION_UNIT_COUNT} mechanical extract ventilation units",
|
||||||
|
overlay=EpcSimulation(
|
||||||
|
ventilation=VentilationOverlay(mechanical_ventilation_kind=_MEV_KIND)
|
||||||
|
),
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Ventilation", options=(option,))
|
||||||
|
|
||||||
|
|
||||||
|
def _already_mechanically_ventilated(epc: EpcPropertyData) -> bool:
|
||||||
|
"""True when the dwelling already lodges a mechanical ventilation kind
|
||||||
|
(MEV/MVHR) — the legacy `has_ventilation` guard."""
|
||||||
|
return (
|
||||||
|
epc.sap_ventilation is not None
|
||||||
|
and epc.sap_ventilation.mechanical_ventilation_kind is not None
|
||||||
|
)
|
||||||
69
domain/modelling/generators/wall_recommendation.py
Normal file
69
domain/modelling/generators/wall_recommendation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""The wall Recommendation Generator.
|
||||||
|
|
||||||
|
Detects a treatable main wall on an EpcPropertyData and emits a Recommendation
|
||||||
|
whose Measure Option carries the Simulation Overlay for the intervention. No
|
||||||
|
scoring, no persistence — impact is produced later by scoring (ADR-0016).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
)
|
||||||
|
from domain.building_geometry import gross_heat_loss_wall_area
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||||
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
_CAVITY_MEASURE_TYPE = MeasureType.CAVITY_WALL_INSULATION
|
||||||
|
|
||||||
|
# RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6
|
||||||
|
# wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled
|
||||||
|
# cavity" (the calculator's dedicated filled-cavity U row — see
|
||||||
|
# domain/sap10_ml/rdsap_uvalues.py u_wall).
|
||||||
|
_CAVITY_WALL_CONSTRUCTION = 4
|
||||||
|
_WALL_UNINSULATED = 4
|
||||||
|
_FILLED_CAVITY = 2
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_cavity_wall(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[Recommendation]:
|
||||||
|
"""Return a cavity-fill Recommendation for the main wall when it is an
|
||||||
|
uninsulated cavity wall, else None. The Option's cost is the heat-loss wall
|
||||||
|
area priced at the Product's fully-loaded unit cost, with its contingency."""
|
||||||
|
main = next(
|
||||||
|
part
|
||||||
|
for part in epc.sap_building_parts
|
||||||
|
if part.identifier is BuildingPartIdentifier.MAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
main.wall_construction != _CAVITY_WALL_CONSTRUCTION
|
||||||
|
or main.wall_insulation_type != _WALL_UNINSULATED
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
product = products.get(_CAVITY_MEASURE_TYPE)
|
||||||
|
wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN)
|
||||||
|
cost = Cost(
|
||||||
|
total=wall_area * product.unit_cost_per_m2,
|
||||||
|
contingency_rate=product.contingency_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
option = MeasureOption(
|
||||||
|
measure_type=_CAVITY_MEASURE_TYPE,
|
||||||
|
description="Cavity wall insulation (fill the existing cavity)",
|
||||||
|
overlay=EpcSimulation(
|
||||||
|
building_parts={
|
||||||
|
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
|
||||||
|
wall_insulation_type=_FILLED_CAVITY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cost=cost,
|
||||||
|
material_id=product.id,
|
||||||
|
)
|
||||||
|
return Recommendation(surface="Main wall", 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
|
||||||
|
}
|
||||||
39
domain/modelling/measure_type.py
Normal file
39
domain/modelling/measure_type.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""MeasureType — the canonical vocabulary of the measures the Modelling stage
|
||||||
|
models.
|
||||||
|
|
||||||
|
One member per Recommendation Generator option. A ``StrEnum`` so each member
|
||||||
|
*is* its string value: it persists straight into the ``recommendation`` varchar
|
||||||
|
column, is the optimiser's group-by key, and compares equal to the raw strings
|
||||||
|
the catalogue and EPC carry — so it can replace the per-generator string
|
||||||
|
constants as the single source of truth without a persistence or optimiser
|
||||||
|
change. It is also the vocabulary the ``considered_measures`` allowlist speaks
|
||||||
|
(mirroring the legacy engine's ``inclusions``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class MeasureType(StrEnum):
|
||||||
|
"""A measure the Modelling stage can recommend (CONTEXT.md)."""
|
||||||
|
|
||||||
|
CAVITY_WALL_INSULATION = "cavity_wall_insulation"
|
||||||
|
EXTERNAL_WALL_INSULATION = "external_wall_insulation"
|
||||||
|
INTERNAL_WALL_INSULATION = "internal_wall_insulation"
|
||||||
|
LOFT_INSULATION = "loft_insulation"
|
||||||
|
SLOPING_CEILING_INSULATION = "sloping_ceiling_insulation"
|
||||||
|
FLAT_ROOF_INSULATION = "flat_roof_insulation"
|
||||||
|
SUSPENDED_FLOOR_INSULATION = "suspended_floor_insulation"
|
||||||
|
SOLID_FLOOR_INSULATION = "solid_floor_insulation"
|
||||||
|
DOUBLE_GLAZING = "double_glazing"
|
||||||
|
SECONDARY_GLAZING = "secondary_glazing"
|
||||||
|
LOW_ENERGY_LIGHTING = "low_energy_lighting"
|
||||||
|
MECHANICAL_VENTILATION = "mechanical_ventilation"
|
||||||
|
HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters"
|
||||||
|
AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump"
|
||||||
|
GAS_BOILER_UPGRADE = "gas_boiler_upgrade"
|
||||||
|
SYSTEM_TUNE_UP = "system_tune_up"
|
||||||
|
SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned"
|
||||||
|
SOLAR_PV = "solar_pv"
|
||||||
|
SECONDARY_HEATING_REMOVAL = "secondary_heating_removal"
|
||||||
0
domain/modelling/optimisation/__init__.py
Normal file
0
domain/modelling/optimisation/__init__.py
Normal file
73
domain/modelling/optimisation/measure_dependency.py
Normal file
73
domain/modelling/optimisation/measure_dependency.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""The ventilation Measure Dependency — a data-declared "fabric insulation
|
||||||
|
requires adequate ventilation" edge (CONTEXT.md: Measure Dependency; ADR-0016).
|
||||||
|
|
||||||
|
Wall insulation tightens the envelope, so SAP10.2 (and good practice) require
|
||||||
|
adequate ventilation alongside it. The optimiser must never *choose* ventilation
|
||||||
|
(it only ever costs SAP), so it is excluded from the candidate pool and instead
|
||||||
|
injected into the Optimised Package before the whole-package re-score, where its
|
||||||
|
real — negative — SAP contribution lands in the truthful figure and the repair
|
||||||
|
decision. The trigger set is held as data (mirroring the legacy
|
||||||
|
`assumptions.measures_needing_ventilation`), so extending it (e.g. to roof
|
||||||
|
insulation) is a data edit, not control flow.
|
||||||
|
|
||||||
|
This module owns only the **selection semantics** (the trigger set + the
|
||||||
|
forced-edge wrapping). **Production** — detecting that the dwelling needs
|
||||||
|
ventilation and pricing the work — is the ventilation Recommendation Generator's
|
||||||
|
job (`generators.ventilation_recommendation`), exactly like wall/roof/floor.
|
||||||
|
`ventilation_dependency` delegates to it and wraps its Recommendation into the
|
||||||
|
forced edge; the Recommendation is consumed here, never offered to the pool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.modelling.generators.ventilation_recommendation import (
|
||||||
|
recommend_ventilation,
|
||||||
|
)
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption
|
||||||
|
from domain.modelling.recommendation import MeasureOption, Recommendation
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
|
# The measure types that force a ventilation dependency (cf. legacy
|
||||||
|
# `assumptions.measures_needing_ventilation`).
|
||||||
|
MEASURES_NEEDING_VENTILATION: frozenset[MeasureType] = frozenset(
|
||||||
|
{
|
||||||
|
MeasureType.CAVITY_WALL_INSULATION,
|
||||||
|
MeasureType.INTERNAL_WALL_INSULATION,
|
||||||
|
MeasureType.EXTERNAL_WALL_INSULATION,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ventilation_dependency(
|
||||||
|
epc: EpcPropertyData, products: ProductRepository
|
||||||
|
) -> Optional[MeasureDependency]:
|
||||||
|
"""The ventilation Measure Dependency for a dwelling, or None when it needs
|
||||||
|
no ventilation (already mechanically ventilated). Delegates production —
|
||||||
|
detection + pricing — to the ventilation Recommendation Generator, then
|
||||||
|
wraps its Recommendation into the forced "fabric requires ventilation"
|
||||||
|
edge."""
|
||||||
|
recommendation: Optional[Recommendation] = recommend_ventilation(epc, products)
|
||||||
|
if recommendation is None:
|
||||||
|
return None
|
||||||
|
return MeasureDependency(
|
||||||
|
triggers=MEASURES_NEEDING_VENTILATION,
|
||||||
|
# Forced, never freely scored — the role-1 signal is irrelevant (0.0).
|
||||||
|
required=ScoredOption(option=_required_option(recommendation), sap_gain=0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_option(recommendation: Recommendation) -> MeasureOption:
|
||||||
|
"""Pick the Option the dependency forces in — the cheapest, mirroring the
|
||||||
|
legacy "default to the cheapest ventilation unit". There is one MEV Option
|
||||||
|
today; this readies the seam for MEV-c / MVHR alternatives."""
|
||||||
|
return min(recommendation.options, key=_option_total)
|
||||||
|
|
||||||
|
|
||||||
|
def _option_total(option: MeasureOption) -> float:
|
||||||
|
if option.cost is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"ventilation option {option.measure_type!r} has no cost; cannot force in"
|
||||||
|
)
|
||||||
|
return option.cost.total
|
||||||
376
domain/modelling/optimisation/optimiser.py
Normal file
376
domain/modelling/optimisation/optimiser.py
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
"""The Optimiser core — a grouped (multiple-choice) knapsack over per-Option
|
||||||
|
role-1 scores (ADR-0016).
|
||||||
|
|
||||||
|
Recycles the formulation of the legacy ``GainOptimiser`` / ``CostOptimiser``
|
||||||
|
(``recommendations/optimiser/``): pick **at most one** Option per Recommendation
|
||||||
|
(disjoint groups, no cross-group exclusion constraints — the Recommendation
|
||||||
|
partition makes selected overlays collision-free), maximising total SAP gain
|
||||||
|
subject to the Scenario budget. The legacy classes solve this as a `mip` MILP;
|
||||||
|
here it is an exact pure-Python multiple-choice knapsack — no native solver
|
||||||
|
dependency, so it runs everywhere and is deterministically testable.
|
||||||
|
|
||||||
|
This is the warm-start **signal** only: per ADR-0016 the role-1 per-Option
|
||||||
|
scores are approximate (independent-vs-baseline), so the truthful figure comes
|
||||||
|
from the whole-package re-score + greedy repair, not from this selection. Exact
|
||||||
|
enumeration is therefore more than adequate, and at retrofit scale (a handful
|
||||||
|
of Recommendations, a few Options each) the candidate space — ``Π(|group|+1)``
|
||||||
|
— is tiny.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Protocol, Sequence
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.scoring.package_scorer import Score
|
||||||
|
from domain.modelling.recommendation import MeasureOption
|
||||||
|
from domain.modelling.simulation import EpcSimulation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScoredOption:
|
||||||
|
"""A candidate Measure Option paired with its role-1 (independent-vs-
|
||||||
|
baseline) SAP gain — the optimiser's input signal. Cost is read from the
|
||||||
|
Option; the gain is supplied by scoring."""
|
||||||
|
|
||||||
|
option: MeasureOption
|
||||||
|
sap_gain: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasureDependency:
|
||||||
|
"""A forced "A requires B" edge (ADR-0016 Measure Dependency): when any
|
||||||
|
selected Option's `measure_type` is in `triggers`, `required` is injected
|
||||||
|
into the package **before** the whole-package re-score — never competing in
|
||||||
|
the optimiser pool, but its (negative) SAP and its cost land in the truthful
|
||||||
|
figure, the repair decision, and the persisted package. Held as data so
|
||||||
|
extending the triggers is a data edit, not control flow."""
|
||||||
|
|
||||||
|
triggers: frozenset[MeasureType]
|
||||||
|
required: ScoredOption
|
||||||
|
|
||||||
|
|
||||||
|
def _option_cost(option: MeasureOption) -> float:
|
||||||
|
if option.cost is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"measure option {option.measure_type!r} has no cost; cannot optimise"
|
||||||
|
)
|
||||||
|
return option.cost.total
|
||||||
|
|
||||||
|
|
||||||
|
def optimise(
|
||||||
|
groups: list[list[ScoredOption]],
|
||||||
|
budget: Optional[float],
|
||||||
|
dependencies: Sequence[MeasureDependency] = (),
|
||||||
|
) -> list[ScoredOption]:
|
||||||
|
"""Select at most one ScoredOption per group to maximise total SAP gain
|
||||||
|
subject to ``budget`` (None = unconstrained). Exact: enumerates every
|
||||||
|
pick-one-or-skip-per-group package, keeps the affordable one with the
|
||||||
|
greatest gain, breaking ties toward lower cost. Returns the selected
|
||||||
|
ScoredOptions (empty if nothing affordable beats selecting none).
|
||||||
|
|
||||||
|
Candidate cost and gain are evaluated with any forced ``dependencies`` the
|
||||||
|
candidate triggers folded in (ADR-0016 amendment — ventilation-aware), so a
|
||||||
|
package is judged on what it will really cost and gain once its dependency
|
||||||
|
is injected. The returned list holds only the group selections, not the
|
||||||
|
folded-in dependencies (the caller injects those)."""
|
||||||
|
choices_per_group: list[list[Optional[ScoredOption]]] = [
|
||||||
|
[None, *group] for group in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
best: list[ScoredOption] = []
|
||||||
|
best_gain: float = -1.0
|
||||||
|
best_cost: float = 0.0
|
||||||
|
for combo in itertools.product(*choices_per_group):
|
||||||
|
selected: list[ScoredOption] = [
|
||||||
|
choice for choice in combo if choice is not None
|
||||||
|
]
|
||||||
|
total_cost, total_gain = _augmented_cost_gain(selected, dependencies)
|
||||||
|
if budget is not None and total_cost > budget:
|
||||||
|
continue
|
||||||
|
# Maximise gain; on a tie prefer the cheaper package.
|
||||||
|
if (total_gain, -total_cost) > (best_gain, -best_cost):
|
||||||
|
best, best_gain, best_cost = selected, total_gain, total_cost
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _augmented_cost_gain(
|
||||||
|
selected: list[ScoredOption], dependencies: Sequence[MeasureDependency]
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""The total cost and total role-1 gain of a candidate **with the forced
|
||||||
|
dependencies it triggers folded in** — what the package will really cost and
|
||||||
|
gain once injected. Dependency gains are negative (ventilation), so this is
|
||||||
|
how selection 'prices' the ventilation a wall drags in."""
|
||||||
|
augmented: list[ScoredOption] = _inject(selected, dependencies)
|
||||||
|
total_cost: float = sum(_option_cost(s.option) for s in augmented)
|
||||||
|
total_gain: float = sum(s.sap_gain for s in augmented)
|
||||||
|
return total_cost, total_gain
|
||||||
|
|
||||||
|
|
||||||
|
def optimise_min_cost(
|
||||||
|
groups: list[list[ScoredOption]],
|
||||||
|
budget: Optional[float],
|
||||||
|
target_gain: float,
|
||||||
|
dependencies: Sequence[MeasureDependency] = (),
|
||||||
|
) -> Optional[list[ScoredOption]]:
|
||||||
|
"""Select at most one ScoredOption per group to **minimise total cost**
|
||||||
|
subject to total SAP gain ``>= target_gain`` and total cost ``<= budget``
|
||||||
|
(None = unconstrained) — the least-cost-to-target objective (ADR-0016
|
||||||
|
amendment). Exact enumeration over every pick-one-or-skip-per-group package.
|
||||||
|
Returns the cheapest target-reaching package (ties broken toward the higher
|
||||||
|
gain — "recommend more"), or ``None`` when no package within budget reaches
|
||||||
|
the target (the caller falls back to max-gain). A non-positive
|
||||||
|
``target_gain`` is met by the empty package.
|
||||||
|
|
||||||
|
Candidate cost and gain are evaluated with any forced ``dependencies`` the
|
||||||
|
candidate triggers folded in (ventilation-aware), so a wall whose mandatory
|
||||||
|
ventilation cancels its gain is not mistaken for a cheap way to the target.
|
||||||
|
The returned list holds only the group selections, not the dependencies."""
|
||||||
|
choices_per_group: list[list[Optional[ScoredOption]]] = [
|
||||||
|
[None, *group] for group in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
best: Optional[list[ScoredOption]] = None
|
||||||
|
best_cost: float = 0.0
|
||||||
|
best_gain: float = 0.0
|
||||||
|
for combo in itertools.product(*choices_per_group):
|
||||||
|
selected: list[ScoredOption] = [
|
||||||
|
choice for choice in combo if choice is not None
|
||||||
|
]
|
||||||
|
total_cost, total_gain = _augmented_cost_gain(selected, dependencies)
|
||||||
|
if budget is not None and total_cost > budget:
|
||||||
|
continue
|
||||||
|
if total_gain < target_gain:
|
||||||
|
continue
|
||||||
|
# Minimise cost; on a tie prefer the higher-gain package.
|
||||||
|
if best is None or (-total_cost, total_gain) > (-best_cost, best_gain):
|
||||||
|
best, best_cost, best_gain = selected, total_cost, total_gain
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
class Scorer(Protocol):
|
||||||
|
"""The whole-package scoring primitive — `PackageScorer` satisfies it.
|
||||||
|
Kept structural so the repair loop is testable with a stub scorer."""
|
||||||
|
|
||||||
|
def score(
|
||||||
|
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||||||
|
) -> Score: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OptimisedPackage:
|
||||||
|
"""The package the Optimiser commits to: the selected ScoredOptions and the
|
||||||
|
**truthful** whole-package re-score (ADR-0016 role 2), after any greedy
|
||||||
|
repair. The per-Option `sap_gain` on the selections is the approximate
|
||||||
|
warm-start signal — never the package total, which is `score`."""
|
||||||
|
|
||||||
|
selected: list[ScoredOption]
|
||||||
|
score: Score
|
||||||
|
|
||||||
|
|
||||||
|
def optimise_package(
|
||||||
|
*,
|
||||||
|
groups: list[list[ScoredOption]],
|
||||||
|
scorer: Scorer,
|
||||||
|
baseline_epc: EpcPropertyData,
|
||||||
|
budget: Optional[float],
|
||||||
|
target_sap: Optional[float],
|
||||||
|
dependencies: Sequence[MeasureDependency] = (),
|
||||||
|
) -> OptimisedPackage:
|
||||||
|
"""Select the Optimised Package for one Property + Scenario (ADR-0016 +
|
||||||
|
its amendment).
|
||||||
|
|
||||||
|
With a ``target_sap`` (an Increasing EPC goal) the objective is
|
||||||
|
**least-cost-to-target**: warm-start with the cheapest package whose role-1
|
||||||
|
signal reaches the target gain within budget (`optimise_min_cost`), inject
|
||||||
|
any forced Measure Dependencies, re-score the whole package for the truth,
|
||||||
|
and greedy-repair toward ``target_sap`` while it undershoots. If the target
|
||||||
|
is unreachable within budget — the warm-start is infeasible, or the repaired
|
||||||
|
package still falls short on the true score — fall back to the **maximum
|
||||||
|
improvement the budget buys** (`optimise`). The min-cost objective stops at
|
||||||
|
the target and does not overshoot into a higher band; surplus budget is left
|
||||||
|
unspent.
|
||||||
|
|
||||||
|
Without a ``target_sap`` (other goals) it is max-gain-within-budget. Either
|
||||||
|
way forced dependencies are injected on every path and their cost counts
|
||||||
|
toward the spend; the returned `selected` includes them. ``budget`` of None
|
||||||
|
means unconstrained."""
|
||||||
|
baseline_sap: float = _score(scorer, baseline_epc, []).sap_continuous
|
||||||
|
# Score each forced dependency's independent (role-1) impact so the selection
|
||||||
|
# can price the ventilation a wall drags in — negative for ventilation.
|
||||||
|
deps: list[MeasureDependency] = _with_role1_signals(
|
||||||
|
dependencies, scorer, baseline_epc, baseline_sap
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_sap is None:
|
||||||
|
return _max_gain_package(groups, scorer, baseline_epc, budget, deps)
|
||||||
|
|
||||||
|
target_gain: float = target_sap - baseline_sap
|
||||||
|
chosen: Optional[list[ScoredOption]] = optimise_min_cost(
|
||||||
|
groups, budget, target_gain, deps
|
||||||
|
)
|
||||||
|
if chosen is not None:
|
||||||
|
package: OptimisedPackage = _repair_to_target(
|
||||||
|
chosen, groups, deps, scorer, baseline_epc, budget, target_sap
|
||||||
|
)
|
||||||
|
if package.score.sap_continuous >= target_sap:
|
||||||
|
return package
|
||||||
|
# Target unreachable within budget (warm-start infeasible, or the repaired
|
||||||
|
# package still falls short) → best effort: the most improvement budget buys.
|
||||||
|
return _max_gain_package(groups, scorer, baseline_epc, budget, deps)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_role1_signals(
|
||||||
|
dependencies: Sequence[MeasureDependency],
|
||||||
|
scorer: Scorer,
|
||||||
|
baseline_epc: EpcPropertyData,
|
||||||
|
baseline_sap: float,
|
||||||
|
) -> list[MeasureDependency]:
|
||||||
|
"""Replace each dependency's placeholder role-1 signal with its true
|
||||||
|
independent-vs-baseline SAP impact, so the selectors price what the
|
||||||
|
dependency really does to the package (ADR-0016 amendment)."""
|
||||||
|
scored: list[MeasureDependency] = []
|
||||||
|
for dependency in dependencies:
|
||||||
|
signal: float = (
|
||||||
|
scorer.score(
|
||||||
|
baseline_epc, [dependency.required.option.overlay]
|
||||||
|
).sap_continuous
|
||||||
|
- baseline_sap
|
||||||
|
)
|
||||||
|
scored.append(
|
||||||
|
MeasureDependency(
|
||||||
|
triggers=dependency.triggers,
|
||||||
|
required=ScoredOption(
|
||||||
|
option=dependency.required.option, sap_gain=signal
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return scored
|
||||||
|
|
||||||
|
|
||||||
|
def _max_gain_package(
|
||||||
|
groups: list[list[ScoredOption]],
|
||||||
|
scorer: Scorer,
|
||||||
|
baseline_epc: EpcPropertyData,
|
||||||
|
budget: Optional[float],
|
||||||
|
dependencies: Sequence[MeasureDependency],
|
||||||
|
) -> OptimisedPackage:
|
||||||
|
"""Max-gain-within-budget, dependencies priced in the selection then
|
||||||
|
injected and re-scored — the no-target objective and the unreachable-target
|
||||||
|
fallback."""
|
||||||
|
chosen: list[ScoredOption] = optimise(groups, budget, dependencies)
|
||||||
|
selected: list[ScoredOption] = _inject(chosen, dependencies)
|
||||||
|
return OptimisedPackage(
|
||||||
|
selected=selected, score=_score(scorer, baseline_epc, selected)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_to_target(
|
||||||
|
chosen: list[ScoredOption],
|
||||||
|
groups: list[list[ScoredOption]],
|
||||||
|
dependencies: Sequence[MeasureDependency],
|
||||||
|
scorer: Scorer,
|
||||||
|
baseline_epc: EpcPropertyData,
|
||||||
|
budget: Optional[float],
|
||||||
|
target_sap: float,
|
||||||
|
) -> OptimisedPackage:
|
||||||
|
"""Inject dependencies onto the warm-start, re-score for the truth, then
|
||||||
|
greedy-add the untreated-group Option with the best marginal SAP-per-£ (its
|
||||||
|
own dependency folded in) until the true SAP clears ``target_sap`` or no
|
||||||
|
affordable improving Option remains."""
|
||||||
|
selected: list[ScoredOption] = _inject(chosen, dependencies)
|
||||||
|
score: Score = _score(scorer, baseline_epc, selected)
|
||||||
|
while score.sap_continuous < target_sap:
|
||||||
|
candidate = _best_repair_candidate(
|
||||||
|
groups, chosen, dependencies, scorer, baseline_epc, score, budget
|
||||||
|
)
|
||||||
|
if candidate is None:
|
||||||
|
break
|
||||||
|
chosen = [*chosen, candidate]
|
||||||
|
selected = _inject(chosen, dependencies)
|
||||||
|
score = _score(scorer, baseline_epc, selected)
|
||||||
|
return OptimisedPackage(selected=selected, score=score)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject(
|
||||||
|
chosen: list[ScoredOption], dependencies: Sequence[MeasureDependency]
|
||||||
|
) -> list[ScoredOption]:
|
||||||
|
"""``chosen`` plus every forced dependency whose triggers intersect the
|
||||||
|
chosen measure-types, de-duplicated by required measure-type (a dependency
|
||||||
|
several measures trigger is injected once)."""
|
||||||
|
chosen_types: set[MeasureType] = {s.option.measure_type for s in chosen}
|
||||||
|
injected: list[ScoredOption] = list(chosen)
|
||||||
|
present: set[MeasureType] = set(chosen_types)
|
||||||
|
for dependency in dependencies:
|
||||||
|
required_type: MeasureType = dependency.required.option.measure_type
|
||||||
|
if dependency.triggers & chosen_types and required_type not in present:
|
||||||
|
injected.append(dependency.required)
|
||||||
|
present.add(required_type)
|
||||||
|
return injected
|
||||||
|
|
||||||
|
|
||||||
|
def _package_cost(selected: list[ScoredOption]) -> float:
|
||||||
|
return sum(_option_cost(s.option) for s in selected)
|
||||||
|
|
||||||
|
|
||||||
|
def _score(
|
||||||
|
scorer: Scorer, baseline_epc: EpcPropertyData, selected: list[ScoredOption]
|
||||||
|
) -> Score:
|
||||||
|
return scorer.score(baseline_epc, [s.option.overlay for s in selected])
|
||||||
|
|
||||||
|
|
||||||
|
def _used_group_indices(
|
||||||
|
groups: list[list[ScoredOption]], selected: list[ScoredOption]
|
||||||
|
) -> set[int]:
|
||||||
|
"""Indices of groups already represented in the selection (≤1 per group),
|
||||||
|
matched by object identity — the selection holds the very ScoredOptions
|
||||||
|
from ``groups``."""
|
||||||
|
return {
|
||||||
|
index
|
||||||
|
for index, group in enumerate(groups)
|
||||||
|
if any(option is chosen for option in group for chosen in selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _best_repair_candidate(
|
||||||
|
groups: list[list[ScoredOption]],
|
||||||
|
chosen: list[ScoredOption],
|
||||||
|
dependencies: Sequence[MeasureDependency],
|
||||||
|
scorer: Scorer,
|
||||||
|
baseline_epc: EpcPropertyData,
|
||||||
|
current: Score,
|
||||||
|
budget: Optional[float],
|
||||||
|
) -> Optional[ScoredOption]:
|
||||||
|
"""The untreated-group Option giving the best **marginal** SAP-per-£ when
|
||||||
|
added to the current package — re-scored (not the role-1 signal) with any
|
||||||
|
ventilation dependency it newly triggers folded in, so both its SAP and its
|
||||||
|
incremental cost are truthful. Affordable when the resulting whole-package
|
||||||
|
cost is within ``budget`` and strictly improving. None if there is none."""
|
||||||
|
used: set[int] = _used_group_indices(groups, chosen)
|
||||||
|
base_cost: float = _package_cost(_inject(chosen, dependencies))
|
||||||
|
best: Optional[ScoredOption] = None
|
||||||
|
best_ratio: float = 0.0
|
||||||
|
for index, group in enumerate(groups):
|
||||||
|
if index in used:
|
||||||
|
continue
|
||||||
|
for option in group:
|
||||||
|
trial_selected: list[ScoredOption] = _inject(
|
||||||
|
[*chosen, option], dependencies
|
||||||
|
)
|
||||||
|
package_cost: float = _package_cost(trial_selected)
|
||||||
|
if budget is not None and package_cost > budget:
|
||||||
|
continue
|
||||||
|
trial: Score = _score(scorer, baseline_epc, trial_selected)
|
||||||
|
marginal: float = trial.sap_continuous - current.sap_continuous
|
||||||
|
if marginal <= 0.0:
|
||||||
|
continue
|
||||||
|
incremental: float = package_cost - base_cost
|
||||||
|
ratio: float = (
|
||||||
|
float("inf") if incremental <= 0.0 else marginal / incremental
|
||||||
|
)
|
||||||
|
if ratio > best_ratio:
|
||||||
|
best, best_ratio = option, ratio
|
||||||
|
return best
|
||||||
148
domain/modelling/plan.py
Normal file
148
domain/modelling/plan.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""Plan and Plan Measure — the Modelling stage's persisted output (ADR-0017).
|
||||||
|
|
||||||
|
A **Plan** is the per-Property output of one Scenario's modelling run: the
|
||||||
|
selected **Optimised Package** (its **Plan Measures**) plus the Property's
|
||||||
|
post-retrofit figures. It is single-phase — multi-phase is deferred
|
||||||
|
(ADR-0005) — so the headline figures are flat on the Plan.
|
||||||
|
|
||||||
|
A **Plan Measure** is the *output* counterpart of a Recommendation's candidate
|
||||||
|
Option: the one Option the Optimiser kept, frozen with its installed **Cost**
|
||||||
|
and its final-package (role-3) attributed **impact**. See CONTEXT.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
from domain.billing.bill import Bill
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.scoring.package_scorer import Score
|
||||||
|
from domain.modelling.recommendation import Cost
|
||||||
|
from domain.modelling.scoring.scoring import MeasureImpact
|
||||||
|
from domain.modelling.valuation import ValuationUplift, estimate_valuation_uplift
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlanMeasure:
|
||||||
|
"""One selected Measure Option as it lands in a Plan: the measure, its
|
||||||
|
installed Cost, and its role-3 (final-package cascade) attributed impact.
|
||||||
|
|
||||||
|
`kwh_savings` (delivered energy) and `energy_cost_savings` (£) are this
|
||||||
|
measure's slice of the telescoping bill cascade — its marginal Bill delta
|
||||||
|
over the running package state. They can be negative (e.g. ventilation
|
||||||
|
increases energy) and telescope exactly to the Plan totals; `None` until
|
||||||
|
billing has run (persisted as NULL — ADR-0014 amendment). They are distinct
|
||||||
|
from `impact.energy_savings_kwh_per_yr`, which is *primary* energy."""
|
||||||
|
|
||||||
|
measure_type: MeasureType
|
||||||
|
description: str
|
||||||
|
cost: Cost
|
||||||
|
impact: MeasureImpact
|
||||||
|
kwh_savings: Optional[float] = None
|
||||||
|
energy_cost_savings: Optional[float] = None
|
||||||
|
# The catalogue id of the Product installed (from the selected Option),
|
||||||
|
# persisted as ``recommendation.material_id``. None when priced from a
|
||||||
|
# catalogue with no ids.
|
||||||
|
material_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Plan:
|
||||||
|
"""A Property's Plan for one Scenario: the selected Plan Measures and the
|
||||||
|
baseline / post-retrofit whole-package Scores. The persisted headline
|
||||||
|
figures are derived from these (cost aggregates, CO₂ saving, post band).
|
||||||
|
|
||||||
|
`baseline_bill` / `post_bill` are the Bills derived (at one Fuel Rates
|
||||||
|
snapshot) for the unmodified and post-package end-states; the energy/bill
|
||||||
|
headline figures derive from them, and are `None` until billing has run
|
||||||
|
(persisted as NULL — ADR-0014 amendment)."""
|
||||||
|
|
||||||
|
measures: tuple[PlanMeasure, ...]
|
||||||
|
baseline: Score
|
||||||
|
post_retrofit: Score
|
||||||
|
baseline_bill: Optional[Bill] = None
|
||||||
|
post_bill: Optional[Bill] = None
|
||||||
|
# The Property's current market value (a Property Valuation), when known.
|
||||||
|
# Mostly absent — then the Valuation Uplift is percentage-only and its £
|
||||||
|
# forms are None (ADR-0018).
|
||||||
|
current_market_value: Optional[float] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cost_of_works(self) -> float:
|
||||||
|
"""Sum of the Plan Measures' fully-loaded Costs."""
|
||||||
|
return sum((measure.cost.total for measure in self.measures), 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contingency_cost(self) -> float:
|
||||||
|
"""Sum of each Plan Measure's contingency (its Cost total × its
|
||||||
|
per-Measure-Type contingency rate)."""
|
||||||
|
return sum(
|
||||||
|
(
|
||||||
|
measure.cost.total * measure.cost.contingency_rate
|
||||||
|
for measure in self.measures
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post_sap_continuous(self) -> float:
|
||||||
|
"""The whole-package re-score's un-rounded SAP rating."""
|
||||||
|
return self.post_retrofit.sap_continuous
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post_epc_rating(self) -> Epc:
|
||||||
|
"""The post-retrofit EPC band, from the rounded SAP rating."""
|
||||||
|
return Epc.from_sap_score(round(self.post_retrofit.sap_continuous))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def baseline_epc_rating(self) -> Epc:
|
||||||
|
"""The baseline EPC band, from the rounded baseline SAP rating."""
|
||||||
|
return Epc.from_sap_score(round(self.baseline.sap_continuous))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valuation(self) -> ValuationUplift:
|
||||||
|
"""The Valuation Uplift this Plan produces — the estimated market-value
|
||||||
|
increase from the baseline -> post band jump (ADR-0018). Always a
|
||||||
|
percentage; the £ forms are populated only when `current_market_value`
|
||||||
|
is known, capped at 2x the works + contingency cost."""
|
||||||
|
return estimate_valuation_uplift(
|
||||||
|
current_band=self.baseline_epc_rating.value,
|
||||||
|
target_band=self.post_epc_rating.value,
|
||||||
|
current_value=self.current_market_value,
|
||||||
|
total_cost=self.cost_of_works + self.contingency_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def co2_savings_kg_per_yr(self) -> float:
|
||||||
|
"""Whole-package CO₂ reduction (kg/yr) vs the baseline re-score. The
|
||||||
|
persistence mapper converts to tonnes for the live column contract."""
|
||||||
|
return self.baseline.co2_kg_per_yr - self.post_retrofit.co2_kg_per_yr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post_energy_bill(self) -> Optional[float]:
|
||||||
|
"""The post-package annual energy bill (£), or None if not billed."""
|
||||||
|
return None if self.post_bill is None else self.post_bill.total_gbp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def energy_bill_savings(self) -> Optional[float]:
|
||||||
|
"""Annual bill reduction (£) vs the baseline bill, both at the same Fuel
|
||||||
|
Rates snapshot. None unless both bills were derived."""
|
||||||
|
if self.baseline_bill is None or self.post_bill is None:
|
||||||
|
return None
|
||||||
|
return self.baseline_bill.total_gbp - self.post_bill.total_gbp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post_energy_consumption(self) -> Optional[float]:
|
||||||
|
"""The post-package total delivered energy (kWh), or None if not billed."""
|
||||||
|
return None if self.post_bill is None else self.post_bill.total_consumption_kwh
|
||||||
|
|
||||||
|
@property
|
||||||
|
def energy_consumption_savings(self) -> Optional[float]:
|
||||||
|
"""Annual delivered-energy reduction (kWh) vs the baseline. None unless
|
||||||
|
both bills were derived."""
|
||||||
|
if self.baseline_bill is None or self.post_bill is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
self.baseline_bill.total_consumption_kwh
|
||||||
|
- self.post_bill.total_consumption_kwh
|
||||||
|
)
|
||||||
23
domain/modelling/portfolio_goal.py
Normal file
23
domain/modelling/portfolio_goal.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""PortfolioGoal — the retrofit objective a Scenario is scored against.
|
||||||
|
|
||||||
|
Domain vocabulary (ubiquitous language): the goal a user sets for a Scenario —
|
||||||
|
raise the EPC band, cut CO₂, cut energy, or improve valuation. The enum
|
||||||
|
*values* are the canonical strings stored in the live ``scenario.goal`` /
|
||||||
|
``portfolio.goal`` columns and used by the front end; the Modelling stage's
|
||||||
|
Optimiser branches on them (#1160).
|
||||||
|
|
||||||
|
Lives in ``domain/`` (not ``backend/``) so the domain, persistence
|
||||||
|
(``infrastructure/postgres/modelling``) and legacy app layers share one
|
||||||
|
definition — co-located with ``scenario.py``, which carries the goal. See
|
||||||
|
CONTEXT.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioGoal(enum.Enum):
|
||||||
|
VALUATION_IMPROVEMENT = "Valuation Improvement"
|
||||||
|
INCREASING_EPC = "Increasing EPC"
|
||||||
|
REDUCING_CO2_EMISSIONS = "Reducing CO2 emissions"
|
||||||
|
ENERGY_SAVINGS = "Energy Savings"
|
||||||
|
NONE = "None"
|
||||||
22
domain/modelling/product.py
Normal file
22
domain/modelling/product.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Product — a catalogue entry a Measure Option installs.
|
||||||
|
|
||||||
|
Carries the data needed to price an Option: a fully-loaded unit cost and the
|
||||||
|
per-Measure-Type contingency rate carried alongside it (CONTEXT.md). The
|
||||||
|
catalogue is equipment-dominated (heat pumps, glazing, PV) — hence "Product",
|
||||||
|
not "material". Read via a `ProductRepository`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Product:
|
||||||
|
measure_type: str
|
||||||
|
unit_cost_per_m2: float
|
||||||
|
contingency_rate: float
|
||||||
|
# The catalogue row id, threaded onto the persisted Plan Measure as
|
||||||
|
# ``recommendation.material_id`` (the single-material reference that replaces
|
||||||
|
# the retired ``recommendation_materials`` BOM). Optional: the JSON
|
||||||
|
# stopgap catalogue carries no ids.
|
||||||
|
id: Optional[int] = None
|
||||||
457
domain/modelling/products.py
Normal file
457
domain/modelling/products.py
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
"""Products — the rich catalogue collection over `Product` (ADR-0025).
|
||||||
|
|
||||||
|
`ProductRepository` is the IO port that fetches catalogue rows; `Products` is
|
||||||
|
the in-memory domain collection carrying the cost-composition behaviour a single
|
||||||
|
`Product` row cannot. A simple measure prices as one row (unit cost x area); a
|
||||||
|
composite measure — the ASHP bundle — prices by selecting and summing many
|
||||||
|
priced line items (the Southern Housing "HEAT PUMPS" rate sheet, ECOHT01-68).
|
||||||
|
|
||||||
|
This module owns the **catalogue math** only: given a typed `AshpCostInputs` it
|
||||||
|
filters the relevant rate lines and sums them into a `Cost`. It is deliberately
|
||||||
|
free of `EpcPropertyData` and the `Sap10Calculator` — the dwelling
|
||||||
|
interpretation that produces the inputs (sizing, proxies, reuse detection)
|
||||||
|
lives in the modelling layer (ADR-0025).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from domain.modelling.contingencies import contingency_rate
|
||||||
|
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`.
|
||||||
|
_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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AshpRates:
|
||||||
|
"""The Southern Housing Group ASHP rate table (ADR-0025) — fully-loaded
|
||||||
|
supply+install rates, one row per priced line item. Data, not code: the
|
||||||
|
committed default loads from `ashp_rates.json`, and a caller can inject a
|
||||||
|
variant (e.g. to recalibrate `reuse_distribution_fraction`)."""
|
||||||
|
|
||||||
|
decommission_electric_storage_small: float
|
||||||
|
decommission_electric_storage_large: float
|
||||||
|
decommission_gas: float
|
||||||
|
decommission_oil: float
|
||||||
|
decommission_lpg: float
|
||||||
|
# Heat-pump install bands (max_kw, price), ascending; design heat loss rounds
|
||||||
|
# up to the first covering band, else `heat_pump_top_price`.
|
||||||
|
heat_pump_bands: tuple[tuple[float, float], ...]
|
||||||
|
heat_pump_top_price: float
|
||||||
|
# Fixed unvented cylinder — one per install (size spread on the sheet is £188).
|
||||||
|
cylinder: float
|
||||||
|
# Full new wet distribution, by radiator count.
|
||||||
|
distribution_by_radiators: dict[int, float]
|
||||||
|
# Power-flush + inhibitor when reusing an existing wet system.
|
||||||
|
distribution_flush: float
|
||||||
|
# Fraction of a full distribution charged on reuse — a stand-in for partial
|
||||||
|
# radiator upsizing at low ASHP flow temps; the headline uncertainty.
|
||||||
|
reuse_distribution_fraction: float
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default(cls) -> "AshpRates":
|
||||||
|
"""Load the committed Southern Housing rate sheet."""
|
||||||
|
return cls.from_json(_ASHP_RATES_PATH)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, path: Path) -> "AshpRates":
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
raw: dict[str, Any] = json.load(handle)
|
||||||
|
decommission: dict[str, Any] = raw["decommission"]
|
||||||
|
return cls(
|
||||||
|
decommission_electric_storage_small=float(
|
||||||
|
decommission["electric_storage_small"]
|
||||||
|
),
|
||||||
|
decommission_electric_storage_large=float(
|
||||||
|
decommission["electric_storage_large"]
|
||||||
|
),
|
||||||
|
decommission_gas=float(decommission["gas"]),
|
||||||
|
decommission_oil=float(decommission["oil"]),
|
||||||
|
decommission_lpg=float(decommission["lpg"]),
|
||||||
|
heat_pump_bands=tuple(
|
||||||
|
(float(kw), float(price)) for kw, price in raw["heat_pump_bands"]
|
||||||
|
),
|
||||||
|
heat_pump_top_price=float(raw["heat_pump_top_price"]),
|
||||||
|
cylinder=float(raw["cylinder"]),
|
||||||
|
distribution_by_radiators={
|
||||||
|
int(rads): float(price)
|
||||||
|
for rads, price in raw["distribution_by_radiators"].items()
|
||||||
|
},
|
||||||
|
distribution_flush=float(raw["distribution_flush"]),
|
||||||
|
reuse_distribution_fraction=float(raw["reuse_distribution_fraction"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolarRates:
|
||||||
|
"""The Southern Housing "SOLAR PV & BATTERY" EA rate table (ADR-0026) —
|
||||||
|
fully-loaded supply+install rates. Data, not code: the committed default
|
||||||
|
loads from `solar_rates.json`, and a caller can inject a variant (e.g. to
|
||||||
|
replace the flagged battery estimate with a DB rate)."""
|
||||||
|
|
||||||
|
# pv_system install price by kWp band (ECOPV06-13, slate roof), ascending.
|
||||||
|
pv_system_by_kwp: tuple[tuple[float, float], ...]
|
||||||
|
scaffolding_first_elevation: float
|
||||||
|
scaffolding_additional_elevation: float
|
||||||
|
enabling_eicr: float
|
||||||
|
enabling_dno: float
|
||||||
|
enabling_consumer_unit: float
|
||||||
|
# Myenergi Eddi microgeneration diverter (ECOPV30).
|
||||||
|
diverter: float
|
||||||
|
# Battery supply+install — NOT on the rate sheet; a flagged estimate
|
||||||
|
# (`battery_estimate`) confirmed with the user to stand in until a DB rate.
|
||||||
|
battery: float
|
||||||
|
battery_estimate: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default(cls) -> "SolarRates":
|
||||||
|
"""Load the committed Southern Housing solar rate sheet."""
|
||||||
|
return cls.from_json(_SOLAR_RATES_PATH)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, path: Path) -> "SolarRates":
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
raw: dict[str, Any] = json.load(handle)
|
||||||
|
bands: dict[str, Any] = raw["pv_system_by_kwp"]
|
||||||
|
return cls(
|
||||||
|
pv_system_by_kwp=tuple(
|
||||||
|
sorted(
|
||||||
|
(float(kwp), float(price)) for kwp, price in bands.items()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
scaffolding_first_elevation=float(raw["scaffolding_first_elevation"]),
|
||||||
|
scaffolding_additional_elevation=float(
|
||||||
|
raw["scaffolding_additional_elevation"]
|
||||||
|
),
|
||||||
|
enabling_eicr=float(raw["enabling_eicr"]),
|
||||||
|
enabling_dno=float(raw["enabling_dno"]),
|
||||||
|
enabling_consumer_unit=float(raw["enabling_consumer_unit"]),
|
||||||
|
diverter=float(raw["diverter"]),
|
||||||
|
battery=float(raw["battery"]),
|
||||||
|
battery_estimate=bool(raw["battery_estimate"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolarCostInputs:
|
||||||
|
"""The dwelling facts the Solar PV catalogue math needs — produced by the
|
||||||
|
modelling layer's interpretation of a chosen array config (ADR-0026)."""
|
||||||
|
|
||||||
|
peak_power_kwp: float
|
||||||
|
has_cylinder: bool
|
||||||
|
has_battery: bool
|
||||||
|
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
|
||||||
|
modelling layer maps fuel / SAP code to one of these."""
|
||||||
|
|
||||||
|
ELECTRIC_STORAGE = "electric_storage"
|
||||||
|
GAS = "gas"
|
||||||
|
OIL = "oil"
|
||||||
|
LPG = "lpg"
|
||||||
|
ELECTRIC_OTHER = "electric_other"
|
||||||
|
NONE = "none"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AshpCostInputs:
|
||||||
|
"""The dwelling facts the ASHP catalogue math needs — produced by the
|
||||||
|
modelling layer's interpretation, never read off the EPC here (ADR-0025)."""
|
||||||
|
|
||||||
|
existing_system: AshpExistingSystem
|
||||||
|
is_small_property: bool
|
||||||
|
design_heat_loss_kw: float
|
||||||
|
radiator_count: int
|
||||||
|
has_reusable_wet_system: bool
|
||||||
|
|
||||||
|
|
||||||
|
class Products:
|
||||||
|
"""The catalogue collection. Owns cost composition for measures whose price
|
||||||
|
is not a single catalogue scalar (the ASHP bundle — ADR-0025). The ASHP rate
|
||||||
|
table is data, injected as `AshpRates` (default: the committed rate sheet)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
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
|
||||||
|
with the separate ASHP contingency rate."""
|
||||||
|
total: float = (
|
||||||
|
self._decommission(inputs)
|
||||||
|
+ self._heat_pump(inputs.design_heat_loss_kw)
|
||||||
|
+ self._rates.cylinder
|
||||||
|
+ self._distribution(inputs)
|
||||||
|
)
|
||||||
|
return Cost(
|
||||||
|
total=total, contingency_rate=contingency_rate(_ASHP_MEASURE_TYPE)
|
||||||
|
)
|
||||||
|
|
||||||
|
def solar_bundle_cost(self, inputs: SolarCostInputs) -> Cost:
|
||||||
|
"""Compose the fully-loaded Solar PV bundle total for a dwelling and
|
||||||
|
pair it with the separate 15% solar contingency (ADR-0026)."""
|
||||||
|
rates = self._solar_rates
|
||||||
|
total: float = (
|
||||||
|
self._pv_system(inputs.peak_power_kwp)
|
||||||
|
+ self._scaffolding(inputs.elevations)
|
||||||
|
+ rates.enabling_eicr
|
||||||
|
+ rates.enabling_dno
|
||||||
|
+ rates.enabling_consumer_unit
|
||||||
|
+ (rates.diverter if inputs.has_cylinder else 0.0)
|
||||||
|
+ (rates.battery if inputs.has_battery else 0.0)
|
||||||
|
)
|
||||||
|
return Cost(
|
||||||
|
total=total, contingency_rate=contingency_rate(_SOLAR_MEASURE_TYPE)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pv_system(self, peak_power_kwp: float) -> float:
|
||||||
|
"""Price the pv_system install at the kWp band nearest the array size,
|
||||||
|
flooring below the smallest band and capping at the largest."""
|
||||||
|
bands = self._solar_rates.pv_system_by_kwp
|
||||||
|
nearest_kwp, _ = min(bands, key=lambda band: abs(band[0] - peak_power_kwp))
|
||||||
|
return dict(bands)[nearest_kwp]
|
||||||
|
|
||||||
|
def _scaffolding(self, elevations: int) -> float:
|
||||||
|
"""£900 for the first elevation + £450 for each additional."""
|
||||||
|
rates = self._solar_rates
|
||||||
|
additional: int = max(0, elevations - 1)
|
||||||
|
return (
|
||||||
|
rates.scaffolding_first_elevation
|
||||||
|
+ additional * rates.scaffolding_additional_elevation
|
||||||
|
)
|
||||||
|
|
||||||
|
def _heat_pump(self, design_heat_loss_kw: float) -> float:
|
||||||
|
"""Price the install at the smallest band that covers the design heat
|
||||||
|
loss (round up); above the largest band, the top rate applies."""
|
||||||
|
for max_kw, price in self._rates.heat_pump_bands:
|
||||||
|
if design_heat_loss_kw <= max_kw:
|
||||||
|
return price
|
||||||
|
return self._rates.heat_pump_top_price
|
||||||
|
|
||||||
|
def _decommission(self, inputs: AshpCostInputs) -> float:
|
||||||
|
rates = self._rates
|
||||||
|
electric_storage: float = (
|
||||||
|
rates.decommission_electric_storage_small
|
||||||
|
if inputs.is_small_property
|
||||||
|
else rates.decommission_electric_storage_large
|
||||||
|
)
|
||||||
|
if inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE:
|
||||||
|
return electric_storage
|
||||||
|
if inputs.existing_system is AshpExistingSystem.GAS:
|
||||||
|
return rates.decommission_gas
|
||||||
|
if inputs.existing_system is AshpExistingSystem.OIL:
|
||||||
|
return rates.decommission_oil
|
||||||
|
if inputs.existing_system is AshpExistingSystem.LPG:
|
||||||
|
return rates.decommission_lpg
|
||||||
|
# Systems off the rate sheet: ASHP is still offered (ADR-0025), so price
|
||||||
|
# a fallback rather than raise. Nothing to remove for no system; electric
|
||||||
|
# room/panel heaters are comparable work to storage heaters; anything
|
||||||
|
# else takes the gas wet-system line as a representative default.
|
||||||
|
if inputs.existing_system is AshpExistingSystem.NONE:
|
||||||
|
return 0.0
|
||||||
|
if inputs.existing_system is AshpExistingSystem.ELECTRIC_OTHER:
|
||||||
|
return electric_storage
|
||||||
|
return rates.decommission_gas
|
||||||
|
|
||||||
|
def _distribution(self, inputs: AshpCostInputs) -> float:
|
||||||
|
radiators: int = max(_MIN_RADIATORS, min(_MAX_RADIATORS, inputs.radiator_count))
|
||||||
|
full: float = self._rates.distribution_by_radiators[radiators]
|
||||||
|
# An existing wet system is reused, not rebuilt: a flush plus a fraction
|
||||||
|
# of the full distribution to cover partial radiator upsizing.
|
||||||
|
if inputs.has_reusable_wet_system:
|
||||||
|
return (
|
||||||
|
self._rates.distribution_flush
|
||||||
|
+ self._rates.reuse_distribution_fraction * full
|
||||||
|
)
|
||||||
|
return full
|
||||||
49
domain/modelling/recommendation.py
Normal file
49
domain/modelling/recommendation.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Recommendation and Measure Option — the Modelling stage's proposal types.
|
||||||
|
|
||||||
|
A Recommendation is a labelled group of mutually-exclusive Measure Options for
|
||||||
|
one target surface; the Optimiser selects at most one. The target itself is
|
||||||
|
encoded entirely in each Option's Simulation Overlay (which addresses building
|
||||||
|
parts, windows, or systems), so this type stays stable as new surfaces land.
|
||||||
|
Impact is never stored here — it is cascade-conditional (ADR-0016). See
|
||||||
|
CONTEXT.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.simulation import EpcSimulation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Cost:
|
||||||
|
"""A Measure Option's cost: a single fully-loaded total (labour + VAT +
|
||||||
|
preliminaries + margin rolled in) plus a separately-carried per-Measure-Type
|
||||||
|
contingency rate."""
|
||||||
|
|
||||||
|
total: float
|
||||||
|
contingency_rate: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasureOption:
|
||||||
|
"""One mutually-exclusive way to treat a Recommendation's surface."""
|
||||||
|
|
||||||
|
measure_type: MeasureType
|
||||||
|
description: str
|
||||||
|
overlay: EpcSimulation
|
||||||
|
cost: Optional[Cost] = None
|
||||||
|
# The catalogue id of the Product this Option installs (Product.id), carried
|
||||||
|
# through to the persisted Plan Measure's ``material_id``. None when priced
|
||||||
|
# from a catalogue with no ids.
|
||||||
|
material_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Recommendation:
|
||||||
|
"""A target surface and the mutually-exclusive Measure Options that treat
|
||||||
|
it. `surface` is a human label for display/grouping; the actual target is
|
||||||
|
in each Option's overlay."""
|
||||||
|
|
||||||
|
surface: str
|
||||||
|
options: tuple[MeasureOption, ...]
|
||||||
27
domain/modelling/scenario.py
Normal file
27
domain/modelling/scenario.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Scenario — the named retrofit brief the Modelling stage scores against.
|
||||||
|
|
||||||
|
Built by a user in the scenario-builder UI and persisted before any modelling
|
||||||
|
fires; the pipeline is handed only its id and reads it back via a
|
||||||
|
`ScenarioRepository`. This is the thin slice the Modelling stage uses today:
|
||||||
|
the goal + budget that the Optimiser will consume (#1160) and `is_default`
|
||||||
|
(which drives `plan.is_default`). The legacy file-path / portfolio-aggregate
|
||||||
|
columns are not modelled. Carries no phases — multi-phase is deferred
|
||||||
|
(ADR-0005). See CONTEXT.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Scenario:
|
||||||
|
"""A retrofit brief: its goal, optional budget, and whether it is the
|
||||||
|
Property's default Scenario. `goal` / `goal_value` are the lodged target
|
||||||
|
(e.g. "INCREASING_EPC" → band "C"); carried for the Optimiser, not yet
|
||||||
|
enforced."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
goal: str
|
||||||
|
goal_value: str
|
||||||
|
budget: Optional[float]
|
||||||
|
is_default: bool
|
||||||
0
domain/modelling/scoring/__init__.py
Normal file
0
domain/modelling/scoring/__init__.py
Normal file
212
domain/modelling/scoring/overlay_applicator.py
Normal file
212
domain/modelling/scoring/overlay_applicator.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""The Overlay Applicator — folds an ordered set of Simulation Overlays onto
|
||||||
|
a baseline EpcPropertyData and returns a new one for the calculator.
|
||||||
|
|
||||||
|
Sequential fold: overlays are applied in order and a later overlay wins on a
|
||||||
|
field it shares with an earlier one. The baseline is never mutated; the
|
||||||
|
returned EpcPropertyData is throwaway (handed to the calculator for scoring,
|
||||||
|
then discarded). See ADR-0016.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import fields
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
EpcPropertyData,
|
||||||
|
SapVentilation,
|
||||||
|
SapWindow,
|
||||||
|
WindowTransmissionDetails,
|
||||||
|
)
|
||||||
|
from domain.modelling.simulation import (
|
||||||
|
EpcSimulation,
|
||||||
|
HeatingOverlay,
|
||||||
|
LightingOverlay,
|
||||||
|
SecondaryHeatingOverlay,
|
||||||
|
SolarOverlay,
|
||||||
|
VentilationOverlay,
|
||||||
|
WindowOverlay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_simulations(
|
||||||
|
baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||||||
|
) -> EpcPropertyData:
|
||||||
|
"""Return a copy of ``baseline`` with every Simulation Overlay's non-``None``
|
||||||
|
fields written onto the building part it targets, applied in order. A
|
||||||
|
whole-dwelling ``ventilation`` overlay folds onto ``sap_ventilation``
|
||||||
|
(creating one if the baseline lodged none)."""
|
||||||
|
result: EpcPropertyData = copy.deepcopy(baseline)
|
||||||
|
parts_by_id = {part.identifier: part for part in result.sap_building_parts}
|
||||||
|
|
||||||
|
for simulation in simulations:
|
||||||
|
for identifier, overlay in simulation.building_parts.items():
|
||||||
|
part = parts_by_id[identifier]
|
||||||
|
for overlay_field in fields(overlay):
|
||||||
|
value = getattr(overlay, overlay_field.name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(part, overlay_field.name, value)
|
||||||
|
for index, window_overlay in simulation.windows.items():
|
||||||
|
_fold_window(result.sap_windows[index], window_overlay)
|
||||||
|
if simulation.ventilation is not None:
|
||||||
|
result.sap_ventilation = _fold_ventilation(
|
||||||
|
result.sap_ventilation, simulation.ventilation
|
||||||
|
)
|
||||||
|
if simulation.lighting is not None:
|
||||||
|
_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).
|
||||||
|
_MAIN_HEATING_FIELDS: tuple[str, ...] = (
|
||||||
|
"main_fuel_type",
|
||||||
|
"heat_emitter_type",
|
||||||
|
"main_heating_control",
|
||||||
|
"sap_main_heating_code",
|
||||||
|
"main_heating_index_number",
|
||||||
|
"main_heating_category",
|
||||||
|
"fan_flue_present",
|
||||||
|
"boiler_flue_type",
|
||||||
|
)
|
||||||
|
_SAP_HEATING_FIELDS: tuple[str, ...] = (
|
||||||
|
"water_heating_code",
|
||||||
|
"water_heating_fuel",
|
||||||
|
"cylinder_size",
|
||||||
|
"cylinder_insulation_type",
|
||||||
|
"cylinder_insulation_thickness_mm",
|
||||||
|
"cylinder_thermostat",
|
||||||
|
)
|
||||||
|
_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas")
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None:
|
||||||
|
"""Write a `HeatingOverlay`'s non-``None`` fields onto the (copied) dwelling,
|
||||||
|
routing each to its home: the primary ``main_heating_details[0]``, the
|
||||||
|
``sap_heating`` hot-water fields, the top-level ``has_hot_water_cylinder``,
|
||||||
|
and the ``sap_energy_source`` meter/mains-gas fields. The bundle targets the
|
||||||
|
primary system only (index 0)."""
|
||||||
|
main = epc.sap_heating.main_heating_details[0]
|
||||||
|
for field_name in _MAIN_HEATING_FIELDS:
|
||||||
|
value = getattr(overlay, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(main, field_name, value)
|
||||||
|
# `main_heating_index_number` (PCDB-resolved, e.g. a heat pump) and
|
||||||
|
# `sap_main_heating_code` (Table 4a-resolved, e.g. storage heaters) are
|
||||||
|
# mutually-exclusive efficiency anchors: a whole-system replacement to one
|
||||||
|
# must clear the other, else a stale code from the old system wins the
|
||||||
|
# calculator's dispatch (e.g. a gas-boiler code 104 left beside a heat-pump
|
||||||
|
# index makes hot water use boiler efficiency, not the HP SCOP).
|
||||||
|
if overlay.main_heating_index_number is not None:
|
||||||
|
main.sap_main_heating_code = None
|
||||||
|
elif overlay.sap_main_heating_code is not None:
|
||||||
|
main.main_heating_index_number = None
|
||||||
|
for field_name in _SAP_HEATING_FIELDS:
|
||||||
|
value = getattr(overlay, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(epc.sap_heating, field_name, value)
|
||||||
|
if overlay.has_hot_water_cylinder is not None:
|
||||||
|
epc.has_hot_water_cylinder = overlay.has_hot_water_cylinder
|
||||||
|
for field_name in _ENERGY_SOURCE_FIELDS:
|
||||||
|
value = getattr(overlay, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(epc.sap_energy_source, field_name, value)
|
||||||
|
|
||||||
|
|
||||||
|
# `SolarOverlay` fields all live on `sap_energy_source` (the home of the SAP
|
||||||
|
# Appendix M PV inputs) — the sixth overlay surface (ADR-0026).
|
||||||
|
_ENERGY_SOURCE_SOLAR_FIELDS: tuple[str, ...] = (
|
||||||
|
"photovoltaic_arrays",
|
||||||
|
"pv_diverter_present",
|
||||||
|
"pv_connection",
|
||||||
|
"is_dwelling_export_capable",
|
||||||
|
"pv_batteries",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_solar(epc: EpcPropertyData, overlay: SolarOverlay) -> None:
|
||||||
|
"""Write a `SolarOverlay`'s non-``None`` fields onto the (copied) dwelling's
|
||||||
|
``sap_energy_source`` — the PV arrays, diverter, connection, export
|
||||||
|
capability and battery a Solar PV Measure Option installs (ADR-0026). The
|
||||||
|
arrays are an absolute target: they replace the dwelling's existing
|
||||||
|
``photovoltaic_arrays`` (empty for a non-PV dwelling)."""
|
||||||
|
for field_name in _ENERGY_SOURCE_SOLAR_FIELDS:
|
||||||
|
value = getattr(overlay, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(epc.sap_energy_source, field_name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None:
|
||||||
|
"""Write a `LightingOverlay`'s non-``None`` bulb counts onto the (copied)
|
||||||
|
dwelling's top-level fields by name — the four counts live directly on
|
||||||
|
`EpcPropertyData`, so the fold writes onto it, not a nested object."""
|
||||||
|
for overlay_field in fields(overlay):
|
||||||
|
value = getattr(overlay, overlay_field.name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(epc, overlay_field.name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_window(window: SapWindow, overlay: WindowOverlay) -> None:
|
||||||
|
"""Write a `WindowOverlay`'s non-``None`` fields onto a (copied) window:
|
||||||
|
``glazing_type`` flat on the window, ``u_value`` / ``solar_transmittance``
|
||||||
|
into its `WindowTransmissionDetails` (where the cascade reads them), starting
|
||||||
|
a fresh one when the window lodged none."""
|
||||||
|
if overlay.glazing_type is not None:
|
||||||
|
window.glazing_type = overlay.glazing_type
|
||||||
|
if overlay.u_value is None and overlay.solar_transmittance is None:
|
||||||
|
return
|
||||||
|
details: Optional[WindowTransmissionDetails] = window.window_transmission_details
|
||||||
|
if details is None:
|
||||||
|
# data_source 1 = manufacturer-lodged (the case the cascade's per-window
|
||||||
|
# U path keys on); both values must be present to start fresh.
|
||||||
|
window.window_transmission_details = WindowTransmissionDetails(
|
||||||
|
u_value=overlay.u_value if overlay.u_value is not None else 0.0,
|
||||||
|
data_source=1,
|
||||||
|
solar_transmittance=(
|
||||||
|
overlay.solar_transmittance
|
||||||
|
if overlay.solar_transmittance is not None
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if overlay.u_value is not None:
|
||||||
|
details.u_value = overlay.u_value
|
||||||
|
if overlay.solar_transmittance is not None:
|
||||||
|
details.solar_transmittance = overlay.solar_transmittance
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_ventilation(
|
||||||
|
baseline: Optional[SapVentilation], overlay: VentilationOverlay
|
||||||
|
) -> SapVentilation:
|
||||||
|
"""Write the overlay's non-``None`` fields onto a (copied) ``SapVentilation``,
|
||||||
|
starting a fresh one when the baseline lodged none."""
|
||||||
|
folded: SapVentilation = (
|
||||||
|
copy.deepcopy(baseline) if baseline is not None else SapVentilation()
|
||||||
|
)
|
||||||
|
for overlay_field in fields(overlay):
|
||||||
|
value = getattr(overlay, overlay_field.name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(folded, overlay_field.name, value)
|
||||||
|
return folded
|
||||||
55
domain/modelling/scoring/package_scorer.py
Normal file
55
domain/modelling/scoring/package_scorer.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""The Package Scorer — the reusable scoring primitive (ADR-0016).
|
||||||
|
|
||||||
|
Composes an ordered set of Simulation Overlays onto a baseline EpcPropertyData
|
||||||
|
(via the Overlay Applicator) and scores the throwaway result on a deterministic
|
||||||
|
SAP calculator, returning the headline metrics. The same primitive powers the
|
||||||
|
optimiser's whole-package re-score and any future live re-score of a
|
||||||
|
user-assembled plan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.modelling.scoring.overlay_applicator import apply_simulations
|
||||||
|
from domain.modelling.simulation import EpcSimulation
|
||||||
|
from domain.sap10_calculator.calculator import SapCalculator, SapResult
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Score:
|
||||||
|
"""The headline metrics of a scored package. `sap_continuous` is the
|
||||||
|
un-rounded SAP rating (used for deltas); carbon and primary energy are the
|
||||||
|
annual totals.
|
||||||
|
|
||||||
|
`sap_result` is the calculator output the headline figures were taken from,
|
||||||
|
carried so Bill Derivation can price the scored end-state without a second
|
||||||
|
`calculate` (ADR-0014 amendment). The optimiser never reads it — it works
|
||||||
|
off `sap_continuous` only — so it stays domain-agnostic and a stub scorer
|
||||||
|
may leave it `None`."""
|
||||||
|
|
||||||
|
sap_continuous: float
|
||||||
|
co2_kg_per_yr: float
|
||||||
|
primary_energy_kwh_per_yr: float
|
||||||
|
sap_result: Optional[SapResult] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PackageScorer:
|
||||||
|
"""Scores a package of Simulation Overlays against a baseline EpcPropertyData
|
||||||
|
on an injected SAP calculator (depends on the `SapCalculator` abstraction,
|
||||||
|
not a concrete engine)."""
|
||||||
|
|
||||||
|
def __init__(self, calculator: SapCalculator) -> None:
|
||||||
|
self._calculator = calculator
|
||||||
|
|
||||||
|
def score(
|
||||||
|
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
|
||||||
|
) -> Score:
|
||||||
|
simulated: EpcPropertyData = apply_simulations(baseline, simulations)
|
||||||
|
result: SapResult = self._calculator.calculate(simulated)
|
||||||
|
return Score(
|
||||||
|
sap_continuous=result.sap_score_continuous,
|
||||||
|
co2_kg_per_yr=result.co2_kg_per_yr,
|
||||||
|
primary_energy_kwh_per_yr=result.primary_energy_kwh_per_yr,
|
||||||
|
sap_result=result,
|
||||||
|
)
|
||||||
115
domain/modelling/scoring/scoring.py
Normal file
115
domain/modelling/scoring/scoring.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Per-measure scoring — the telescoping marginal cascade (ADR-0016).
|
||||||
|
|
||||||
|
`marginal_impacts` applies overlays one at a time in the given order and
|
||||||
|
reports each measure's marginal contribution. It serves two of the three
|
||||||
|
scoring roles:
|
||||||
|
- role 1 (per-Option optimiser signal): call per Option as a 1-element
|
||||||
|
sequence -> its independent-vs-baseline impact;
|
||||||
|
- role 3 (final-package display attribution): call once with the selected
|
||||||
|
overlays in best-practice order -> per-measure impacts that telescope
|
||||||
|
exactly to the whole-package total.
|
||||||
|
|
||||||
|
Per-Option (role 1) figures are an approximate signal and must not be surfaced
|
||||||
|
as a measure's true impact — only the final-package cascade (role 3) is
|
||||||
|
truthful. The whole-package re-score (role 2) is `PackageScorer.score` directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.modelling.scoring.package_scorer import PackageScorer, Score
|
||||||
|
from domain.modelling.recommendation import MeasureOption
|
||||||
|
from domain.modelling.simulation import EpcSimulation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasureImpact:
|
||||||
|
"""One measure's marginal contribution, signed so positive is always an
|
||||||
|
improvement: `sap_points` is the SAP gain; the savings are reductions
|
||||||
|
(baseline-at-this-step minus the new value)."""
|
||||||
|
|
||||||
|
sap_points: float
|
||||||
|
co2_savings_kg_per_yr: float
|
||||||
|
energy_savings_kwh_per_yr: float
|
||||||
|
|
||||||
|
|
||||||
|
def cascade_scores(
|
||||||
|
scorer: PackageScorer,
|
||||||
|
baseline: EpcPropertyData,
|
||||||
|
overlays: Sequence[EpcSimulation],
|
||||||
|
) -> list[Score]:
|
||||||
|
"""Score the cumulative prefixes of `overlays` in order: index 0 is the
|
||||||
|
baseline (empty prefix), index i the state after the first i overlays. The
|
||||||
|
list has `len(overlays) + 1` entries — one calculator run each.
|
||||||
|
|
||||||
|
Each Score carries its `SapResult`, so the same cascade powers both the
|
||||||
|
role-3 marginal attribution (`marginals_from_scores`) and the telescoping
|
||||||
|
per-measure bill cascade — neither needs to re-score (ADR-0014 / ADR-0016)."""
|
||||||
|
return [
|
||||||
|
scorer.score(baseline, list(overlays[:prefix_length]))
|
||||||
|
for prefix_length in range(len(overlays) + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def marginals_from_scores(scores: Sequence[Score]) -> list[MeasureImpact]:
|
||||||
|
"""Each measure's marginal impact from a precomputed cumulative-prefix
|
||||||
|
cascade (`scores[0]` is the baseline). Signed so positive is an improvement;
|
||||||
|
the marginals telescope to `scores[-1]` vs `scores[0]`."""
|
||||||
|
impacts: list[MeasureImpact] = []
|
||||||
|
for index in range(1, len(scores)):
|
||||||
|
previous: Score = scores[index - 1]
|
||||||
|
current: Score = scores[index]
|
||||||
|
impacts.append(
|
||||||
|
MeasureImpact(
|
||||||
|
sap_points=current.sap_continuous - previous.sap_continuous,
|
||||||
|
co2_savings_kg_per_yr=previous.co2_kg_per_yr - current.co2_kg_per_yr,
|
||||||
|
energy_savings_kwh_per_yr=(
|
||||||
|
previous.primary_energy_kwh_per_yr
|
||||||
|
- current.primary_energy_kwh_per_yr
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return impacts
|
||||||
|
|
||||||
|
|
||||||
|
def marginal_impacts(
|
||||||
|
scorer: PackageScorer,
|
||||||
|
baseline: EpcPropertyData,
|
||||||
|
overlays: Sequence[EpcSimulation],
|
||||||
|
) -> list[MeasureImpact]:
|
||||||
|
"""Apply overlays cumulatively in order; return each one's marginal impact
|
||||||
|
over the running state. The marginals telescope to the whole-package total."""
|
||||||
|
return marginals_from_scores(cascade_scores(scorer, baseline, overlays))
|
||||||
|
|
||||||
|
|
||||||
|
def independent_option_impacts(
|
||||||
|
scorer: PackageScorer,
|
||||||
|
baseline: EpcPropertyData,
|
||||||
|
options: Sequence[MeasureOption],
|
||||||
|
) -> list[MeasureImpact]:
|
||||||
|
"""Score each Option's overlay independently against the baseline (role 1 —
|
||||||
|
the optimiser's approximate input signal). Each *distinct* Simulation Overlay
|
||||||
|
is scored once (Options sharing an overlay reuse the result), so the baseline
|
||||||
|
is scored once plus one score per distinct overlay. Results follow the input
|
||||||
|
order. These figures are an approximate signal — never surface them as a
|
||||||
|
measure's true impact."""
|
||||||
|
base: Score = scorer.score(baseline, [])
|
||||||
|
scored: list[tuple[EpcSimulation, MeasureImpact]] = []
|
||||||
|
impacts: list[MeasureImpact] = []
|
||||||
|
for option in options:
|
||||||
|
cached = next(
|
||||||
|
(impact for overlay, impact in scored if overlay == option.overlay), None
|
||||||
|
)
|
||||||
|
if cached is None:
|
||||||
|
current: Score = scorer.score(baseline, [option.overlay])
|
||||||
|
cached = MeasureImpact(
|
||||||
|
sap_points=current.sap_continuous - base.sap_continuous,
|
||||||
|
co2_savings_kg_per_yr=base.co2_kg_per_yr - current.co2_kg_per_yr,
|
||||||
|
energy_savings_kwh_per_yr=(
|
||||||
|
base.primary_energy_kwh_per_yr - current.primary_energy_kwh_per_yr
|
||||||
|
),
|
||||||
|
)
|
||||||
|
scored.append((option.overlay, cached))
|
||||||
|
impacts.append(cached)
|
||||||
|
return impacts
|
||||||
216
domain/modelling/simulation.py
Normal file
216
domain/modelling/simulation.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
"""The Simulation Overlay (`EpcSimulation`) — the change a single Measure
|
||||||
|
Option makes to a Property's EpcPropertyData.
|
||||||
|
|
||||||
|
An all-optional partial mirror of EpcPropertyData / SapBuildingPart, covering
|
||||||
|
the retrofit-relevant surface only (wall fields first). It is *not* an
|
||||||
|
EpcPropertyData — composition, not inheritance — and carries no scores.
|
||||||
|
Building parts are targeted by `BuildingPartIdentifier` so a measure addresses
|
||||||
|
the exact `SapBuildingPart` (the main wall vs an extension). See CONTEXT.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Mapping, Optional, Union
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
PhotovoltaicArray,
|
||||||
|
PvBatteries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuildingPartOverlay:
|
||||||
|
"""All-optional partial of `SapBuildingPart` (wall surface first).
|
||||||
|
|
||||||
|
A `None` field means "leave the baseline value unchanged".
|
||||||
|
"""
|
||||||
|
|
||||||
|
wall_insulation_type: Optional[int] = None
|
||||||
|
# Added solid-wall insulation depth (mm) — drives the calculator's Table 6
|
||||||
|
# bucket / §5.8 documentary U-value for EWI (`wall_insulation_type=1`) and
|
||||||
|
# IWI (`wall_insulation_type=3`); λ defaults to 0.04 W/m·K in the calculator.
|
||||||
|
wall_insulation_thickness: Optional[int] = None
|
||||||
|
roof_insulation_thickness: Optional[int] = None
|
||||||
|
floor_insulation_thickness: Optional[int] = None
|
||||||
|
floor_insulation_type_str: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VentilationOverlay:
|
||||||
|
"""All-optional partial of `SapVentilation` — the whole-dwelling ventilation
|
||||||
|
change a Measure Option makes (e.g. retrofit MEV). Unlike a
|
||||||
|
`BuildingPartOverlay` this targets no building part; it folds onto the
|
||||||
|
dwelling's single `sap_ventilation`.
|
||||||
|
|
||||||
|
`mechanical_ventilation_kind` names the SAP10.2 §2 mechanical-ventilation
|
||||||
|
kind (the `MechanicalVentilationKind` enum name, e.g.
|
||||||
|
``"EXTRACT_OR_PIV_OUTSIDE"`` for decentralised MEV). A `None` field means
|
||||||
|
"leave the baseline value unchanged".
|
||||||
|
"""
|
||||||
|
|
||||||
|
mechanical_ventilation_kind: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WindowOverlay:
|
||||||
|
"""All-optional partial of one `SapWindow` — the change a glazing Measure
|
||||||
|
makes to a single window (ADR-0022).
|
||||||
|
|
||||||
|
`glazing_type` is the SAP10.2 Table U2 code (drives only the §5 daylight
|
||||||
|
factor when a per-window U is lodged). `u_value` and `solar_transmittance`
|
||||||
|
are written into the window's `WindowTransmissionDetails` — where the
|
||||||
|
calculator reads heat loss and solar gain from — because our calculator
|
||||||
|
consumes the lodged values directly rather than deriving them from
|
||||||
|
`glazing_type`. A `None` field means "leave the baseline value unchanged".
|
||||||
|
"""
|
||||||
|
|
||||||
|
glazing_type: Optional[int] = None
|
||||||
|
u_value: Optional[float] = None
|
||||||
|
solar_transmittance: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LightingOverlay:
|
||||||
|
"""All-optional partial of the dwelling's fixed-lighting bulb counts — the
|
||||||
|
whole-dwelling lighting change a Measure Option makes (e.g. an all-LED
|
||||||
|
upgrade — ADR-0023). Unlike a `BuildingPartOverlay` or `WindowOverlay` this
|
||||||
|
targets no building part or window; its fields are the four **top-level**
|
||||||
|
`EpcPropertyData` bulb counts, folded directly by name.
|
||||||
|
|
||||||
|
The counts are absolute target states, not deltas (an all-LED upgrade sets
|
||||||
|
``led = total``, the rest 0). A `None` field means "leave the baseline value
|
||||||
|
unchanged".
|
||||||
|
"""
|
||||||
|
|
||||||
|
led_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
cfl_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
incandescent_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HeatingOverlay:
|
||||||
|
"""All-optional partial of a dwelling's whole heating + hot-water system —
|
||||||
|
the change a heating Measure Option makes (e.g. a high-heat-retention
|
||||||
|
storage or ASHP bundle — ADR-0024). The deepest overlay surface: a heating
|
||||||
|
bundle is a whole-system replacement, so its fields target **five**
|
||||||
|
locations, and `_fold_heating` routes each to its home:
|
||||||
|
|
||||||
|
- ``main_heating_details[0]`` (the primary system) — fuel, heat emitter,
|
||||||
|
control, and the efficiency anchor (`sap_main_heating_code` for table-
|
||||||
|
resolved systems like storage heaters, or `main_heating_index_number` +
|
||||||
|
`main_heating_category` for PCDB-resolved systems like heat pumps);
|
||||||
|
- ``sap_heating`` (top-level) — the implied hot-water arrangement
|
||||||
|
(`water_heating_*`, cylinder size + insulation);
|
||||||
|
- the top-level `EpcPropertyData` — `has_hot_water_cylinder`;
|
||||||
|
- ``sap_energy_source`` — `meter_type` (an off-peak tariff for storage) and
|
||||||
|
`mains_gas` (cleared when the dwelling goes all-electric).
|
||||||
|
|
||||||
|
The values are **absolute target states**, not deltas (the bundle replaces
|
||||||
|
the system regardless of the before). A `None` field means "leave the
|
||||||
|
baseline value unchanged".
|
||||||
|
"""
|
||||||
|
|
||||||
|
# main_heating_details[0]
|
||||||
|
main_fuel_type: Optional[Union[int, str]] = None
|
||||||
|
heat_emitter_type: Optional[Union[int, str]] = None
|
||||||
|
main_heating_control: Optional[Union[int, str]] = None
|
||||||
|
sap_main_heating_code: Optional[int] = None
|
||||||
|
main_heating_index_number: Optional[int] = None
|
||||||
|
main_heating_category: Optional[int] = None
|
||||||
|
# A modern condensing boiler has a fanned (room-sealed) flue; the boiler
|
||||||
|
# upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the
|
||||||
|
# Table 4b condensing-boiler seasonal-efficiency basis depend on it).
|
||||||
|
fan_flue_present: Optional[bool] = None
|
||||||
|
# The boiler's flue type (Elmhurst enum) — a new condensing boiler lodges
|
||||||
|
# type 2 (room-sealed/balanced). SAP-inert, but written for fidelity so the
|
||||||
|
# end-state matches the installed boiler.
|
||||||
|
boiler_flue_type: Optional[int] = None
|
||||||
|
# sap_heating (top-level)
|
||||||
|
water_heating_code: Optional[int] = None
|
||||||
|
water_heating_fuel: Optional[int] = None
|
||||||
|
cylinder_size: Optional[Union[int, str]] = None
|
||||||
|
cylinder_insulation_type: Optional[Union[int, str]] = None
|
||||||
|
cylinder_insulation_thickness_mm: Optional[int] = None
|
||||||
|
cylinder_thermostat: Optional[str] = None
|
||||||
|
# EpcPropertyData (top-level)
|
||||||
|
has_hot_water_cylinder: Optional[bool] = None
|
||||||
|
# sap_energy_source
|
||||||
|
meter_type: Optional[str] = None
|
||||||
|
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
|
||||||
|
whole-dwelling change a Solar PV Measure Option makes (ADR-0026). Like the
|
||||||
|
ventilation/lighting overlays it targets no building part; `_fold_solar`
|
||||||
|
writes its fields onto `sap_energy_source`, the home of the SAP Appendix M
|
||||||
|
PV inputs.
|
||||||
|
|
||||||
|
The values are **absolute target states**, not deltas:
|
||||||
|
|
||||||
|
- ``photovoltaic_arrays`` is the *installed potential* — one
|
||||||
|
`PhotovoltaicArray` per non-north roof segment — and **replaces** the
|
||||||
|
dwelling's existing `photovoltaic_arrays` (empty for a non-PV dwelling),
|
||||||
|
never the EPC's own existing PV;
|
||||||
|
- ``pv_diverter_present`` routes surplus PV to a hot-water cylinder
|
||||||
|
immersion (App G4); set only when the dwelling has a cylinder to divert
|
||||||
|
to (a combi has none);
|
||||||
|
- ``is_dwelling_export_capable`` is set ``True`` absolutely — an export
|
||||||
|
meter is ensured post-install, driving the SEG export credit regardless
|
||||||
|
of the before;
|
||||||
|
- ``pv_batteries`` carries the battery variant's storage.
|
||||||
|
|
||||||
|
A `None` field means "leave the baseline value unchanged".
|
||||||
|
"""
|
||||||
|
|
||||||
|
photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None
|
||||||
|
pv_diverter_present: Optional[bool] = None
|
||||||
|
pv_connection: Optional[Union[int, str]] = None
|
||||||
|
is_dwelling_export_capable: Optional[bool] = None
|
||||||
|
pv_batteries: Optional[PvBatteries] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_windows() -> dict[int, WindowOverlay]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EpcSimulation:
|
||||||
|
"""A Simulation Overlay: the per-building-part changes a Measure Option
|
||||||
|
makes, keyed by `BuildingPartIdentifier`; per-window changes keyed by the
|
||||||
|
`sap_windows` index; plus an optional whole-dwelling `ventilation` change
|
||||||
|
(the Measure Dependency surface — ADR-0016)."""
|
||||||
|
|
||||||
|
building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field(
|
||||||
|
default_factory=_no_building_parts
|
||||||
|
)
|
||||||
|
windows: Mapping[int, WindowOverlay] = field(default_factory=_no_windows)
|
||||||
|
ventilation: Optional[VentilationOverlay] = None
|
||||||
|
lighting: Optional[LightingOverlay] = None
|
||||||
|
heating: Optional[HeatingOverlay] = None
|
||||||
|
secondary_heating: Optional[SecondaryHeatingOverlay] = None
|
||||||
|
solar: Optional[SolarOverlay] = None
|
||||||
124
domain/modelling/solar_potential.py
Normal file
124
domain/modelling/solar_potential.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Solar Potential — the installable PV potential of a dwelling, projected
|
||||||
|
from a Google Solar ``buildingInsights`` response (ADR-0026).
|
||||||
|
|
||||||
|
The production source of PV array configuration is the Google Solar API: the
|
||||||
|
raw ``buildingInsights`` JSON is fetched once by Ingestion and persisted as
|
||||||
|
JSONB (`SolarRepository`), never re-fetched. This module is the strictly-typed
|
||||||
|
projection Modelling reads over that JSON — the panel-count ladder
|
||||||
|
(``solarPanelConfigs``), each rung broken into the roof segments the SAP
|
||||||
|
calculator scores, with Google's continuous azimuth/tilt mapped to the SAP
|
||||||
|
octant / RdSAP pitch enums.
|
||||||
|
|
||||||
|
`SolarPotential` is *not* the dwelling's existing PV (that lives on the EPC's
|
||||||
|
``photovoltaic_arrays`` and is empty for a non-PV dwelling); it is the
|
||||||
|
*potential* the solar Recommendation Generator installs. The Google JSON →
|
||||||
|
`SolarPotential` mapping is its own validated boundary (CONTEXT: Solar
|
||||||
|
Potential).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
# Google's `azimuthDegrees` is a compass bearing: 0°=N, 90°=E, 180°=S, 270°=W,
|
||||||
|
# increasing clockwise. The SAP octant codes (ORIENTATION_BY_SAP10_CODE in the
|
||||||
|
# calculator) are 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW — exactly the
|
||||||
|
# eight 45° compass points in code order, so snapping to the nearest octant and
|
||||||
|
# adding one yields the SAP code.
|
||||||
|
_OCTANT_COUNT = 8
|
||||||
|
_DEGREES_PER_OCTANT = 45.0
|
||||||
|
|
||||||
|
# RdSAP 10 §11.1 fixes PV tilt to one of five values; the calculator's
|
||||||
|
# `_PV_PITCH_DEG_BY_CODE` is the inverse of this. Google reports a continuous
|
||||||
|
# `pitchDegrees`, so we snap to the nearest fixed tilt and return its code.
|
||||||
|
_PITCH_CODE_BY_DEGREES: dict[float, int] = {0.0: 1, 30.0: 2, 45.0: 3, 60.0: 4, 90.0: 5}
|
||||||
|
|
||||||
|
|
||||||
|
def azimuth_to_sap_octant(azimuth_degrees: float) -> int:
|
||||||
|
"""Bucket a Google compass azimuth (0°=N, clockwise) to the SAP octant code
|
||||||
|
{1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW}."""
|
||||||
|
index: int = round(azimuth_degrees / _DEGREES_PER_OCTANT) % _OCTANT_COUNT
|
||||||
|
return index + 1
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_to_sap_code(pitch_degrees: float) -> int:
|
||||||
|
"""Snap a Google continuous tilt to the nearest RdSAP 10 §11.1 fixed tilt
|
||||||
|
and return its code {0°→1, 30°→2, 45°→3, 60°→4, 90°→5}."""
|
||||||
|
nearest: float = min(
|
||||||
|
_PITCH_CODE_BY_DEGREES, key=lambda deg: abs(deg - pitch_degrees)
|
||||||
|
)
|
||||||
|
return _PITCH_CODE_BY_DEGREES[nearest]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolarRoofSegment:
|
||||||
|
"""One roof plane within a panel configuration — the panels Google places
|
||||||
|
on it and the orientation, tilt and expected DC generation that drive the
|
||||||
|
SAP Appendix M output."""
|
||||||
|
|
||||||
|
segment_index: int
|
||||||
|
panels_count: int
|
||||||
|
azimuth_degrees: float
|
||||||
|
pitch_degrees: float
|
||||||
|
yearly_energy_dc_kwh: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sap_orientation(self) -> int:
|
||||||
|
"""The SAP octant code for this plane's azimuth."""
|
||||||
|
return azimuth_to_sap_octant(self.azimuth_degrees)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sap_pitch_code(self) -> int:
|
||||||
|
"""The RdSAP §11.1 pitch code for this plane's tilt."""
|
||||||
|
return pitch_to_sap_code(self.pitch_degrees)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolarPanelConfiguration:
|
||||||
|
"""One rung of Google's ``solarPanelConfigs`` ladder: a whole-array layout
|
||||||
|
of ``panels_count`` panels spread across the roof segments, with the
|
||||||
|
array's total expected yearly DC generation."""
|
||||||
|
|
||||||
|
panels_count: int
|
||||||
|
yearly_energy_dc_kwh: float
|
||||||
|
segments: tuple[SolarRoofSegment, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolarPotential:
|
||||||
|
"""Strictly-typed projection of a Google Solar ``buildingInsights``
|
||||||
|
response — the panel ladder and the per-segment geometry Modelling needs to
|
||||||
|
size, score and cost a PV array (ADR-0026)."""
|
||||||
|
|
||||||
|
panel_capacity_watts: float
|
||||||
|
max_array_panels_count: int
|
||||||
|
configurations: tuple[SolarPanelConfiguration, ...]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_building_insights(cls, insights: Mapping[str, Any]) -> "SolarPotential":
|
||||||
|
"""Project a raw Google ``buildingInsights`` response (as persisted by
|
||||||
|
`SolarRepository`) into a `SolarPotential`."""
|
||||||
|
solar_potential: Mapping[str, Any] = insights["solarPotential"]
|
||||||
|
configurations: tuple[SolarPanelConfiguration, ...] = tuple(
|
||||||
|
SolarPanelConfiguration(
|
||||||
|
panels_count=int(config["panelsCount"]),
|
||||||
|
yearly_energy_dc_kwh=float(config["yearlyEnergyDcKwh"]),
|
||||||
|
segments=tuple(
|
||||||
|
SolarRoofSegment(
|
||||||
|
segment_index=int(summary["segmentIndex"]),
|
||||||
|
panels_count=int(summary["panelsCount"]),
|
||||||
|
azimuth_degrees=float(summary["azimuthDegrees"]),
|
||||||
|
pitch_degrees=float(summary["pitchDegrees"]),
|
||||||
|
yearly_energy_dc_kwh=float(summary["yearlyEnergyDcKwh"]),
|
||||||
|
)
|
||||||
|
for summary in config.get("roofSegmentSummaries", [])
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for config in solar_potential.get("solarPanelConfigs", [])
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
panel_capacity_watts=float(solar_potential["panelCapacityWatts"]),
|
||||||
|
max_array_panels_count=int(solar_potential["maxArrayPanelsCount"]),
|
||||||
|
configurations=configurations,
|
||||||
|
)
|
||||||
21
domain/modelling/solar_rates.json
Normal file
21
domain/modelling/solar_rates.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"_source": "20260409 Eco Approach - DOMNA Southern Housing Group Rates.xlsx, 'SOLAR PV & BATTERY' tab, EA Rates column (ADR-0026).",
|
||||||
|
"pv_system_by_kwp": {
|
||||||
|
"1.0": 2410.0,
|
||||||
|
"1.5": 2635.0,
|
||||||
|
"2.0": 2890.0,
|
||||||
|
"2.5": 2965.0,
|
||||||
|
"3.0": 3115.0,
|
||||||
|
"3.5": 3380.0,
|
||||||
|
"4.0": 3490.0,
|
||||||
|
"4.5": 3690.0
|
||||||
|
},
|
||||||
|
"scaffolding_first_elevation": 900.0,
|
||||||
|
"scaffolding_additional_elevation": 450.0,
|
||||||
|
"enabling_eicr": 150.0,
|
||||||
|
"enabling_dno": 50.0,
|
||||||
|
"enabling_consumer_unit": 330.0,
|
||||||
|
"diverter": 980.0,
|
||||||
|
"battery": 2000.0,
|
||||||
|
"battery_estimate": true
|
||||||
|
}
|
||||||
151
domain/modelling/valuation.py
Normal file
151
domain/modelling/valuation.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""Valuation Uplift — the estimated market-value increase a retrofit produces.
|
||||||
|
|
||||||
|
Percentage-primary (ADR-0018): the uplift is computed purely from the EPC Band
|
||||||
|
jump (current -> target) and is always returned as a percentage; the absolute £
|
||||||
|
forms appear only when a Property Valuation (current market value) is supplied,
|
||||||
|
and are capped so the £ uplift never exceeds twice the retrofit cost.
|
||||||
|
|
||||||
|
The band-transition percentages are ported verbatim from the legacy
|
||||||
|
`backend/ml_models/Valuation.py` — four published broker sources, a provenance
|
||||||
|
snapshot rather than a live feed. MoneySupermarket / Lloyds give per-band-step
|
||||||
|
figures we compound across the jump; Knight Frank / Rightmove give whole-jump
|
||||||
|
spot figures. The uplift takes the min / max / mean across the sources that
|
||||||
|
cover the jump. See CONTEXT.md (Property Valuation, Valuation Uplift).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import prod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Ascending energy efficiency, worst -> best (RdSAP band letters).
|
||||||
|
_EPC_BANDS: tuple[str, ...] = ("G", "F", "E", "D", "C", "B", "A")
|
||||||
|
|
||||||
|
# Per-band-step uplift %, compounded across the jump.
|
||||||
|
_MSM_STEP: dict[tuple[str, str], float] = {
|
||||||
|
("G", "F"): 0.06,
|
||||||
|
("F", "E"): 0.01,
|
||||||
|
("E", "D"): 0.01,
|
||||||
|
("D", "C"): 0.02,
|
||||||
|
("C", "B"): 0.04,
|
||||||
|
("B", "A"): 0.0,
|
||||||
|
}
|
||||||
|
_LLOYDS_STEP: dict[tuple[str, str], float] = {
|
||||||
|
("G", "F"): 0.038,
|
||||||
|
("F", "E"): 0.029,
|
||||||
|
("E", "D"): 0.024,
|
||||||
|
("D", "C"): 0.02,
|
||||||
|
("C", "B"): 0.02,
|
||||||
|
("B", "A"): 0.018,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whole-jump spot uplift %, looked up by (current, target); absent jumps don't
|
||||||
|
# contribute a source.
|
||||||
|
_KNIGHT_FRANK_JUMP: dict[tuple[str, str], float] = {
|
||||||
|
("D", "C"): 0.03,
|
||||||
|
("D", "B"): 0.088,
|
||||||
|
("D", "A"): 0.088,
|
||||||
|
}
|
||||||
|
_RIGHTMOVE_JUMP: dict[tuple[str, str], float] = {
|
||||||
|
("G", "C"): 0.15,
|
||||||
|
("G", "B"): 0.15,
|
||||||
|
("G", "A"): 0.15,
|
||||||
|
("F", "C"): 0.15,
|
||||||
|
("F", "B"): 0.15,
|
||||||
|
("F", "A"): 0.15,
|
||||||
|
("E", "C"): 0.07,
|
||||||
|
("E", "B"): 0.07,
|
||||||
|
("E", "A"): 0.07,
|
||||||
|
("D", "C"): 0.03,
|
||||||
|
("D", "B"): 0.03,
|
||||||
|
("D", "A"): 0.03,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ROI_CAP = 2.0 # the £ uplift is capped at this multiple of the retrofit cost
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValuationUplift:
|
||||||
|
"""A retrofit's estimated market-value uplift. The percentages are always
|
||||||
|
present (from the Band jump); the £ forms are populated only when a current
|
||||||
|
market value was supplied. `lower_value` / `upper_value` / `average_value`
|
||||||
|
are the £ *increase* at the min / max / mean source; `post_retrofit_value`
|
||||||
|
is the resulting market value (current + average increase)."""
|
||||||
|
|
||||||
|
lower_pct: float
|
||||||
|
upper_pct: float
|
||||||
|
average_pct: float
|
||||||
|
lower_value: Optional[float] = None
|
||||||
|
upper_value: Optional[float] = None
|
||||||
|
average_value: Optional[float] = None
|
||||||
|
post_retrofit_value: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_band(band: str) -> int:
|
||||||
|
if band not in _EPC_BANDS:
|
||||||
|
raise ValueError(f"unknown EPC band {band!r}")
|
||||||
|
return _EPC_BANDS.index(band)
|
||||||
|
|
||||||
|
|
||||||
|
def _band_uplift_percentages(current_band: str, target_band: str) -> tuple[float, float, float]:
|
||||||
|
"""The (min, max, mean) uplift percentages across the sources covering the
|
||||||
|
jump. A non-improving jump (target no better than current) compounds over no
|
||||||
|
steps and matches no spot source, so MoneySupermarket / Lloyds both yield
|
||||||
|
0 and the result is a no-op 0%."""
|
||||||
|
current_index = _require_band(current_band)
|
||||||
|
target_index = _require_band(target_band)
|
||||||
|
steps = [
|
||||||
|
(_EPC_BANDS[i], _EPC_BANDS[i + 1]) for i in range(current_index, target_index)
|
||||||
|
]
|
||||||
|
msm: float = prod(1 + _MSM_STEP[step] for step in steps) - 1
|
||||||
|
lloyds: float = prod(1 + _LLOYDS_STEP[step] for step in steps) - 1
|
||||||
|
increases: list[float] = [msm, lloyds]
|
||||||
|
knight_frank: Optional[float] = _KNIGHT_FRANK_JUMP.get((current_band, target_band))
|
||||||
|
rightmove: Optional[float] = _RIGHTMOVE_JUMP.get((current_band, target_band))
|
||||||
|
if knight_frank is not None:
|
||||||
|
increases.append(knight_frank)
|
||||||
|
if rightmove is not None:
|
||||||
|
increases.append(rightmove)
|
||||||
|
return min(increases), max(increases), sum(increases) / len(increases)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_valuation_uplift(
|
||||||
|
current_band: str,
|
||||||
|
target_band: str,
|
||||||
|
current_value: Optional[float] = None,
|
||||||
|
total_cost: Optional[float] = None,
|
||||||
|
) -> ValuationUplift:
|
||||||
|
"""Estimate the Valuation Uplift of moving a Property from `current_band` to
|
||||||
|
`target_band`. Returns percentages always; absolute £ forms only when
|
||||||
|
`current_value` is given. When both `current_value` and `total_cost` are
|
||||||
|
given, the percentages are rescaled so the average £ uplift does not exceed
|
||||||
|
`_ROI_CAP` times the cost (the cap can only bite once a value is known)."""
|
||||||
|
lower_pct, upper_pct, average_pct = _band_uplift_percentages(
|
||||||
|
current_band, target_band
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_value is not None and total_cost is not None and total_cost > 0:
|
||||||
|
average_value = current_value * average_pct
|
||||||
|
if average_value > _ROI_CAP * total_cost:
|
||||||
|
capped_average_pct = _ROI_CAP * total_cost / current_value
|
||||||
|
scalar = capped_average_pct / average_pct
|
||||||
|
lower_pct *= scalar
|
||||||
|
upper_pct *= scalar
|
||||||
|
average_pct = capped_average_pct
|
||||||
|
|
||||||
|
if current_value is None:
|
||||||
|
return ValuationUplift(
|
||||||
|
lower_pct=lower_pct, upper_pct=upper_pct, average_pct=average_pct
|
||||||
|
)
|
||||||
|
|
||||||
|
average_increase: float = current_value * average_pct
|
||||||
|
return ValuationUplift(
|
||||||
|
lower_pct=lower_pct,
|
||||||
|
upper_pct=upper_pct,
|
||||||
|
average_pct=average_pct,
|
||||||
|
lower_value=current_value * lower_pct,
|
||||||
|
upper_value=current_value * upper_pct,
|
||||||
|
average_value=average_increase,
|
||||||
|
post_retrofit_value=current_value + average_increase,
|
||||||
|
)
|
||||||
|
|
@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.property.site_notes import SiteNotes
|
from domain.property.site_notes import SiteNotes
|
||||||
|
|
||||||
SourcePath = Literal["site_notes", "epc_with_overlay"]
|
SourcePath = Literal["site_notes", "epc_with_overlay"]
|
||||||
|
|
@ -37,6 +38,12 @@ class Property:
|
||||||
identity: PropertyIdentity
|
identity: PropertyIdentity
|
||||||
epc: Optional[EpcPropertyData] = None
|
epc: Optional[EpcPropertyData] = None
|
||||||
site_notes: Optional[SiteNotes] = None
|
site_notes: Optional[SiteNotes] = None
|
||||||
|
# The current open-market value (a Property Valuation) — externally sourced
|
||||||
|
# and mostly absent; feeds the Plan's Valuation Uplift £ forms (ADR-0018).
|
||||||
|
current_market_value: Optional[float] = None
|
||||||
|
# Planning protections resolved from the geospatial layer (ADR-0020); gate
|
||||||
|
# wall insulation (ADR-0019). Defaults to unrestricted when unknown.
|
||||||
|
planning_restrictions: PlanningRestrictions = PlanningRestrictions()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_path(self) -> SourcePath:
|
def source_path(self) -> SourcePath:
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
from domain.fuel_rates.fuel import Fuel
|
|
||||||
|
|
||||||
|
|
||||||
class BillSection(Enum):
|
|
||||||
"""A user-meaningful slice of the annual energy bill — the calculator's raw
|
|
||||||
end uses folded into the sections the UI shows (ADR-0014)."""
|
|
||||||
|
|
||||||
HEATING = "HEATING"
|
|
||||||
HOT_WATER = "HOT_WATER"
|
|
||||||
LIGHTING = "LIGHTING"
|
|
||||||
APPLIANCES = "APPLIANCES"
|
|
||||||
COOKING = "COOKING"
|
|
||||||
PUMPS_FANS = "PUMPS_FANS"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EnergyLine:
|
|
||||||
"""One section's delivered energy on one fuel. A section may have more than
|
|
||||||
one line (e.g. gas main heating + electric secondary heating)."""
|
|
||||||
|
|
||||||
section: BillSection
|
|
||||||
fuel: Fuel
|
|
||||||
kwh: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EnergyBreakdown:
|
|
||||||
"""A Property's delivered energy per end use, the input to Bill Derivation —
|
|
||||||
produced from SAP10 Calculation in a later slice. ``exported_kwh`` is PV
|
|
||||||
generation exported to the grid, credited at the SEG rate."""
|
|
||||||
|
|
||||||
lines: Sequence[EnergyLine]
|
|
||||||
exported_kwh: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BillSectionCost:
|
|
||||||
"""One section's rolled-up delivered kWh and annual cost (£)."""
|
|
||||||
|
|
||||||
kwh: float
|
|
||||||
cost_gbp: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Bill:
|
|
||||||
"""A Property's annual energy bill, composed per section plus the per-meter
|
|
||||||
standing charges and the SEG export credit, and the total (ADR-0014)."""
|
|
||||||
|
|
||||||
sections: Mapping[BillSection, BillSectionCost]
|
|
||||||
standing_charges_gbp: float
|
|
||||||
seg_credit_gbp: float
|
|
||||||
total_gbp: float
|
|
||||||
|
|
@ -4,7 +4,7 @@ import logging
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from domain.property_baseline.performance import Performance
|
from domain.property_baseline.performance import Performance
|
||||||
from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineReason
|
from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
|
@ -12,12 +12,19 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# The calculator targets SAP 10.2 (14-03-2025). A cert lodged below this carries
|
# Lodged figures are trusted from SAP 10.2 (14-03-2025) onward — the version the
|
||||||
# a superseded methodology and is rebaselined to the calculator's output; at or
|
# calculator targets. A cert lodged below this carries a superseded methodology,
|
||||||
# above it, the API's lodged figures are kept and the calculator only validates.
|
# so the calculator's output replaces it; at or above it the lodged figures are
|
||||||
_SAP10_2_FLOOR = 10.2
|
# kept and the calculator only validates against them.
|
||||||
_SAP_ABS_TOL = 0.5
|
_MIN_TRUSTED_SAP_VERSION = 10.2
|
||||||
_REL_TOL = 0.01
|
|
||||||
|
# Divergence thresholds for that validation log. The calculator emits a
|
||||||
|
# *continuous* SAP score whereas the lodged score is rounded to an integer, so a
|
||||||
|
# gap up to half a point is just rounding — beyond it the calculator and the
|
||||||
|
# register genuinely disagree and we record it. CO2 and Primary Energy Intensity
|
||||||
|
# are not rounded that way, so they get a 1% relative band instead.
|
||||||
|
_MAX_SAP_SCORE_DIVERGENCE = 0.5
|
||||||
|
_MAX_RELATIVE_DIVERGENCE = 0.01
|
||||||
_KG_PER_TONNE = 1000.0
|
_KG_PER_TONNE = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,17 +51,23 @@ class CalculatorRebaseliner(Rebaseliner):
|
||||||
|
|
||||||
def rebaseline(
|
def rebaseline(
|
||||||
self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance
|
self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance
|
||||||
) -> tuple[Performance, RebaselineReason]:
|
) -> RebaselineResult:
|
||||||
# A raise (UnmappedSapCode, etc.) propagates: the calculator is
|
# A raise (UnmappedSapCode, etc.) propagates: the calculator is
|
||||||
# load-bearing, so the batch aborts and the cert is fixed at once.
|
# load-bearing, so the batch aborts and the cert is fixed at once. The
|
||||||
|
# SapResult rides on the result either way — Bill Derivation prices it
|
||||||
|
# regardless of whether lodged or calculated figures win (ADR-0013/0014).
|
||||||
result: SapResult = self._calculator.calculate(effective_epc)
|
result: SapResult = self._calculator.calculate(effective_epc)
|
||||||
sap_version: Optional[float] = effective_epc.sap_version
|
sap_version: Optional[float] = effective_epc.sap_version
|
||||||
if sap_version is not None and sap_version < _SAP10_2_FLOOR:
|
if sap_version is not None and sap_version < _MIN_TRUSTED_SAP_VERSION:
|
||||||
return Performance.from_sap_result(result), "pre_sap10"
|
return RebaselineResult(
|
||||||
|
effective=Performance.from_sap_result(result),
|
||||||
|
reason="pre_sap10",
|
||||||
|
sap_result=result,
|
||||||
|
)
|
||||||
self._log_divergence(
|
self._log_divergence(
|
||||||
property_id=property_id, sap_version=sap_version, result=result, lodged=lodged
|
property_id=property_id, sap_version=sap_version, result=result, lodged=lodged
|
||||||
)
|
)
|
||||||
return lodged, "none"
|
return RebaselineResult(effective=lodged, reason="none", sap_result=result)
|
||||||
|
|
||||||
def _log_divergence(
|
def _log_divergence(
|
||||||
self,
|
self,
|
||||||
|
|
@ -64,15 +77,15 @@ class CalculatorRebaseliner(Rebaseliner):
|
||||||
result: "SapResult",
|
result: "SapResult",
|
||||||
lodged: Performance,
|
lodged: Performance,
|
||||||
) -> None:
|
) -> None:
|
||||||
if abs(result.sap_score_continuous - lodged.sap_score) > _SAP_ABS_TOL:
|
if abs(result.sap_score_continuous - lodged.sap_score) > _MAX_SAP_SCORE_DIVERGENCE:
|
||||||
self._warn(property_id, sap_version, "sap_score", lodged.sap_score, result.sap_score_continuous)
|
self._warn(property_id, sap_version, "sap_score", lodged.sap_score, result.sap_score_continuous)
|
||||||
if _relative_diff(result.primary_energy_kwh_per_m2, lodged.primary_energy_intensity) > _REL_TOL:
|
if _relative_diff(result.primary_energy_kwh_per_m2, lodged.primary_energy_intensity) > _MAX_RELATIVE_DIVERGENCE:
|
||||||
self._warn(
|
self._warn(
|
||||||
property_id, sap_version, "primary_energy_intensity",
|
property_id, sap_version, "primary_energy_intensity",
|
||||||
lodged.primary_energy_intensity, result.primary_energy_kwh_per_m2,
|
lodged.primary_energy_intensity, result.primary_energy_kwh_per_m2,
|
||||||
)
|
)
|
||||||
calculated_co2_t = result.co2_kg_per_yr / _KG_PER_TONNE
|
calculated_co2_t = result.co2_kg_per_yr / _KG_PER_TONNE
|
||||||
if _relative_diff(calculated_co2_t, lodged.co2_emissions) > _REL_TOL:
|
if _relative_diff(calculated_co2_t, lodged.co2_emissions) > _MAX_RELATIVE_DIVERGENCE:
|
||||||
self._warn(property_id, sap_version, "co2_emissions", lodged.co2_emissions, calculated_co2_t)
|
self._warn(property_id, sap_version, "co2_emissions", lodged.co2_emissions, calculated_co2_t)
|
||||||
|
|
||||||
def _warn(
|
def _warn(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from domain.billing.bill import Bill
|
||||||
from domain.property_baseline.performance import Performance
|
from domain.property_baseline.performance import Performance
|
||||||
from domain.property_baseline.rebaseliner import RebaselineReason
|
from domain.property_baseline.rebaseliner import RebaselineReason
|
||||||
|
|
||||||
|
|
@ -17,8 +19,10 @@ class PropertyBaselinePerformance:
|
||||||
|
|
||||||
Carries the part of the energy block that needs no derivation: annual
|
Carries the part of the energy block that needs no derivation: annual
|
||||||
``space_heating_kwh`` / ``water_heating_kwh`` read off the EPC's RHI.
|
``space_heating_kwh`` / ``water_heating_kwh`` read off the EPC's RHI.
|
||||||
Fuel split and bills (the rest of EPC Energy Derivation) land in a
|
|
||||||
follow-up once a Fuel Rates source exists.
|
Carries the derived ``bill`` (ADR-0014): the calculator's delivered kWh per
|
||||||
|
end use priced at current Fuel Rates. It is ``None`` only when no calculator
|
||||||
|
ran (the stub path produced no ``SapResult`` to price).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lodged: Performance
|
lodged: Performance
|
||||||
|
|
@ -26,3 +30,4 @@ class PropertyBaselinePerformance:
|
||||||
rebaseline_reason: RebaselineReason
|
rebaseline_reason: RebaselineReason
|
||||||
space_heating_kwh: float
|
space_heating_kwh: float
|
||||||
water_heating_kwh: float
|
water_heating_kwh: float
|
||||||
|
bill: Optional[Bill] = None
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Literal
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
from domain.property_baseline.performance import Performance
|
from domain.property_baseline.performance import Performance
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from domain.sap10_calculator.calculator import SapResult
|
||||||
|
|
||||||
RebaselineReason = Literal["none", "pre_sap10", "physical_state_changed", "both"]
|
RebaselineReason = Literal["none", "pre_sap10", "physical_state_changed", "both"]
|
||||||
|
|
||||||
# The SAP spec version below which a cert's recorded scores reflect a superseded
|
# The SAP spec version below which a cert's recorded scores reflect a superseded
|
||||||
# methodology and must be ML-rebaselined (CONTEXT.md: Rebaselining).
|
# methodology and must be rebaselined to the calculator's output (CONTEXT.md:
|
||||||
|
# Rebaselining).
|
||||||
_SAP10_FLOOR = 10.0
|
_SAP10_FLOOR = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,40 +28,60 @@ class RebaselineNotImplemented(Exception):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Rebaseliner(ABC):
|
@dataclass(frozen=True)
|
||||||
"""Produces a Property's Effective Performance from its Effective EPC.
|
class RebaselineResult:
|
||||||
|
"""The outcome of Rebaselining a Property: its Effective Performance, why it
|
||||||
|
differs from Lodged, and the calculator `SapResult` it was scored from.
|
||||||
|
|
||||||
Rebaselining (CONTEXT.md) re-predicts the rated quantities via ML when the
|
``sap_result`` is the scored picture (ADR-0013 amendment) — a first-class
|
||||||
EPC was lodged pre-SAP10 or its physical state diverged from the lodged EPC;
|
part of the result because Bill Derivation prices the *same* scoring
|
||||||
|
(ADR-0014). It is ``None`` only for a Rebaseliner that ran no calculator (the
|
||||||
|
test ``StubRebaseliner``); the load-bearing ``CalculatorRebaseliner`` always
|
||||||
|
sets it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
effective: Performance
|
||||||
|
reason: RebaselineReason
|
||||||
|
sap_result: Optional["SapResult"]
|
||||||
|
|
||||||
|
|
||||||
|
class Rebaseliner(ABC):
|
||||||
|
"""Produces a Property's Effective Performance by Rebaselining its Effective EPC.
|
||||||
|
|
||||||
|
Rebaselining (CONTEXT.md) assembles the Effective EPC picture and scores it
|
||||||
|
through SAP10 Calculation, replacing the recorded scores when the EPC was
|
||||||
|
lodged pre-SAP10 or its physical state diverged from the lodged EPC;
|
||||||
otherwise Effective Performance equals Lodged. Injected into the
|
otherwise Effective Performance equals Lodged. Injected into the
|
||||||
PropertyBaselineOrchestrator (ADR-0011) so the ML adapter can swap in without
|
PropertyBaselineOrchestrator (ADR-0011) so the implementation can swap
|
||||||
touching the orchestrator, and so the single-property re-score-on-override
|
without touching the orchestrator, and so the single-property
|
||||||
flow reuses the same port.
|
re-score-on-override flow reuses the same port.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def rebaseline(
|
def rebaseline(
|
||||||
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
||||||
) -> tuple[Performance, RebaselineReason]: ...
|
) -> RebaselineResult: ...
|
||||||
|
|
||||||
|
|
||||||
class StubRebaseliner(Rebaseliner):
|
class StubRebaseliner(Rebaseliner):
|
||||||
"""A no-calculator stub for tests that don't want the real calculator.
|
"""A no-calculator stub for tests that don't want the real calculator.
|
||||||
|
|
||||||
SAP10 certs pass through untouched — Effective Performance equals Lodged,
|
SAP10 certs pass through untouched — Effective Performance equals Lodged,
|
||||||
reason ``"none"``. A pre-SAP10 cert genuinely needs rebaselining, which this
|
reason ``"none"``, and ``sap_result`` is ``None`` (no calculator ran). A
|
||||||
stub does not do, so it raises rather than fabricating a "none". Production
|
pre-SAP10 cert genuinely needs rebaselining, which this stub does not do, so
|
||||||
uses ``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013
|
it raises rather than fabricating a "none". Production uses
|
||||||
amendment); this stub stays for orchestrator/repo unit tests.
|
``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013
|
||||||
|
amendment); this stub stays for orchestrator/repo unit tests that don't
|
||||||
|
exercise the bill.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def rebaseline(
|
def rebaseline(
|
||||||
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
||||||
) -> tuple[Performance, RebaselineReason]:
|
) -> RebaselineResult:
|
||||||
sap_version = effective_epc.sap_version
|
sap_version = effective_epc.sap_version
|
||||||
if sap_version is not None and sap_version < _SAP10_FLOOR:
|
if sap_version is not None and sap_version < _SAP10_FLOOR:
|
||||||
raise RebaselineNotImplemented(
|
raise RebaselineNotImplemented(
|
||||||
f"Property needs rebaselining (pre-SAP10 cert, sap_version="
|
f"Property needs rebaselining (pre-SAP10 cert, sap_version="
|
||||||
f"{sap_version}); ML rebaselining is not implemented yet"
|
f"{sap_version}); this stub does not run the calculator"
|
||||||
)
|
)
|
||||||
return lodged, "none"
|
return RebaselineResult(effective=lodged, reason="none", sap_result=None)
|
||||||
|
|
|
||||||
|
|
@ -744,6 +744,19 @@ def _pv_annual_s_kwh_per_m2(
|
||||||
total += days * s_m
|
total += days * s_m
|
||||||
return _HOURS_PER_DAY_OVER_1000 * total
|
return _HOURS_PER_DAY_OVER_1000 * total
|
||||||
|
|
||||||
|
|
||||||
|
def pv_annual_solar_radiation_kwh_per_m2(
|
||||||
|
orientation_code: int, pitch_code: int, climate: int = 0
|
||||||
|
) -> float:
|
||||||
|
"""Public seam over the SAP 10.2 Appendix U3.3 annual PV solar radiation
|
||||||
|
`S` (kWh/m²/yr) for a plane of given SAP orientation octant + RdSAP pitch
|
||||||
|
code. `climate` defaults to 0 (UK average, the rating cascade). Reused by
|
||||||
|
the Modelling solar overshading calibration (ADR-0026), which back-solves
|
||||||
|
the overshading factor ZPV from Google's expected generation against this
|
||||||
|
unshaded `S`."""
|
||||||
|
return _pv_annual_s_kwh_per_m2(orientation_code, pitch_code, climate)
|
||||||
|
|
||||||
|
|
||||||
# SAP 10.2 Table M1 — PV overshading factor ZPV. RdSAP10 omits SAP10.2's
|
# SAP 10.2 Table M1 — PV overshading factor ZPV. RdSAP10 omits SAP10.2's
|
||||||
# 5th "Severe" bucket; the four RdSAP codes map directly:
|
# 5th "Severe" bucket; the four RdSAP codes map directly:
|
||||||
# 1 = very little / none → 1.0
|
# 1 = very little / none → 1.0
|
||||||
|
|
|
||||||
0
harness/__init__.py
Normal file
0
harness/__init__.py
Normal file
142
harness/cohort.py
Normal file
142
harness/cohort.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Run a cohort of API-shaped EPC JSONs through Modelling, offline.
|
||||||
|
|
||||||
|
Parses each file with `EpcPropertyDataMapper.from_api_response` (the EPC-API
|
||||||
|
shape) and runs it through `run_modelling` — no database, no network, no
|
||||||
|
Baseline gate. A cert that raises (e.g. an unpriced fuel, an unmapped code) is
|
||||||
|
captured as an error rather than aborting the sweep, so one bad cert never
|
||||||
|
stops the inspection. Point it at your EPC dump and read the summary / CSV.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from domain.modelling.plan import Plan
|
||||||
|
from harness.console import DEFAULT_CATALOGUE, run_modelling
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CertResult:
|
||||||
|
"""The outcome of modelling one cert: its `Plan` (for full inspection), or
|
||||||
|
the error it raised. The flat properties summarise the Plan for tables/CSV."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
plan: Optional[Plan] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def measures(self) -> int:
|
||||||
|
return 0 if self.plan is None else len(self.plan.measures)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def baseline_sap(self) -> Optional[float]:
|
||||||
|
return None if self.plan is None else self.plan.baseline.sap_continuous
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post_sap(self) -> Optional[float]:
|
||||||
|
return None if self.plan is None else self.plan.post_sap_continuous
|
||||||
|
|
||||||
|
|
||||||
|
def run_cohort(
|
||||||
|
json_paths: Iterable[Path],
|
||||||
|
*,
|
||||||
|
goal_band: str = "C",
|
||||||
|
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||||
|
) -> list[CertResult]:
|
||||||
|
"""Model every API-JSON path in `json_paths` offline, returning one
|
||||||
|
`CertResult` each (errors captured, never raised)."""
|
||||||
|
results: list[CertResult] = []
|
||||||
|
for path in json_paths:
|
||||||
|
try:
|
||||||
|
epc = EpcPropertyDataMapper.from_api_response(json.loads(path.read_text()))
|
||||||
|
plan = run_modelling(
|
||||||
|
epc,
|
||||||
|
goal_band=goal_band,
|
||||||
|
catalogue_path=catalogue_path,
|
||||||
|
print_table=False,
|
||||||
|
)
|
||||||
|
results.append(CertResult(name=path.stem, plan=plan))
|
||||||
|
except Exception as error: # noqa: BLE001 — one bad cert must not stop the sweep
|
||||||
|
results.append(
|
||||||
|
CertResult(name=path.stem, error=f"{type(error).__name__}: {error}")
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def format_cohort_summary(results: list[CertResult]) -> str:
|
||||||
|
"""A compact summary: cohort size, how many ran / produced measures /
|
||||||
|
errored, the measure-count distribution, and each distinct error."""
|
||||||
|
ran = [result for result in results if result.error is None]
|
||||||
|
errored = [result for result in results if result.error is not None]
|
||||||
|
with_measures = sum(1 for result in ran if result.measures > 0)
|
||||||
|
|
||||||
|
distribution: dict[int, int] = {}
|
||||||
|
for result in ran:
|
||||||
|
distribution[result.measures] = distribution.get(result.measures, 0) + 1
|
||||||
|
|
||||||
|
error_kinds: dict[str, int] = {}
|
||||||
|
for result in errored:
|
||||||
|
assert result.error is not None
|
||||||
|
error_kinds[result.error] = error_kinds.get(result.error, 0) + 1
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"cohort size : {len(results)}",
|
||||||
|
f"ran offline : {len(ran)}",
|
||||||
|
f"w/ measures : {with_measures}",
|
||||||
|
f"errors : {len(errored)}",
|
||||||
|
f"measure-count distribution: {dict(sorted(distribution.items()))}",
|
||||||
|
]
|
||||||
|
if error_kinds:
|
||||||
|
lines.append("error kinds:")
|
||||||
|
lines.extend(
|
||||||
|
f" {count:3d} {kind}"
|
||||||
|
for kind, count in sorted(error_kinds.items(), key=lambda item: -item[1])
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
_CSV_HEADER = (
|
||||||
|
"cert,baseline_sap,post_sap,post_band,measures,measure_types,"
|
||||||
|
"cost_of_works,bill_savings,valuation_avg_pct,error"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_cell(value: object) -> str:
|
||||||
|
"""Render a CSV cell, rounding floats and keeping the row comma-safe
|
||||||
|
(measure types are ';'-joined; an error message's commas are stripped)."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, float):
|
||||||
|
return f"{value:.2f}"
|
||||||
|
return str(value).replace(",", ";")
|
||||||
|
|
||||||
|
|
||||||
|
def format_cohort_csv(results: list[CertResult]) -> str:
|
||||||
|
"""One header row plus one row per cert — browsable/sortable in a
|
||||||
|
spreadsheet for a large dump."""
|
||||||
|
rows = [_CSV_HEADER]
|
||||||
|
for result in results:
|
||||||
|
plan = result.plan
|
||||||
|
measure_types = (
|
||||||
|
";".join(measure.measure_type for measure in plan.measures)
|
||||||
|
if plan is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
cells = [
|
||||||
|
result.name,
|
||||||
|
result.baseline_sap,
|
||||||
|
result.post_sap,
|
||||||
|
plan.post_epc_rating.value if plan is not None else None,
|
||||||
|
result.measures,
|
||||||
|
measure_types,
|
||||||
|
plan.cost_of_works if plan is not None else None,
|
||||||
|
plan.energy_bill_savings if plan is not None else None,
|
||||||
|
plan.valuation.average_pct if plan is not None else None,
|
||||||
|
result.error,
|
||||||
|
]
|
||||||
|
rows.append(",".join(_csv_cell(cell) for cell in cells))
|
||||||
|
return "\n".join(rows)
|
||||||
248
harness/console.py
Normal file
248
harness/console.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""Run one property through the full First Run pipeline with no database.
|
||||||
|
|
||||||
|
The interactive inspection entrypoint: hand it an `EpcPropertyData` (e.g.
|
||||||
|
`EpcPropertyDataMapper.from_api_response(json)`), and it wires the whole
|
||||||
|
`AraFirstRunPipeline` (Ingestion -> Baseline -> Modelling) against in-memory
|
||||||
|
fakes — no Postgres, no network — runs it, prints the sense-check table, and
|
||||||
|
returns the `Plan` for further poking.
|
||||||
|
|
||||||
|
Dev tooling, not deployed: it reuses the in-memory test fakes, so run it from a
|
||||||
|
REPL at the worktree root::
|
||||||
|
|
||||||
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from harness.console import run_one
|
||||||
|
plan = run_one(EpcPropertyDataMapper.from_api_response(my_api_json), goal_band="C")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
|
from domain.geospatial.coordinates import Coordinates
|
||||||
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
from domain.modelling.plan import Plan
|
||||||
|
from domain.modelling.scenario import Scenario
|
||||||
|
from domain.property.property import Property, PropertyIdentity
|
||||||
|
from domain.property_baseline.rebaseliner import StubRebaseliner
|
||||||
|
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||||
|
from harness.plan_table import format_plan_table
|
||||||
|
from orchestration.ara_first_run_pipeline import AraFirstRunPipeline
|
||||||
|
from orchestration.ingestion_orchestrator import IngestionOrchestrator
|
||||||
|
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
||||||
|
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
|
||||||
|
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||||
|
FuelRatesStaticFileRepository,
|
||||||
|
)
|
||||||
|
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
||||||
|
from repositories.product.product_json_repository import ProductJsonRepository
|
||||||
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
from tests.orchestration.fakes import (
|
||||||
|
FakeEpcRepo,
|
||||||
|
FakePlanRepository,
|
||||||
|
FakePropertyRepo,
|
||||||
|
FakeScenarioRepository,
|
||||||
|
FakeSolarRepo,
|
||||||
|
FakeUnitOfWork,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_CATALOGUE = Path(__file__).resolve().parent / "sample_catalogue.json"
|
||||||
|
|
||||||
|
_PROPERTY_ID = 1
|
||||||
|
_SCENARIO_ID = 7
|
||||||
|
_PORTFOLIO_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Command:
|
||||||
|
portfolio_id: int
|
||||||
|
property_ids: list[int]
|
||||||
|
scenario_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class _FetcherReturning:
|
||||||
|
def __init__(self, epc: EpcPropertyData) -> None:
|
||||||
|
self._epc = epc
|
||||||
|
|
||||||
|
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
|
||||||
|
return self._epc
|
||||||
|
|
||||||
|
|
||||||
|
class _NoCoordinates(GeospatialRepository):
|
||||||
|
def coordinates_for(self, uprn: int) -> Optional[Coordinates]:
|
||||||
|
return None # skip the solar leg
|
||||||
|
|
||||||
|
|
||||||
|
class _UnusedSolarFetcher:
|
||||||
|
def get_building_insights(
|
||||||
|
self, longitude: float, latitude: float
|
||||||
|
) -> dict[str, Any]: # pragma: no cover
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
*,
|
||||||
|
goal_band: str = "C",
|
||||||
|
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||||
|
current_market_value: Optional[float] = None,
|
||||||
|
print_table: bool = True,
|
||||||
|
) -> Plan:
|
||||||
|
"""Run ``epc`` through the full First Run pipeline with no database and
|
||||||
|
return its Plan for the default Increasing-EPC Scenario targeting
|
||||||
|
``goal_band``. Prints the sense-check table unless ``print_table`` is False.
|
||||||
|
|
||||||
|
Pass ``current_market_value`` (a Property Valuation) to value the Plan's
|
||||||
|
Valuation Uplift in £ — otherwise the uplift is percentage-only (ADR-0018).
|
||||||
|
``epc`` must carry lodged recorded-performance + the RHI block (a real lodged
|
||||||
|
EPC does) so the Baseline stage can run."""
|
||||||
|
epc_repo = FakeEpcRepo()
|
||||||
|
plan_repo = FakePlanRepository()
|
||||||
|
property_repo = FakePropertyRepo(
|
||||||
|
{
|
||||||
|
_PROPERTY_ID: Property(
|
||||||
|
identity=PropertyIdentity(
|
||||||
|
portfolio_id=_PORTFOLIO_ID,
|
||||||
|
postcode="A0 0AA",
|
||||||
|
address="1 Some Street",
|
||||||
|
uprn=12345,
|
||||||
|
),
|
||||||
|
current_market_value=current_market_value,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
epc_repo=epc_repo,
|
||||||
|
)
|
||||||
|
unit = FakeUnitOfWork(
|
||||||
|
property=property_repo,
|
||||||
|
epc=epc_repo,
|
||||||
|
scenario=FakeScenarioRepository(
|
||||||
|
{
|
||||||
|
_SCENARIO_ID: Scenario(
|
||||||
|
id=_SCENARIO_ID,
|
||||||
|
goal="Increasing EPC",
|
||||||
|
goal_value=goal_band,
|
||||||
|
budget=None,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
product=ProductJsonRepository(catalogue_path),
|
||||||
|
plan=plan_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = AraFirstRunPipeline(
|
||||||
|
ingestion=IngestionOrchestrator(
|
||||||
|
unit_of_work=lambda: unit,
|
||||||
|
epc_fetcher=_FetcherReturning(epc),
|
||||||
|
geospatial_repo=_NoCoordinates(),
|
||||||
|
solar_fetcher=_UnusedSolarFetcher(),
|
||||||
|
),
|
||||||
|
baseline=PropertyBaselineOrchestrator(
|
||||||
|
unit_of_work=lambda: unit,
|
||||||
|
rebaseliner=StubRebaseliner(),
|
||||||
|
fuel_rates=FuelRatesStaticFileRepository(),
|
||||||
|
),
|
||||||
|
modelling=ModellingOrchestrator(
|
||||||
|
unit_of_work=lambda: unit,
|
||||||
|
calculator=Sap10Calculator(),
|
||||||
|
fuel_rates=FuelRatesStaticFileRepository(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pipeline.run(
|
||||||
|
_Command(
|
||||||
|
portfolio_id=_PORTFOLIO_ID,
|
||||||
|
property_ids=[_PROPERTY_ID],
|
||||||
|
scenario_ids=[_SCENARIO_ID],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)]
|
||||||
|
if print_table:
|
||||||
|
print("\n" + format_plan_table(plan))
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def run_modelling(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
*,
|
||||||
|
goal_band: str = "C",
|
||||||
|
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||||
|
current_market_value: Optional[float] = None,
|
||||||
|
planning_restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||||
|
solar_insights: Optional[dict[str, Any]] = None,
|
||||||
|
considered_measures: Optional[frozenset[MeasureType]] = None,
|
||||||
|
products: Optional[ProductRepository] = None,
|
||||||
|
scenario: Optional[Scenario] = None,
|
||||||
|
print_table: bool = True,
|
||||||
|
) -> Plan:
|
||||||
|
"""Run ONLY the Modelling stage over ``epc`` with no database — skipping
|
||||||
|
Ingestion and Baseline. Modelling re-scores the EPC itself, so unlike
|
||||||
|
`run_one` this needs no lodged recorded-performance / RHI: it runs on any
|
||||||
|
EPC the calculator can score, which is what you want for inspecting
|
||||||
|
recommendations across an arbitrary EPC dump offline.
|
||||||
|
|
||||||
|
``solar_insights`` is the Property's raw Google Solar ``buildingInsights``
|
||||||
|
JSON (as persisted by ``SolarRepository``); when given, the solar
|
||||||
|
Recommendation Generator sees the dwelling's potential and can offer Solar
|
||||||
|
PV Options (ADR-0026).
|
||||||
|
|
||||||
|
``products`` overrides the Product catalogue source (default: the JSON
|
||||||
|
sample catalogue) — pass a read-only ``ProductPostgresRepository`` to price
|
||||||
|
against the live ``material`` table. ``scenario`` overrides the default
|
||||||
|
Increasing-EPC-to-``goal_band`` Scenario — pass a Scenario read from the DB
|
||||||
|
so the run targets a real ``scenario_id`` (its ``goal_value``/budget drive
|
||||||
|
the Optimiser); the computed Plan is then keyed by that Scenario's id."""
|
||||||
|
scenario_obj = scenario or Scenario(
|
||||||
|
id=_SCENARIO_ID,
|
||||||
|
goal="Increasing EPC",
|
||||||
|
goal_value=goal_band,
|
||||||
|
budget=None,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
scenario_id = scenario_obj.id
|
||||||
|
plan_repo = FakePlanRepository()
|
||||||
|
property_repo = FakePropertyRepo(
|
||||||
|
{
|
||||||
|
_PROPERTY_ID: Property(
|
||||||
|
identity=PropertyIdentity(
|
||||||
|
portfolio_id=_PORTFOLIO_ID,
|
||||||
|
postcode="A0 0AA",
|
||||||
|
address="1 Some Street",
|
||||||
|
uprn=12345,
|
||||||
|
),
|
||||||
|
epc=epc,
|
||||||
|
current_market_value=current_market_value,
|
||||||
|
planning_restrictions=planning_restrictions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
unit = FakeUnitOfWork(
|
||||||
|
property=property_repo,
|
||||||
|
solar=FakeSolarRepo(
|
||||||
|
by_property={_PROPERTY_ID: solar_insights}
|
||||||
|
if solar_insights is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
scenario=FakeScenarioRepository({scenario_id: scenario_obj}),
|
||||||
|
product=products or ProductJsonRepository(catalogue_path),
|
||||||
|
plan=plan_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
ModellingOrchestrator(
|
||||||
|
unit_of_work=lambda: unit,
|
||||||
|
calculator=Sap10Calculator(),
|
||||||
|
fuel_rates=FuelRatesStaticFileRepository(),
|
||||||
|
).run(
|
||||||
|
property_ids=[_PROPERTY_ID],
|
||||||
|
scenario_ids=[scenario_id],
|
||||||
|
portfolio_id=_PORTFOLIO_ID,
|
||||||
|
considered_measures=considered_measures,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = plan_repo.saved[(_PROPERTY_ID, scenario_id)]
|
||||||
|
if print_table:
|
||||||
|
print("\n" + format_plan_table(plan))
|
||||||
|
return plan
|
||||||
96
harness/epc_bulk.py
Normal file
96
harness/epc_bulk.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Read the gov EPC **bulk** export without downloading the 15.7 GB archive.
|
||||||
|
|
||||||
|
The live API's bulk endpoint (`/api/files/domestic/json`) 302-redirects to a
|
||||||
|
temporary S3 ZIP holding one NDJSON member per year (`certificates-<year>.json`,
|
||||||
|
e.g. 2026 is ~559 MB compressed / ~7.6 GB uncompressed). Each NDJSON line is a
|
||||||
|
warehouse record whose per-cert payload is a *stringified* `document` field; the
|
||||||
|
parsed document is the same shape `EpcPropertyDataMapper.from_api_response`
|
||||||
|
already handles (`RdSAP-Schema-21.0.1`, `sap_building_parts`,
|
||||||
|
`energy_rating_current`, ...).
|
||||||
|
|
||||||
|
`RangeFile` exposes the S3 object as a seekable file backed by HTTP range
|
||||||
|
requests, so `zipfile` reads the central directory and streams a single member's
|
||||||
|
deflate stream — and a sampler can stop early after N records, fetching only the
|
||||||
|
compressed prefix it needs. The line-level parsing is pure and unit-tested here;
|
||||||
|
the network wiring lives in `scripts/fetch_epc_bulk_sample.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bulk_line(line: str) -> Optional[tuple[str, dict[str, Any]]]:
|
||||||
|
"""Parse one NDJSON bulk record into `(certificate_number, document)`,
|
||||||
|
unwrapping the stringified `document`. Blank lines return None."""
|
||||||
|
stripped: str = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
record: dict[str, Any] = json.loads(stripped)
|
||||||
|
raw_document: Any = record["document"]
|
||||||
|
document: dict[str, Any] = (
|
||||||
|
json.loads(raw_document) if isinstance(raw_document, str) else raw_document
|
||||||
|
)
|
||||||
|
return record["certificate_number"], document
|
||||||
|
|
||||||
|
|
||||||
|
def is_sap_version(document: dict[str, Any], wanted: str) -> bool:
|
||||||
|
"""True when the document's `sap_version` equals `wanted` (the export carries
|
||||||
|
it as a number, so compare on the string form)."""
|
||||||
|
version: Any = document.get("sap_version")
|
||||||
|
return version is not None and str(version) == wanted
|
||||||
|
|
||||||
|
|
||||||
|
class RangeFile(io.RawIOBase):
|
||||||
|
"""A seekable read-only file over an HTTP object that supports byte ranges
|
||||||
|
(an S3 presigned URL). Each `read` issues a `Range` GET, so `zipfile` can
|
||||||
|
parse the central directory and stream one member without downloading the
|
||||||
|
whole archive."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, size: int) -> None:
|
||||||
|
self._url = url
|
||||||
|
self._size = size
|
||||||
|
self._pos = 0
|
||||||
|
self._client = httpx.Client(timeout=120)
|
||||||
|
# Bytes actually transferred — distinct from `tell()`, which is the
|
||||||
|
# absolute offset (a deep member sits GBs into the archive).
|
||||||
|
self.bytes_read = 0
|
||||||
|
|
||||||
|
def seekable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def readable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
return self._pos
|
||||||
|
|
||||||
|
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
||||||
|
if whence == io.SEEK_SET:
|
||||||
|
self._pos = offset
|
||||||
|
elif whence == io.SEEK_CUR:
|
||||||
|
self._pos += offset
|
||||||
|
elif whence == io.SEEK_END:
|
||||||
|
self._pos = self._size + offset
|
||||||
|
return self._pos
|
||||||
|
|
||||||
|
def read(self, size: Optional[int] = -1) -> bytes:
|
||||||
|
if size is None or size < 0:
|
||||||
|
size = self._size - self._pos
|
||||||
|
if size == 0 or self._pos >= self._size:
|
||||||
|
return b""
|
||||||
|
end: int = min(self._pos + size, self._size) - 1
|
||||||
|
resp = self._client.get(self._url, headers={"Range": f"bytes={self._pos}-{end}"})
|
||||||
|
resp.raise_for_status()
|
||||||
|
data: bytes = resp.content
|
||||||
|
self._pos += len(data)
|
||||||
|
self.bytes_read += len(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
super().close()
|
||||||
68
harness/plan_table.py
Normal file
68
harness/plan_table.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Render a Plan as a plain-text sense-check table.
|
||||||
|
|
||||||
|
The DB-less inspection harness prints this so the modelled package — its SAP
|
||||||
|
band transition, cost, and each Plan Measure's attributed SAP / bill impact —
|
||||||
|
can be eyeballed and debugged by hand. Pure presentation: it reads a `Plan`
|
||||||
|
domain object and returns a string, computing nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
from domain.modelling.plan import Plan
|
||||||
|
|
||||||
|
_KG_PER_TONNE = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _band(sap_continuous: float) -> str:
|
||||||
|
return Epc.from_sap_score(round(sap_continuous)).value
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_gbp(value: Optional[float]) -> str:
|
||||||
|
return "n/a" if value is None else f"{value:+,.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _money(value: Optional[float]) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "n/a"
|
||||||
|
sign = "-" if value < 0 else ""
|
||||||
|
return f"{sign}£{abs(value):,.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_kwh(value: Optional[float]) -> str:
|
||||||
|
return "n/a" if value is None else f"{value:+,.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_plan_table(plan: Plan) -> str:
|
||||||
|
"""A multi-line table: one package summary line, then one line per Plan
|
||||||
|
Measure (signed so positive is an improvement / a saving)."""
|
||||||
|
co2_tonnes_saved: float = plan.co2_savings_kg_per_yr / _KG_PER_TONNE
|
||||||
|
header = (
|
||||||
|
f"Plan SAP {plan.baseline.sap_continuous:.1f} ({_band(plan.baseline.sap_continuous)})"
|
||||||
|
f" -> {plan.post_sap_continuous:.1f} ({plan.post_epc_rating.value})"
|
||||||
|
f" CO2 saved {co2_tonnes_saved:.2f} t/yr"
|
||||||
|
f" cost £{plan.cost_of_works:,.0f} (+£{plan.contingency_cost:,.0f} cont.)"
|
||||||
|
f" bill saved {_money(plan.energy_bill_savings)}/yr"
|
||||||
|
)
|
||||||
|
valuation = plan.valuation
|
||||||
|
valuation_line = f" valuation uplift {valuation.average_pct:+.1%}"
|
||||||
|
if valuation.average_value is not None and valuation.post_retrofit_value is not None:
|
||||||
|
valuation_line += (
|
||||||
|
f" ({_money(valuation.average_value)}"
|
||||||
|
f" -> {_money(valuation.post_retrofit_value)})"
|
||||||
|
)
|
||||||
|
columns = (
|
||||||
|
f" {'measure':<30}{'SAP':>7}{'cost':>10}"
|
||||||
|
f"{'kWh/yr':>10}{'£/yr':>9}"
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
f" {measure.measure_type:<30}"
|
||||||
|
f"{measure.impact.sap_points:>+7.1f}"
|
||||||
|
f"{('£' + format(measure.cost.total, ',.0f')):>10}"
|
||||||
|
f"{_signed_kwh(measure.kwh_savings):>10}"
|
||||||
|
f"{_signed_gbp(measure.energy_cost_savings):>9}"
|
||||||
|
for measure in plan.measures
|
||||||
|
]
|
||||||
|
return "\n".join([header, valuation_line, columns, *rows])
|
||||||
451
harness/report.py
Normal file
451
harness/report.py
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
"""Per-property inspection report over a dump of API-shaped EPC JSONs.
|
||||||
|
|
||||||
|
Builds, for each cert, the three things an inspection wants:
|
||||||
|
|
||||||
|
1. **Calculator error** — the lodged SAP on the cert (`energy_rating_current`)
|
||||||
|
versus our deterministic calculator's un-rounded SAP, flagging divergence
|
||||||
|
beyond half a SAP point. This is the Validation Cohort / shadow-validation
|
||||||
|
idea (ADR-0010/0013): the calculator runs alongside the lodged figure and
|
||||||
|
logs where they disagree.
|
||||||
|
2. **Plan + costings** — the optimised Plan (measures, cost, SAP/band jump,
|
||||||
|
bill & CO₂ savings, valuation uplift). Carried on `PropertyReport.plan`.
|
||||||
|
3. **Measures + their triggers** — each fired measure and the EPC attribute(s)
|
||||||
|
that caused its generator to recommend it.
|
||||||
|
|
||||||
|
The calculator can raise on an un-mapped cert (UnmappedSapCode / UnmappedApiCode)
|
||||||
|
and modelling can raise independently; both are captured per-cert so one bad
|
||||||
|
cert never aborts the report. Run from the worktree root (import trap).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final, Iterable, Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
SapBuildingPart,
|
||||||
|
)
|
||||||
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from domain.modelling.plan import Plan
|
||||||
|
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||||
|
from domain.sap10_calculator.validation.parity_report import (
|
||||||
|
ParityCase,
|
||||||
|
ParityReport,
|
||||||
|
build_parity_report,
|
||||||
|
)
|
||||||
|
from harness.console import DEFAULT_CATALOGUE, run_modelling
|
||||||
|
|
||||||
|
# A lodged-vs-calculated SAP gap beyond this many points is flagged for
|
||||||
|
# investigation (the ADR-0010/0013 shadow-validation design target).
|
||||||
|
SAP_ERROR_THRESHOLD: Final[float] = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasureTrigger:
|
||||||
|
"""One fired measure and the EPC attribute(s) that triggered its generator
|
||||||
|
— the "why" behind the recommendation (e.g. cavity fill fired because
|
||||||
|
`wall_construction == 4` and `wall_insulation_type == 4`)."""
|
||||||
|
|
||||||
|
measure_type: str
|
||||||
|
triggers: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PropertyReport:
|
||||||
|
"""One property's inspection result. `calculator_error` records a raise
|
||||||
|
from mapping or scoring the cert (then the SAP figures are None);
|
||||||
|
`plan_error` records a raise from the Modelling stage (then `plan` is None
|
||||||
|
and no triggers are surfaced)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
lodged_sap: Optional[int]
|
||||||
|
calculated_sap: Optional[float]
|
||||||
|
calculator_error: Optional[str] = None
|
||||||
|
plan: Optional[Plan] = None
|
||||||
|
plan_error: Optional[str] = None
|
||||||
|
measure_triggers: tuple[MeasureTrigger, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sap_error(self) -> Optional[float]:
|
||||||
|
"""Lodged − calculated (positive = the cert rates higher than us).
|
||||||
|
None when either figure is missing."""
|
||||||
|
if self.lodged_sap is None or self.calculated_sap is None:
|
||||||
|
return None
|
||||||
|
return self.lodged_sap - self.calculated_sap
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sap_error_exceeds_threshold(self) -> bool:
|
||||||
|
"""True when |lodged − calculated| > 0.5 — the shadow-validation flag."""
|
||||||
|
error: Optional[float] = self.sap_error
|
||||||
|
return error is not None and abs(error) > SAP_ERROR_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def _main_part(epc: EpcPropertyData) -> SapBuildingPart:
|
||||||
|
"""The MAIN building part the fabric generators read."""
|
||||||
|
return next(
|
||||||
|
part
|
||||||
|
for part in epc.sap_building_parts
|
||||||
|
if part.identifier is BuildingPartIdentifier.MAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]:
|
||||||
|
"""The EPC attribute(s) that caused `measure_type`'s generator to fire.
|
||||||
|
Mirrors each generator's guard so the report can explain the "why":
|
||||||
|
- cavity_wall_insulation : wall_recommendation.py (wall_construction == 4
|
||||||
|
and wall_insulation_type == 4)
|
||||||
|
- loft_insulation : roof_recommendation.py (roof_insulation_thickness == 0)
|
||||||
|
- {solid,suspended}_floor_insulation : floor_recommendation.py
|
||||||
|
(uninsulated floor_insulation_thickness + floor_construction_type)
|
||||||
|
- mechanical_ventilation : ventilation_recommendation.py (no lodged kind)
|
||||||
|
"""
|
||||||
|
main: SapBuildingPart = _main_part(epc)
|
||||||
|
if measure_type == "cavity_wall_insulation":
|
||||||
|
return {
|
||||||
|
"wall_construction": main.wall_construction,
|
||||||
|
"wall_insulation_type": main.wall_insulation_type,
|
||||||
|
}
|
||||||
|
if measure_type == "loft_insulation":
|
||||||
|
return {"roof_insulation_thickness": main.roof_insulation_thickness}
|
||||||
|
if measure_type in ("solid_floor_insulation", "suspended_floor_insulation"):
|
||||||
|
return {
|
||||||
|
"floor_insulation_thickness": main.floor_insulation_thickness,
|
||||||
|
"floor_construction_type": main.floor_construction_type,
|
||||||
|
}
|
||||||
|
if measure_type == "mechanical_ventilation":
|
||||||
|
kind: Optional[str] = (
|
||||||
|
None
|
||||||
|
if epc.sap_ventilation is None
|
||||||
|
else epc.sap_ventilation.mechanical_ventilation_kind
|
||||||
|
)
|
||||||
|
return {"mechanical_ventilation_kind": kind}
|
||||||
|
if measure_type == "low_energy_lighting":
|
||||||
|
# lighting_recommendation.py fires on any non-LED bulb.
|
||||||
|
return {
|
||||||
|
"incandescent_fixed_lighting_bulbs_count": (
|
||||||
|
epc.incandescent_fixed_lighting_bulbs_count
|
||||||
|
),
|
||||||
|
"cfl_fixed_lighting_bulbs_count": epc.cfl_fixed_lighting_bulbs_count,
|
||||||
|
"low_energy_fixed_lighting_bulbs_count": (
|
||||||
|
epc.low_energy_fixed_lighting_bulbs_count
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if measure_type == "high_heat_retention_storage_heaters":
|
||||||
|
# heating_recommendation.py offers HHR storage to an electrically-heated
|
||||||
|
# or off-gas dwelling (translated from legacy is_high_heat_retention_valid).
|
||||||
|
return {
|
||||||
|
"main_fuel_type": epc.sap_heating.main_heating_details[0].main_fuel_type,
|
||||||
|
"sap_main_heating_code": (
|
||||||
|
epc.sap_heating.main_heating_details[0].sap_main_heating_code
|
||||||
|
),
|
||||||
|
"mains_gas": epc.sap_energy_source.mains_gas,
|
||||||
|
}
|
||||||
|
if measure_type == "air_source_heat_pump":
|
||||||
|
# heating_recommendation.py offers ASHP to any non-flat house/bungalow
|
||||||
|
# not already a heat pump (eligibility is physical/planning only).
|
||||||
|
return {
|
||||||
|
"property_type": epc.property_type,
|
||||||
|
"main_heating_category": (
|
||||||
|
epc.sap_heating.main_heating_details[0].main_heating_category
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if measure_type == "gas_boiler_upgrade":
|
||||||
|
# heating_recommendation.py offers a gas condensing boiler to a dwelling
|
||||||
|
# with an existing (non-electric) wet boiler and a mains-gas connection;
|
||||||
|
# the cylinder presence shapes it (combi vs regular + cylinder fixes).
|
||||||
|
return {
|
||||||
|
"sap_main_heating_code": (
|
||||||
|
epc.sap_heating.main_heating_details[0].sap_main_heating_code
|
||||||
|
),
|
||||||
|
"mains_gas": epc.sap_energy_source.mains_gas,
|
||||||
|
"has_hot_water_cylinder": epc.has_hot_water_cylinder,
|
||||||
|
}
|
||||||
|
if measure_type in ("system_tune_up", "system_tune_up_zoned"):
|
||||||
|
# heating_recommendation.py offers a tune-up (keep the boiler, upgrade
|
||||||
|
# the controls + fix the cylinder) to a wet-boiler dwelling whose
|
||||||
|
# existing control can still be improved.
|
||||||
|
return {
|
||||||
|
"sap_main_heating_code": (
|
||||||
|
epc.sap_heating.main_heating_details[0].sap_main_heating_code
|
||||||
|
),
|
||||||
|
"main_heating_control": (
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def build_property_report(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
goal_band: str = "C",
|
||||||
|
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||||
|
) -> PropertyReport:
|
||||||
|
"""Build one `PropertyReport` from an API-shaped EPC JSON file: the
|
||||||
|
lodged-vs-calculated SAP comparison, the optimised Plan, and each fired
|
||||||
|
measure's trigger attributes. A mapping/scoring raise is captured as
|
||||||
|
`calculator_error`; a Modelling raise as `plan_error`; neither propagates."""
|
||||||
|
name: str = path.stem
|
||||||
|
try:
|
||||||
|
epc = EpcPropertyDataMapper.from_api_response(json.loads(path.read_text()))
|
||||||
|
lodged_sap: Optional[int] = epc.energy_rating_current
|
||||||
|
calculated_sap: float = Sap10Calculator().calculate(epc).sap_score_continuous
|
||||||
|
except Exception as error: # noqa: BLE001 — one bad cert must not abort the report
|
||||||
|
return PropertyReport(
|
||||||
|
name=name,
|
||||||
|
lodged_sap=None,
|
||||||
|
calculated_sap=None,
|
||||||
|
calculator_error=f"{type(error).__name__}: {error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
plan: Optional[Plan] = None
|
||||||
|
plan_error: Optional[str] = None
|
||||||
|
measure_triggers: tuple[MeasureTrigger, ...] = ()
|
||||||
|
try:
|
||||||
|
plan = run_modelling(
|
||||||
|
epc,
|
||||||
|
goal_band=goal_band,
|
||||||
|
catalogue_path=catalogue_path,
|
||||||
|
print_table=False,
|
||||||
|
)
|
||||||
|
measure_triggers = tuple(
|
||||||
|
MeasureTrigger(
|
||||||
|
measure_type=measure.measure_type,
|
||||||
|
triggers=_triggers_for(epc, measure.measure_type),
|
||||||
|
)
|
||||||
|
for measure in plan.measures
|
||||||
|
)
|
||||||
|
except Exception as error: # noqa: BLE001 — modelling raise must not abort the report
|
||||||
|
plan_error = f"{type(error).__name__}: {error}"
|
||||||
|
|
||||||
|
return PropertyReport(
|
||||||
|
name=name,
|
||||||
|
lodged_sap=lodged_sap,
|
||||||
|
calculated_sap=calculated_sap,
|
||||||
|
plan=plan,
|
||||||
|
plan_error=plan_error,
|
||||||
|
measure_triggers=measure_triggers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_property_reports(
|
||||||
|
paths: Iterable[Path],
|
||||||
|
*,
|
||||||
|
goal_band: str = "C",
|
||||||
|
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||||
|
) -> list[PropertyReport]:
|
||||||
|
"""Build one `PropertyReport` per path, in order. Errors are captured on
|
||||||
|
each report, never raised, so one bad cert never aborts the cohort."""
|
||||||
|
return [
|
||||||
|
build_property_report(path, goal_band=goal_band, catalogue_path=catalogue_path)
|
||||||
|
for path in paths
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parity_report_for(reports: Iterable[PropertyReport]) -> ParityReport:
|
||||||
|
"""Aggregate the cohort's lodged-vs-calculated SAP into a `ParityReport`
|
||||||
|
(MAE / RMSE / bias / worst-N) for the cohort-level calculator-error view.
|
||||||
|
Certs that failed to map or score (no lodged or calculated SAP) are
|
||||||
|
excluded — they have no parity case to compare. The residual convention is
|
||||||
|
the calculator's own (predicted − actual = calculated − lodged), the
|
||||||
|
negative of each report's `sap_error`."""
|
||||||
|
cases: list[ParityCase] = [
|
||||||
|
ParityCase(
|
||||||
|
certificate_number=report.name,
|
||||||
|
actual_sap=report.lodged_sap,
|
||||||
|
predicted_sap=report.calculated_sap,
|
||||||
|
is_typical=True,
|
||||||
|
)
|
||||||
|
for report in reports
|
||||||
|
if report.lodged_sap is not None and report.calculated_sap is not None
|
||||||
|
]
|
||||||
|
return build_parity_report(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(value: Optional[float]) -> str:
|
||||||
|
return "n/a" if value is None else f"£{value:,.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_triggers(triggers: dict[str, Any]) -> str:
|
||||||
|
"""Render trigger fields as `field=value, field=value` for the "why" line."""
|
||||||
|
return ", ".join(f"{field}={value}" for field, value in triggers.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _calculator_error_section(reports: list[PropertyReport]) -> list[str]:
|
||||||
|
"""Section 1 — the cohort parity stats plus a per-property lodged-vs-
|
||||||
|
calculated table with the |Δ| > 0.5 flag (and any scoring errors)."""
|
||||||
|
parity: ParityReport = parity_report_for(reports)
|
||||||
|
flagged: int = sum(1 for report in reports if report.sap_error_exceeds_threshold)
|
||||||
|
worst: str = (
|
||||||
|
f" · worst Δ {abs(parity.worst_cases[0].predicted_sap - parity.worst_cases[0].actual_sap):.2f}"
|
||||||
|
if parity.worst_cases
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
lines: list[str] = [
|
||||||
|
"## 1. Calculator error — lodged vs calculated SAP",
|
||||||
|
"",
|
||||||
|
f"Cohort parity ({parity.case_count} scorable certs): "
|
||||||
|
f"MAE {parity.global_mae:.2f} · RMSE {parity.global_rmse:.2f} · "
|
||||||
|
f"bias {parity.global_bias:+.2f}{worst}",
|
||||||
|
f"Flagged (|Δ| > {SAP_ERROR_THRESHOLD}): {flagged} of {len(reports)}",
|
||||||
|
"",
|
||||||
|
"| Cert | Lodged | Calculated | Δ (lodged−calc) | Flag |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
for report in reports:
|
||||||
|
if report.calculator_error is not None:
|
||||||
|
lines.append(
|
||||||
|
f"| {report.name} | — | — | — | error: {report.calculator_error} |"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
lodged: str = "—" if report.lodged_sap is None else str(report.lodged_sap)
|
||||||
|
calculated: str = (
|
||||||
|
"—" if report.calculated_sap is None else f"{report.calculated_sap:.2f}"
|
||||||
|
)
|
||||||
|
delta: str = "—" if report.sap_error is None else f"{report.sap_error:+.2f}"
|
||||||
|
flag: str = "⚠ FLAG" if report.sap_error_exceeds_threshold else ""
|
||||||
|
lines.append(
|
||||||
|
f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |"
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_costings_section(reports: list[PropertyReport]) -> list[str]:
|
||||||
|
"""Section 2 — the optimised Plan and its costings, per property."""
|
||||||
|
lines: list[str] = ["## 2. Plans + costings", ""]
|
||||||
|
for report in reports:
|
||||||
|
if report.plan is None:
|
||||||
|
note: str = report.plan_error or report.calculator_error or "not modelled"
|
||||||
|
lines.extend([f"### {report.name}", f"- No Plan — {note}", ""])
|
||||||
|
continue
|
||||||
|
plan: Plan = report.plan
|
||||||
|
measure_types: str = (
|
||||||
|
", ".join(measure.measure_type for measure in plan.measures)
|
||||||
|
if plan.measures
|
||||||
|
else "none (already efficient)"
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"### {report.name}",
|
||||||
|
f"- SAP: {plan.baseline.sap_continuous:.1f} → "
|
||||||
|
f"{plan.post_sap_continuous:.1f} "
|
||||||
|
f"(band {plan.baseline_epc_rating.value} → {plan.post_epc_rating.value})",
|
||||||
|
f"- Measures: {len(plan.measures)} — {measure_types}",
|
||||||
|
f"- Cost of works: {_fmt_money(plan.cost_of_works)} "
|
||||||
|
f"(+ {_fmt_money(plan.contingency_cost)} contingency)",
|
||||||
|
f"- Bill savings: {_fmt_money(plan.energy_bill_savings)}/yr · "
|
||||||
|
f"CO₂ savings: {plan.co2_savings_kg_per_yr:,.0f} kg/yr",
|
||||||
|
f"- Valuation uplift: {plan.valuation.average_pct * 100:+.1f}%",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _measures_triggers_section(reports: list[PropertyReport]) -> list[str]:
|
||||||
|
"""Section 3 — each fired measure and the EPC attribute(s) behind it."""
|
||||||
|
lines: list[str] = ["## 3. Recommended measures + their triggers", ""]
|
||||||
|
for report in reports:
|
||||||
|
if not report.measure_triggers:
|
||||||
|
continue
|
||||||
|
lines.append(f"### {report.name}")
|
||||||
|
lines.extend(
|
||||||
|
f"- **{trigger.measure_type}** — fired because "
|
||||||
|
f"{_fmt_triggers(trigger.triggers)}"
|
||||||
|
for trigger in report.measure_triggers
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def format_report_markdown(reports: list[PropertyReport]) -> str:
|
||||||
|
"""Render the three-section property inspection report as Markdown:
|
||||||
|
(1) calculator error vs lodged SAP, (2) Plans + costings, (3) recommended
|
||||||
|
measures and the attributes that triggered them."""
|
||||||
|
modelled: int = sum(1 for report in reports if report.plan is not None)
|
||||||
|
errored: int = sum(1 for report in reports if report.calculator_error is not None)
|
||||||
|
header: list[str] = [
|
||||||
|
"# Property inspection report",
|
||||||
|
"",
|
||||||
|
f"{len(reports)} properties · {modelled} modelled · "
|
||||||
|
f"{errored} calculator errors",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
sections: list[str] = [
|
||||||
|
*header,
|
||||||
|
*_calculator_error_section(reports),
|
||||||
|
"",
|
||||||
|
*_plan_costings_section(reports),
|
||||||
|
*_measures_triggers_section(reports),
|
||||||
|
]
|
||||||
|
return "\n".join(sections).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
_CSV_HEADER: Final[str] = (
|
||||||
|
"cert,lodged_sap,calculated_sap,sap_error,sap_error_flag,"
|
||||||
|
"baseline_sap,post_sap,baseline_band,post_band,measures,measure_types,"
|
||||||
|
"cost_of_works,contingency,bill_savings,co2_savings,valuation_pct,"
|
||||||
|
"triggers,error"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_cell(value: object) -> str:
|
||||||
|
"""Render a CSV cell, rounding floats and keeping the row comma-safe
|
||||||
|
(commas in any value become ';' so the column count never changes)."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, float):
|
||||||
|
return f"{value:.2f}"
|
||||||
|
return str(value).replace(",", ";")
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_triggers(report: PropertyReport) -> str:
|
||||||
|
"""Flatten the fired measures and their triggers into one comma-safe cell:
|
||||||
|
`type(field=value;field=value)|type(field=value)`."""
|
||||||
|
return "|".join(
|
||||||
|
f"{trigger.measure_type}("
|
||||||
|
+ ";".join(f"{field}={value}" for field, value in trigger.triggers.items())
|
||||||
|
+ ")"
|
||||||
|
for trigger in report.measure_triggers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_report_csv(reports: list[PropertyReport]) -> str:
|
||||||
|
"""Render the report as a flat CSV — one row per property, browsable and
|
||||||
|
sortable in a spreadsheet for a large dump. The calculator-error fields, the
|
||||||
|
Plan headline figures, and the flattened triggers all share one row."""
|
||||||
|
rows: list[str] = [_CSV_HEADER]
|
||||||
|
for report in reports:
|
||||||
|
plan: Optional[Plan] = report.plan
|
||||||
|
cells: list[object] = [
|
||||||
|
report.name,
|
||||||
|
report.lodged_sap,
|
||||||
|
report.calculated_sap,
|
||||||
|
report.sap_error,
|
||||||
|
1 if report.sap_error_exceeds_threshold else 0,
|
||||||
|
None if plan is None else plan.baseline.sap_continuous,
|
||||||
|
None if plan is None else plan.post_sap_continuous,
|
||||||
|
None if plan is None else plan.baseline_epc_rating.value,
|
||||||
|
None if plan is None else plan.post_epc_rating.value,
|
||||||
|
None if plan is None else len(plan.measures),
|
||||||
|
None
|
||||||
|
if plan is None
|
||||||
|
else ";".join(measure.measure_type for measure in plan.measures),
|
||||||
|
None if plan is None else plan.cost_of_works,
|
||||||
|
None if plan is None else plan.contingency_cost,
|
||||||
|
None if plan is None else plan.energy_bill_savings,
|
||||||
|
None if plan is None else plan.co2_savings_kg_per_yr,
|
||||||
|
None if plan is None else plan.valuation.average_pct * 100,
|
||||||
|
_csv_triggers(report),
|
||||||
|
report.calculator_error or report.plan_error,
|
||||||
|
]
|
||||||
|
rows.append(",".join(_csv_cell(cell) for cell in cells))
|
||||||
|
return "\n".join(rows)
|
||||||
21
harness/sample_catalogue.json
Normal file
21
harness/sample_catalogue.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"cavity_wall_insulation": { "unit_cost_per_m2": 18.5 },
|
||||||
|
"loft_insulation": { "unit_cost_per_m2": 12.0 },
|
||||||
|
"sloping_ceiling_insulation": { "unit_cost_per_m2": 40.0 },
|
||||||
|
"flat_roof_insulation": { "unit_cost_per_m2": 60.0 },
|
||||||
|
"suspended_floor_insulation": { "unit_cost_per_m2": 25.0 },
|
||||||
|
"solid_floor_insulation": { "unit_cost_per_m2": 45.0 },
|
||||||
|
"mechanical_ventilation": { "unit_cost_per_m2": 450.0 },
|
||||||
|
"external_wall_insulation": { "unit_cost_per_m2": 100.0 },
|
||||||
|
"internal_wall_insulation": { "unit_cost_per_m2": 90.0 },
|
||||||
|
"double_glazing": { "unit_cost_per_m2": 600.0 },
|
||||||
|
"secondary_glazing": { "unit_cost_per_m2": 510.0 },
|
||||||
|
"low_energy_lighting": { "unit_cost_per_m2": 8.0 },
|
||||||
|
"high_heat_retention_storage_heaters": { "unit_cost_per_m2": 3500.0 },
|
||||||
|
"air_source_heat_pump": { "unit_cost_per_m2": 12000.0 },
|
||||||
|
"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 },
|
||||||
|
"secondary_heating_removal": { "unit_cost_per_m2": 250.0 }
|
||||||
|
}
|
||||||
30
infrastructure/postgres/modelling/__init__.py
Normal file
30
infrastructure/postgres/modelling/__init__.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""SQLModel definitions of the Modelling stage's live persistence tables
|
||||||
|
(ADR-0017 amendment).
|
||||||
|
|
||||||
|
One canonical SQLModel per physical table — `plan`, `recommendation`,
|
||||||
|
`recommendation_materials`, `scenario`, `installed_measure` — replacing the
|
||||||
|
legacy SQLAlchemy `Base` models in `backend/app/db/models/recommendations.py`
|
||||||
|
(now a re-export shim, the `epc_property` pattern). `recommendation` carries
|
||||||
|
`plan_id`; the `plan_recommendations` m2m is retired.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from infrastructure.postgres.modelling.plan_table import PlanModel, PlanType
|
||||||
|
from infrastructure.postgres.modelling.recommendation_table import (
|
||||||
|
RecommendationMaterialModel,
|
||||||
|
RecommendationModel,
|
||||||
|
)
|
||||||
|
from infrastructure.postgres.modelling.scenario_table import ScenarioModel
|
||||||
|
from infrastructure.postgres.modelling.installed_measure_table import (
|
||||||
|
InstalledMeasureModel,
|
||||||
|
MeasureType,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanModel",
|
||||||
|
"PlanType",
|
||||||
|
"RecommendationModel",
|
||||||
|
"RecommendationMaterialModel",
|
||||||
|
"ScenarioModel",
|
||||||
|
"InstalledMeasureModel",
|
||||||
|
"MeasureType",
|
||||||
|
]
|
||||||
73
infrastructure/postgres/modelling/installed_measure_table.py
Normal file
73
infrastructure/postgres/modelling/installed_measure_table.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Column, TIMESTAMP
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class MeasureType(enum.Enum):
|
||||||
|
air_source_heat_pump = "air_source_heat_pump"
|
||||||
|
boiler_upgrade = "boiler_upgrade"
|
||||||
|
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
|
||||||
|
secondary_heating = "secondary_heating"
|
||||||
|
|
||||||
|
roomstat_programmer_trvs = "roomstat_programmer_trvs"
|
||||||
|
time_temperature_zone_control = "time_temperature_zone_control"
|
||||||
|
cylinder_thermostat = "cylinder_thermostat"
|
||||||
|
|
||||||
|
cavity_wall_insulation = "cavity_wall_insulation"
|
||||||
|
extension_cavity_wall_insulation = "extension_cavity_wall_insulation"
|
||||||
|
external_wall_insulation = "external_wall_insulation"
|
||||||
|
internal_wall_insulation = "internal_wall_insulation"
|
||||||
|
loft_insulation = "loft_insulation"
|
||||||
|
flat_roof_insulation = "flat_roof_insulation"
|
||||||
|
room_roof_insulation = "room_roof_insulation"
|
||||||
|
solid_floor_insulation = "solid_floor_insulation"
|
||||||
|
suspended_floor_insulation = "suspended_floor_insulation"
|
||||||
|
|
||||||
|
double_glazing = "double_glazing"
|
||||||
|
secondary_glazing = "secondary_glazing"
|
||||||
|
draught_proofing = "draught_proofing"
|
||||||
|
|
||||||
|
mechanical_ventilation = "mechanical_ventilation"
|
||||||
|
low_energy_lighting = "low_energy_lighting"
|
||||||
|
solar_pv = "solar_pv"
|
||||||
|
hot_water_tank_insulation = "hot_water_tank_insulation"
|
||||||
|
sealing_open_fireplace = "sealing_open_fireplace"
|
||||||
|
|
||||||
|
|
||||||
|
class InstalledMeasureModel(SQLModel, table=True):
|
||||||
|
"""The single SQLModel definition of the live ``installed_measure`` table
|
||||||
|
(ADR-0017 amendment). ``measure_type`` is the ``MeasureType`` Postgres enum;
|
||||||
|
the remaining NOT-NULLs are relaxed to nullable (mirror convention — the
|
||||||
|
live constraints are owned by the Drizzle schema)."""
|
||||||
|
|
||||||
|
__tablename__: ClassVar[str] = "installed_measure" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
uprn: Optional[int] = Field(default=None, index=True)
|
||||||
|
measure_type: MeasureType = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
MeasureType,
|
||||||
|
name="measure_type",
|
||||||
|
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
|
||||||
|
create_type=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
installed_at: Optional[datetime] = Field(
|
||||||
|
default=None, sa_column=Column(TIMESTAMP, nullable=True)
|
||||||
|
)
|
||||||
|
sap_points: Optional[float] = Field(default=None)
|
||||||
|
carbon_savings: Optional[float] = Field(default=None)
|
||||||
|
kwh_savings: Optional[float] = Field(default=None)
|
||||||
|
bill_savings: Optional[float] = Field(default=None)
|
||||||
|
heat_demand_savings: Optional[float] = Field(default=None)
|
||||||
|
source: Optional[str] = Field(default=None)
|
||||||
|
is_active: bool = True
|
||||||
113
infrastructure/postgres/modelling/plan_table.py
Normal file
113
infrastructure/postgres/modelling/plan_table.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Column, TIMESTAMP
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
from domain.modelling.plan import Plan
|
||||||
|
|
||||||
|
# Calculator metrics are in kg CO₂/yr; the live ``plan`` columns are tonnes
|
||||||
|
# (legacy ``emissions_kg / 1000``). Convert on the way in.
|
||||||
|
_KG_PER_TONNE = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
class PlanType(enum.Enum):
|
||||||
|
SOLAR_ECO4 = "solar_eco4"
|
||||||
|
SOLAR_HHRSH_ECO4 = "solar_hhrsh_eco4"
|
||||||
|
EMPTY_CAVITY_ECO = "empty_cavity_eco"
|
||||||
|
PARTIAL_CAVITY_ECO = "partial_cavity_eco"
|
||||||
|
EXTRACTION_ECO = "extraction_eco"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanModel(SQLModel, table=True):
|
||||||
|
"""The single SQLModel definition of the live ``plan`` table (ADR-0017
|
||||||
|
amendment). Full legacy column parity; out-of-cluster references
|
||||||
|
(``portfolio_id`` / ``property_id`` / ``scenario_id``) are plain indexed
|
||||||
|
ints, not FK constraints (mirror convention — the live FKs are owned by the
|
||||||
|
Drizzle schema)."""
|
||||||
|
|
||||||
|
__tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
name: Optional[str] = Field(default="")
|
||||||
|
portfolio_id: int
|
||||||
|
property_id: int = Field(index=True)
|
||||||
|
scenario_id: Optional[int] = Field(default=None)
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
valuation_increase_lower_bound: Optional[float] = Field(default=None)
|
||||||
|
valuation_increase_upper_bound: Optional[float] = Field(default=None)
|
||||||
|
valuation_increase_average: Optional[float] = Field(default=None)
|
||||||
|
|
||||||
|
plan_type: Optional[PlanType] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
PlanType,
|
||||||
|
name="plan_type",
|
||||||
|
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
|
||||||
|
create_type=False,
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
post_sap_points: Optional[float] = Field(default=None)
|
||||||
|
post_epc_rating: Optional[Epc] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SAEnum(Epc, name="epc"), nullable=True),
|
||||||
|
)
|
||||||
|
post_co2_emissions: Optional[float] = Field(default=None) # tonnes/yr
|
||||||
|
co2_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||||
|
post_energy_bill: Optional[float] = Field(default=None) # £/yr
|
||||||
|
energy_bill_savings: Optional[float] = Field(default=None) # £/yr
|
||||||
|
post_energy_consumption: Optional[float] = Field(default=None) # kWh/yr
|
||||||
|
energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr
|
||||||
|
valuation_post_retrofit: Optional[float] = Field(default=None)
|
||||||
|
valuation_increase: Optional[float] = Field(default=None)
|
||||||
|
cost_of_works: Optional[float] = Field(default=None)
|
||||||
|
contingency_cost: Optional[float] = Field(default=None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_domain(
|
||||||
|
cls,
|
||||||
|
plan: Plan,
|
||||||
|
*,
|
||||||
|
property_id: int,
|
||||||
|
scenario_id: int,
|
||||||
|
portfolio_id: int,
|
||||||
|
is_default: bool,
|
||||||
|
) -> "PlanModel":
|
||||||
|
return cls(
|
||||||
|
portfolio_id=portfolio_id,
|
||||||
|
property_id=property_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
is_default=is_default,
|
||||||
|
post_sap_points=plan.post_sap_continuous,
|
||||||
|
post_epc_rating=plan.post_epc_rating,
|
||||||
|
post_co2_emissions=plan.post_retrofit.co2_kg_per_yr / _KG_PER_TONNE,
|
||||||
|
co2_savings=plan.co2_savings_kg_per_yr / _KG_PER_TONNE,
|
||||||
|
cost_of_works=plan.cost_of_works,
|
||||||
|
contingency_cost=plan.contingency_cost,
|
||||||
|
post_energy_bill=plan.post_energy_bill,
|
||||||
|
energy_bill_savings=plan.energy_bill_savings,
|
||||||
|
post_energy_consumption=plan.post_energy_consumption,
|
||||||
|
energy_consumption_savings=plan.energy_consumption_savings,
|
||||||
|
# Valuation Uplift £ forms (NULL when no Property Valuation is known;
|
||||||
|
# the percentage is not persisted on the live plan columns — ADR-0018).
|
||||||
|
valuation_increase_lower_bound=plan.valuation.lower_value,
|
||||||
|
valuation_increase_upper_bound=plan.valuation.upper_value,
|
||||||
|
valuation_increase_average=plan.valuation.average_value,
|
||||||
|
valuation_post_retrofit=plan.valuation.post_retrofit_value,
|
||||||
|
valuation_increase=plan.valuation.average_value,
|
||||||
|
)
|
||||||
125
infrastructure/postgres/modelling/recommendation_table.py
Normal file
125
infrastructure/postgres/modelling/recommendation_table.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Column, ForeignKey, TIMESTAMP
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from datatypes.enums import QuantityUnits
|
||||||
|
from domain.modelling.plan import PlanMeasure
|
||||||
|
|
||||||
|
# Calculator metrics are in kg CO₂/yr; the live ``recommendation`` column is
|
||||||
|
# tonnes (legacy ``emissions_kg / 1000``). Convert on the way in.
|
||||||
|
_KG_PER_TONNE = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationModel(SQLModel, table=True):
|
||||||
|
"""The single SQLModel definition of the live ``recommendation`` table
|
||||||
|
(ADR-0017 amendment) — one row per persisted Plan Measure.
|
||||||
|
|
||||||
|
Carries full legacy column parity (the readers iterate the columns / sum
|
||||||
|
them) **plus** ``plan_id``, the FK that links a measure to its Plan and
|
||||||
|
replaces the retired ``plan_recommendations`` m2m. Out-of-cluster columns
|
||||||
|
(``property_id``) are plain indexed ints, not FK constraints, matching the
|
||||||
|
mirror convention so ``SQLModel.metadata.create_all`` needs no foreign
|
||||||
|
table to exist (the live FKs are owned by the Drizzle schema).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__: ClassVar[str] = "recommendation" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
property_id: int = Field(index=True)
|
||||||
|
plan_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("plan.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
type: str
|
||||||
|
measure_type: Optional[str] = Field(default=None)
|
||||||
|
description: str
|
||||||
|
# The single Product this measure installs — the live ``material_id`` column
|
||||||
|
# that replaces the retired ``recommendation_materials`` BOM (one material
|
||||||
|
# per Plan Measure). Plain int, out-of-cluster (mirror convention).
|
||||||
|
material_id: Optional[int] = Field(default=None, index=True)
|
||||||
|
estimated_cost: Optional[float] = Field(default=None)
|
||||||
|
starting_u_value: Optional[float] = Field(default=None)
|
||||||
|
new_u_value: Optional[float] = Field(default=None)
|
||||||
|
sap_points: Optional[float] = Field(default=None)
|
||||||
|
heat_demand: Optional[float] = Field(default=None)
|
||||||
|
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
|
||||||
|
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||||
|
energy_savings: Optional[float] = Field(default=None)
|
||||||
|
energy_cost_savings: Optional[float] = Field(default=None) # £/yr
|
||||||
|
property_valuation_increase: Optional[float] = Field(default=None)
|
||||||
|
rental_yield_increase: Optional[float] = Field(default=None)
|
||||||
|
total_work_hours: Optional[float] = Field(default=None)
|
||||||
|
labour_days: Optional[float] = Field(default=None)
|
||||||
|
default: bool = True
|
||||||
|
already_installed: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_domain(
|
||||||
|
cls, measure: PlanMeasure, *, property_id: int, plan_id: int
|
||||||
|
) -> "RecommendationModel":
|
||||||
|
return cls(
|
||||||
|
property_id=property_id,
|
||||||
|
plan_id=plan_id,
|
||||||
|
type=measure.measure_type,
|
||||||
|
measure_type=measure.measure_type,
|
||||||
|
description=measure.description,
|
||||||
|
material_id=measure.material_id,
|
||||||
|
estimated_cost=measure.cost.total,
|
||||||
|
sap_points=measure.impact.sap_points,
|
||||||
|
co2_equivalent_savings=(
|
||||||
|
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
|
||||||
|
),
|
||||||
|
kwh_savings=measure.kwh_savings,
|
||||||
|
energy_cost_savings=measure.energy_cost_savings,
|
||||||
|
default=True,
|
||||||
|
already_installed=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationMaterialModel(SQLModel, table=True):
|
||||||
|
"""The live ``recommendation_materials`` table — one row per material used
|
||||||
|
by a Recommendation. ``recommendation_id`` is an intra-cluster FK;
|
||||||
|
``material_id`` is a plain int (out-of-cluster, mirror convention)."""
|
||||||
|
|
||||||
|
__tablename__: ClassVar[str] = "recommendation_materials" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
recommendation_id: int = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
BigInteger, ForeignKey("recommendation.id"), nullable=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
material_id: int = Field(index=True)
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
depth: Optional[float] = Field(default=None)
|
||||||
|
quantity: Optional[float] = Field(default=None)
|
||||||
|
quantity_unit: Optional[QuantityUnits] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
QuantityUnits,
|
||||||
|
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
estimated_cost: Optional[float] = Field(default=None)
|
||||||
92
infrastructure/postgres/modelling/scenario_table.py
Normal file
92
infrastructure/postgres/modelling/scenario_table.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Column, TIMESTAMP
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||||
|
from domain.modelling.scenario import Scenario
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioModel(SQLModel, table=True):
|
||||||
|
"""The single SQLModel definition of the live ``scenario`` table (ADR-0017
|
||||||
|
amendment). Full legacy column parity; ``goal`` is the ``PortfolioGoal``
|
||||||
|
enum (legacy planning branches on it, so it must stay an enum — the stored
|
||||||
|
string is the enum *value*, e.g. ``"Increasing EPC"``).
|
||||||
|
|
||||||
|
Only ``goal`` / ``goal_value`` are required; everything else is nullable
|
||||||
|
(mirror convention — the live NOT-NULLs are owned by the Drizzle schema),
|
||||||
|
so the Modelling stage can construct the thin slice it uses while the legacy
|
||||||
|
writers still supply the full row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__: ClassVar[str] = "scenario" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
name: Optional[str] = Field(default=None)
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(TIMESTAMP, nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
budget: Optional[float] = Field(default=None)
|
||||||
|
portfolio_id: Optional[int] = Field(default=None)
|
||||||
|
housing_type: Optional[str] = Field(default=None)
|
||||||
|
goal: PortfolioGoal = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
PortfolioGoal,
|
||||||
|
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
|
||||||
|
name="goal",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
goal_value: str
|
||||||
|
trigger_file_path: Optional[str] = Field(default=None)
|
||||||
|
already_installed_file_path: Optional[str] = Field(default=None)
|
||||||
|
patches_file_path: Optional[str] = Field(default=None)
|
||||||
|
non_invasive_recommendations_file_path: Optional[str] = Field(default=None)
|
||||||
|
exclusions: Optional[str] = Field(default=None)
|
||||||
|
multi_plan: bool = False
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
# Portfolio-level aggregates stored against the Scenario.
|
||||||
|
cost: Optional[float] = Field(default=None)
|
||||||
|
contingency: Optional[float] = Field(default=None)
|
||||||
|
funding: Optional[float] = Field(default=None)
|
||||||
|
total_work_hours: Optional[float] = Field(default=None)
|
||||||
|
energy_savings: Optional[float] = Field(default=None)
|
||||||
|
co2_equivalent_savings: Optional[float] = Field(default=None)
|
||||||
|
energy_cost_savings: Optional[float] = Field(default=None)
|
||||||
|
epc_breakdown_pre_retrofit: Optional[str] = Field(default=None)
|
||||||
|
epc_breakdown_post_retrofit: Optional[str] = Field(default=None)
|
||||||
|
number_of_properties: Optional[int] = Field(default=None)
|
||||||
|
n_units_to_retrofit: Optional[int] = Field(default=None)
|
||||||
|
co2_per_unit_pre_retrofit: Optional[str] = Field(default=None)
|
||||||
|
co2_per_unit_post_retrofit: Optional[str] = Field(default=None)
|
||||||
|
energy_bill_per_unit_pre_retrofit: Optional[str] = Field(default=None)
|
||||||
|
energy_bill_per_unit_post_retrofit: Optional[str] = Field(default=None)
|
||||||
|
energy_consumption_per_unit_pre_retrofit: Optional[str] = Field(default=None)
|
||||||
|
energy_consumption_per_unit_post_retrofit: Optional[str] = Field(default=None)
|
||||||
|
valuation_improvement_per_unit: Optional[str] = Field(default=None)
|
||||||
|
cost_per_unit: Optional[str] = Field(default=None)
|
||||||
|
cost_per_co2_saved: Optional[str] = Field(default=None)
|
||||||
|
cost_per_sap_point: Optional[str] = Field(default=None)
|
||||||
|
valuation_return_on_investment: Optional[str] = Field(default=None)
|
||||||
|
property_valuation_increase: Optional[float] = Field(default=None)
|
||||||
|
labour_days: Optional[float] = Field(default=None)
|
||||||
|
|
||||||
|
def to_domain(self) -> Scenario:
|
||||||
|
if self.id is None:
|
||||||
|
raise ValueError("scenario row has no id")
|
||||||
|
return Scenario(
|
||||||
|
id=self.id,
|
||||||
|
goal=self.goal.value,
|
||||||
|
goal_value=self.goal_value,
|
||||||
|
budget=self.budget,
|
||||||
|
is_default=self.is_default,
|
||||||
|
)
|
||||||
24
infrastructure/postgres/product_table.py
Normal file
24
infrastructure/postgres/product_table.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialRow(SQLModel, table=True):
|
||||||
|
"""Defensive view of the externally-owned ``material`` catalogue table.
|
||||||
|
|
||||||
|
Declares only the columns the modelling backend reads to price a Measure
|
||||||
|
Option; other columns (r-values, labour breakdowns, etc.) are left off so
|
||||||
|
schema churn elsewhere doesn't ripple in. `total_cost` is the fully-loaded
|
||||||
|
cost per the row's `cost_unit` (GBP/m^2 for fabric measures).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__: ClassVar[str] = "material" # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
id: int = Field(primary_key=True)
|
||||||
|
type: str
|
||||||
|
total_cost: Optional[float] = Field(default=None)
|
||||||
|
cost_unit: Optional[str] = Field(default=None)
|
||||||
|
description: Optional[str] = Field(default=None)
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
|
@ -5,10 +5,22 @@ from typing import ClassVar, Optional, cast
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
from datatypes.epc.domain.epc import Epc
|
from datatypes.epc.domain.epc import Epc
|
||||||
|
from domain.billing.bill import Bill, BillSection, BillSectionCost
|
||||||
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
|
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
|
||||||
from domain.property_baseline.performance import Performance
|
from domain.property_baseline.performance import Performance
|
||||||
from domain.property_baseline.rebaseliner import RebaselineReason
|
from domain.property_baseline.rebaseliner import RebaselineReason
|
||||||
|
|
||||||
|
# Each Bill section's flat-column stem (``bill_{stem}_kwh`` / ``bill_{stem}_cost_gbp``).
|
||||||
|
_SECTION_COLUMN_STEM: dict[BillSection, str] = {
|
||||||
|
BillSection.HEATING: "heating",
|
||||||
|
BillSection.HOT_WATER: "hot_water",
|
||||||
|
BillSection.LIGHTING: "lighting",
|
||||||
|
BillSection.APPLIANCES: "appliances",
|
||||||
|
BillSection.COOKING: "cooking",
|
||||||
|
BillSection.PUMPS_FANS: "pumps_fans",
|
||||||
|
BillSection.COOLING: "cooling",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
||||||
"""The ``property_baseline_performance`` row — one per Property (ADR-0004).
|
"""The ``property_baseline_performance`` row — one per Property (ADR-0004).
|
||||||
|
|
@ -38,11 +50,32 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
||||||
space_heating_kwh: float
|
space_heating_kwh: float
|
||||||
water_heating_kwh: float
|
water_heating_kwh: float
|
||||||
|
|
||||||
|
# Bill Derivation block (ADR-0014 §6). Nullable: all None when no calculator
|
||||||
|
# ran (stub path). The ``bill_`` prefix avoids clashing with the
|
||||||
|
# recorded-demand ``space_heating_kwh`` / ``water_heating_kwh`` above.
|
||||||
|
bill_heating_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_heating_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_hot_water_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_hot_water_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_lighting_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_lighting_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_appliances_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_appliances_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_cooking_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_cooking_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_pumps_fans_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_pumps_fans_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_cooling_kwh: Optional[float] = Field(default=None)
|
||||||
|
bill_cooling_cost_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_standing_charges_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_seg_credit_gbp: Optional[float] = Field(default=None)
|
||||||
|
bill_total_annual_bill_gbp: Optional[float] = Field(default=None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_domain(
|
def from_domain(
|
||||||
cls, baseline: PropertyBaselinePerformance, property_id: int
|
cls, baseline: PropertyBaselinePerformance, property_id: int
|
||||||
) -> "PropertyBaselinePerformanceModel":
|
) -> "PropertyBaselinePerformanceModel":
|
||||||
return cls(
|
model = cls(
|
||||||
property_id=property_id,
|
property_id=property_id,
|
||||||
lodged_sap_score=baseline.lodged.sap_score,
|
lodged_sap_score=baseline.lodged.sap_score,
|
||||||
lodged_epc_band=baseline.lodged.epc_band.value,
|
lodged_epc_band=baseline.lodged.epc_band.value,
|
||||||
|
|
@ -56,6 +89,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
||||||
space_heating_kwh=baseline.space_heating_kwh,
|
space_heating_kwh=baseline.space_heating_kwh,
|
||||||
water_heating_kwh=baseline.water_heating_kwh,
|
water_heating_kwh=baseline.water_heating_kwh,
|
||||||
)
|
)
|
||||||
|
model._write_bill(baseline.bill)
|
||||||
|
return model
|
||||||
|
|
||||||
|
def _write_bill(self, bill: Optional[Bill]) -> None:
|
||||||
|
"""Flatten the Bill onto the ``bill_*`` columns. When ``bill`` is None
|
||||||
|
(no calculator ran) every bill column is left None; a section absent from
|
||||||
|
the mapping leaves its two columns None (None != 0 — it was not billed)."""
|
||||||
|
if bill is None:
|
||||||
|
return
|
||||||
|
for section, stem in _SECTION_COLUMN_STEM.items():
|
||||||
|
cost = bill.sections.get(section)
|
||||||
|
setattr(self, f"bill_{stem}_kwh", cost.kwh if cost is not None else None)
|
||||||
|
setattr(
|
||||||
|
self,
|
||||||
|
f"bill_{stem}_cost_gbp",
|
||||||
|
cost.cost_gbp if cost is not None else None,
|
||||||
|
)
|
||||||
|
self.bill_standing_charges_gbp = bill.standing_charges_gbp
|
||||||
|
self.bill_seg_credit_gbp = bill.seg_credit_gbp
|
||||||
|
self.bill_total_annual_bill_gbp = bill.total_gbp
|
||||||
|
|
||||||
def to_domain(self) -> PropertyBaselinePerformance:
|
def to_domain(self) -> PropertyBaselinePerformance:
|
||||||
return PropertyBaselinePerformance(
|
return PropertyBaselinePerformance(
|
||||||
|
|
@ -74,4 +127,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
||||||
rebaseline_reason=cast(RebaselineReason, self.rebaseline_reason),
|
rebaseline_reason=cast(RebaselineReason, self.rebaseline_reason),
|
||||||
space_heating_kwh=self.space_heating_kwh,
|
space_heating_kwh=self.space_heating_kwh,
|
||||||
water_heating_kwh=self.water_heating_kwh,
|
water_heating_kwh=self.water_heating_kwh,
|
||||||
|
bill=self._read_bill(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_bill(self) -> Optional[Bill]:
|
||||||
|
"""Reconstruct the Bill from the ``bill_*`` columns. The total is the
|
||||||
|
not-None discriminator: a persisted bill always sets it, so its absence
|
||||||
|
means no calculator ran and the bill was None. A section is rebuilt only
|
||||||
|
when its kWh column is not None (paired with its cost)."""
|
||||||
|
if self.bill_total_annual_bill_gbp is None:
|
||||||
|
return None
|
||||||
|
sections: dict[BillSection, BillSectionCost] = {}
|
||||||
|
for section, stem in _SECTION_COLUMN_STEM.items():
|
||||||
|
kwh = cast(Optional[float], getattr(self, f"bill_{stem}_kwh"))
|
||||||
|
if kwh is None:
|
||||||
|
continue
|
||||||
|
cost_gbp = cast(float, getattr(self, f"bill_{stem}_cost_gbp"))
|
||||||
|
sections[section] = BillSectionCost(kwh=kwh, cost_gbp=cost_gbp)
|
||||||
|
return Bill(
|
||||||
|
sections=sections,
|
||||||
|
standing_charges_gbp=cast(float, self.bill_standing_charges_gbp),
|
||||||
|
seg_credit_gbp=cast(float, self.bill_seg_credit_gbp),
|
||||||
|
total_gbp=self.bill_total_annual_bill_gbp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue