diff --git a/.gitignore b/.gitignore index 6cd39e9d..285309dd 100644 --- a/.gitignore +++ b/.gitignore @@ -283,6 +283,10 @@ cache/ *.csv # Tracked reference CSV: SAP enum codes (gov api /api/codes) co-located with EpcPropertyData. !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 # *.pdf **/Chunks/ @@ -298,4 +302,4 @@ pyrightconfig.json backlog/* # Local Claude config files -.claude/* \ No newline at end of file +.claude/*modelling_cohort.csv diff --git a/CONTEXT.md b/CONTEXT.md index 8234aa73..18277d73 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,15 +88,15 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits ### Modelling **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 **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) **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 **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 **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 **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 **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 **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 **First Run**: @@ -194,41 +194,110 @@ _Avoid_: emission factors (ambiguous), CO2 rates ### Outputs **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 -**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**: 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 **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 -**Plan Phase**: -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. -_Avoid_: plan stage, plan step - -**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 +**Plan Measure**: +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_: 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 **Recommendation**: -A single proposed retrofit measure for a Property, with its cost, SAP impact, kWh savings, carbon savings, and parts list. -_Avoid_: suggestion, option +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, 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**: -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 **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. _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 **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. - **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**. -- 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. -- 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. +- 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. - 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 **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**. ## 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." -> **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 - **"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`. - **"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. -- **"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. +- **"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. diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index e82da40f..8f4f9afa 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -23,10 +23,11 @@ from orchestration.ingestion_orchestrator import ( ) from orchestration.modelling_orchestrator import ModellingOrchestrator 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.materials.materials_repository import MaterialsRepository from repositories.postgres_unit_of_work import PostgresUnitOfWork -from repositories.scenario.scenario_repository import ScenarioRepository from repositories.unit_of_work import UnitOfWork 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 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 - stubbed (#1136); its Scenario / Materials ports are seams. + their config is not settled — see ``_source_clients_from_env``. """ return AraFirstRunPipeline( ingestion=IngestionOrchestrator( @@ -85,10 +85,12 @@ def build_first_run_pipeline( # certs, lodged + divergence-logged at/above 10.2; a raise aborts the # batch (ADR-0013 amendment). rebaseliner=CalculatorRebaseliner(Sap10Calculator()), + fuel_rates=FuelRatesStaticFileRepository(), ), modelling=ModellingOrchestrator( - scenario_repo=ScenarioRepository(), - materials_repo=MaterialsRepository(), + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), ), ) diff --git a/backend/Outputs.py b/backend/Outputs.py index 7111e4d3..0a62cf95 100644 --- a/backend/Outputs.py +++ b/backend/Outputs.py @@ -11,7 +11,6 @@ from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcMod from backend.app.db.models.recommendations import ( Recommendation, PlanModel, - PlanRecommendations, ) @@ -124,20 +123,15 @@ class Outputs: return plans_data 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 = ( self.session.query(Recommendation, PlanModel.scenario_id) - .join( - PlanRecommendations, - Recommendation.id == PlanRecommendations.recommendation_id, - ) .join( PlanModel, - PlanModel.id - == PlanRecommendations.plan_id, # Join with Plan to access scenario_id + PlanModel.id == Recommendation.plan_id, # access scenario_id ) .filter( - PlanRecommendations.plan_id.in_(plan_ids), + Recommendation.plan_id.in_(plan_ids), Recommendation.default == True, # Filtering for default recommendations ) .all() diff --git a/backend/app/db/functions/portfolio_functions.py b/backend/app/db/functions/portfolio_functions.py index ae48afed..c9b15cd2 100644 --- a/backend/app/db/functions/portfolio_functions.py +++ b/backend/app/db/functions/portfolio_functions.py @@ -1,7 +1,6 @@ from sqlalchemy import func from backend.app.db.models.recommendations import ( PlanModel, - PlanRecommendations, Recommendation, ScenarioModel, ) @@ -26,11 +25,7 @@ def aggregate_portfolio_recommendations( ), func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"), ) - .join( - PlanRecommendations, - PlanRecommendations.recommendation_id == Recommendation.id, - ) - .join(PlanModel, PlanModel.id == PlanRecommendations.plan_id) + .join(PlanModel, PlanModel.id == Recommendation.plan_id) .filter( PlanModel.portfolio_id == portfolio_id, PlanModel.scenario_id == scenario_id, diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index ed3fb435..79168d71 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -17,7 +17,6 @@ from backend.app.db.models.recommendations import ( PlanModel, Recommendation, RecommendationMaterials, - PlanRecommendations, ScenarioModel, ) from backend.app.db.models.portfolio import PropertyModel @@ -236,23 +235,6 @@ def create_recommendation_material( 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( session: Session, recommendations_to_upload, property_id, new_plan_id ): @@ -261,6 +243,7 @@ def upload_recommendations( recommendations_data = [ { "property_id": property_id, + "plan_id": new_plan_id, "type": rec["type"], "measure_type": rec["measure_type"], "description": rec["description"], @@ -319,10 +302,6 @@ def upload_recommendations( # flush the changes to get the newly created IDs session.flush() - create_plan_recommendations( - session, plan_id=new_plan_id, recommendation_ids=uploaded_recommendation_ids - ) - # Commit the transaction session.commit() @@ -347,12 +326,12 @@ def bulk_upload_recommendations_and_materials( # --------------------------------------------------------- recommendation_rows = [] parts_by_index = [] - plan_ids_by_index = [] for rec in recommendation_payload: recommendation_rows.append( { "property_id": rec["property_id"], + "plan_id": rec["plan_id"], "type": rec["type"], "measure_type": rec["measure_type"], "description": rec["description"], @@ -373,7 +352,6 @@ def bulk_upload_recommendations_and_materials( ) parts_by_index.append(rec["parts"]) - plan_ids_by_index.append(rec["plan_id"]) # --------------------------------------------------------- # 2. Insert recommendations and get IDs @@ -405,18 +383,8 @@ def bulk_upload_recommendations_and_materials( if materials_rows: session.execute(insert(RecommendationMaterials).values(materials_rows)) - # --------------------------------------------------------- - # 4. Insert plan ↔ recommendation links - # --------------------------------------------------------- - 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)) + # Recommendations carry their plan via recommendation.plan_id (set above) — + # the plan_recommendations m2m is retired (ADR-0017 amendment). def chunked(iterable, size=100): @@ -455,21 +423,6 @@ def delete_property_batch(session: Session, property_ids: list[int]): 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 # -------------------------------------------------- diff --git a/backend/app/db/functions/tests/test_portfolio_functions.py b/backend/app/db/functions/tests/test_portfolio_functions.py new file mode 100644 index 00000000..957f6663 --- /dev/null +++ b/backend/app/db/functions/tests/test_portfolio_functions.py @@ -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 diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index 452c8d36..58aa2535 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -18,6 +18,11 @@ from backend.app.db.models.users import UserModel # noqa from backend.app.db.models.materials import MaterialType 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): SCOPING = "scoping" @@ -32,14 +37,6 @@ class PortfolioStatus(enum.Enum): 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): __tablename__ = "portfolio" id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 096cc1de..608fe346 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -1,289 +1,41 @@ -import enum -from typing import Iterable, List, NamedTuple, Optional, Type -from sqlalchemy import ( - Column, - BigInteger, - String, - Float, - Boolean, - TIMESTAMP, - ForeignKey, - Enum, +"""Re-export shim (ADR-0017 amendment). + +The Modelling-stage persistence models — `plan`, `recommendation`, +`recommendation_materials`, `scenario`, `installed_measure` — moved to +`infrastructure/postgres/modelling/` as single SQLModel definitions (the +`epc_property` pattern). This module re-exports them under their legacy names so +the dying `backend/` callers keep working; new code imports from +`infrastructure.postgres.modelling` directly. The `plan_recommendations` m2m is +retired — measures link to their Plan via `recommendation.plan_id`. +""" + +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 -from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel -from backend.app.db.models.materials import Material -from datatypes.enums import QuantityUnits -from datatypes.epc.domain.epc import Epc +# Legacy names → the single SQLModel definitions now in +# `infrastructure/postgres/modelling/`. +Recommendation = RecommendationModel +RecommendationMaterials = RecommendationMaterialModel +PlanTypeEnum = PlanType +InstalledMeasure = InstalledMeasureModel - -def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]: - return [e.value for e in enum_cls] - - -class Recommendation(Base): - __tablename__ = "recommendation" - - 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] +__all__ = [ + "PlanModel", + "ScenarioModel", + "Recommendation", + "RecommendationMaterials", + "InstalledMeasure", + "PlanTypeEnum", + "PlanPersistence", +] class PlanPersistence(NamedTuple): diff --git a/backend/export/property_scenarios/db_functions.py b/backend/export/property_scenarios/db_functions.py index e9b3d7e3..d18b97f6 100644 --- a/backend/export/property_scenarios/db_functions.py +++ b/backend/export/property_scenarios/db_functions.py @@ -8,7 +8,6 @@ from collections import defaultdict from backend.app.db.models.recommendations import ( Recommendation, PlanModel, - PlanRecommendations, RecommendationMaterials, ) from backend.app.db.models.portfolio import ( @@ -157,13 +156,9 @@ class DbMethods: stmt = ( select(Recommendation, PlanModel.scenario_id, PlanModel.name) - .join( - PlanRecommendations, - Recommendation.id == PlanRecommendations.recommendation_id, - ) - .join(PlanModel, PlanModel.id == PlanRecommendations.plan_id) + .join(PlanModel, PlanModel.id == Recommendation.plan_id) .where( - PlanRecommendations.plan_id.in_(plan_ids), + Recommendation.plan_id.in_(plan_ids), Recommendation.default.is_(True), Recommendation.already_installed.is_(False), ) diff --git a/backend/export/tests/conftest.py b/backend/export/tests/conftest.py index 80344c5e..fc73ae2c 100644 --- a/backend/export/tests/conftest.py +++ b/backend/export/tests/conftest.py @@ -25,17 +25,23 @@ def engine(postgresql): engine = create_engine(connection_string) - # Create tables once per test session - Base.metadata.create_all(engine) + # Create tables once per test session. SQLModel first: the Modelling tables + # (`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) + 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 # tests have completed yield engine - # Clean-up after entire test session - SQLModel.metadata.drop_all(engine) - Base.metadata.drop_all(engine) + # The `postgresql` fixture is function-scoped — a fresh, throwaway database + # per test — so an explicit drop_all is redundant. We skip it: the `epc` + # 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() diff --git a/backend/export/tests/test_export.py b/backend/export/tests/test_export.py index 42177749..9d84fa4a 100644 --- a/backend/export/tests/test_export.py +++ b/backend/export/tests/test_export.py @@ -16,7 +16,6 @@ from backend.app.db.models.portfolio import ( from backend.app.db.models.recommendations import ( PlanModel, Recommendation, - PlanRecommendations, RecommendationMaterials, ) from backend.app.db.models.materials import Material @@ -171,13 +170,17 @@ def test_default_export_integration(db_session): # 5) Insert recommendation # ---------------------------------------- + rec_to_plan = dict( + zip(plan_recs_df["recommendation_id"], plan_recs_df["plan_id"]) + ) recs = [ Recommendation( + plan_id=rec_to_plan.get(row["id"]), **{ col: row[col] 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() ] @@ -185,18 +188,9 @@ def test_default_export_integration(db_session): db_session.bulk_save_objects(recs) db_session.flush() - # ---------------------------------------- - # 6) Insert PlanRecommendations - # ---------------------------------------- - 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) + # Recommendations are linked to their plan by recommendation.plan_id (set + # above from plan_recs_df) — the plan_recommendations m2m is retired + # (ADR-0017 amendment). db_session.commit() 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" + rec_to_plan = dict(zip(plan_recs_df.recommendation_id, plan_recs_df.plan_id)) for row in recommendations_df.itertuples(index=False): rec = Recommendation( id=row.id, + plan_id=rec_to_plan.get(row.id), property_id=row.property_id, measure_type=row.measure_type, estimated_cost=row.estimated_cost, @@ -622,17 +618,8 @@ def test_solar_with_battery_example(db_session): db_session.add(rec) db_session.flush() - # ------------------------------------------------- - # Link Plan -> Recommendation - # ------------------------------------------------- - 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() + # Plan ↔ Recommendation link is recommendation.plan_id (set above) — the + # plan_recommendations m2m is retired (ADR-0017 amendment). # ------------------------------------------------- # Insert Material (includes_battery=True) diff --git a/datatypes/epc/domain/epc.py b/datatypes/epc/domain/epc.py index b715be82..ae4fd824 100644 --- a/datatypes/epc/domain/epc.py +++ b/datatypes/epc/domain/epc.py @@ -31,3 +31,18 @@ class Epc(Enum): if score >= 21: return cls.F 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] diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 4b45e598..4d158c7e 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -601,6 +601,16 @@ class RenewableHeatIncentive: @dataclass 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 dwelling_type: str # TODO: make enum? inspection_date: date diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 04f39ba6..bd6d61e9 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4188,6 +4188,21 @@ def _elmhurst_bp_roof_type( 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( w: ElmhurstWindow, survey: ElmhurstSiteNotes, ) -> bool: @@ -4216,14 +4231,19 @@ def _is_elmhurst_roof_window( _ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS ): return True - # U > 3.0 backstop — Elmhurst routes high-U "Double pre 2002" units - # through the worksheet's (27a) Roof Windows line regardless of the - # lodged "External wall" location, which is a §11 lodging artifact - # (cert 000516's W6 is lodged "External wall" yet scored via (27a)). - # The location string is therefore NOT a reliable vertical signal: - # all six of 000516's §11 rows read "External wall", and only U - # separates the five vertical (2.8) panes from the one rooflight - # (3.1). Matching the worksheet means trusting U here, not location. + # U > 3.0 backstop, gated on the BP having a room-in-roof. Elmhurst routes a + # high-U "Double pre 2002" unit through the worksheet's (27a) Roof Windows + # line regardless of its lodged "External wall" location — but ONLY where the + # BP has a room-in-roof whose sloping ceiling can host an inclined rooflight. + # cert 000516's W6 (Double pre 2002 / Wood / 6 mm / U 3.10) is lodged + # "External wall" yet scored via (27a): its Main BP has a "Room in roof type + # 1". cert 001431 lodges a BYTE-IDENTICAL window row (same glazing, frame, + # 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 @@ -4983,6 +5003,16 @@ _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = { "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- # 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.""" if not cylinder_present or cylinder_insulation_label is 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) if code is None: raise UnmappedElmhurstLabel("cylinder_insulation", cylinder_insulation_label) diff --git a/docs/HANDOVER_API_FETCH_AND_REPORT.md b/docs/HANDOVER_API_FETCH_AND_REPORT.md new file mode 100644 index 00000000..14f78655 --- /dev/null +++ b/docs/HANDOVER_API_FETCH_AND_REPORT.md @@ -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 -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 `; 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()`). 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. diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md new file mode 100644 index 00000000..83fa2aa8 --- /dev/null +++ b/docs/HANDOVER_MODELLING.md @@ -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 -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 `; 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. diff --git a/docs/HANDOVER_NEXT_PHASE_PROMPT.md b/docs/HANDOVER_NEXT_PHASE_PROMPT.md new file mode 100644 index 00000000..e8b7512f --- /dev/null +++ b/docs/HANDOVER_NEXT_PHASE_PROMPT.md @@ -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 -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 `; 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). diff --git a/docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md b/docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md index 6fc5b4cf..1f0c0f1a 100644 --- a/docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md +++ b/docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md @@ -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: []` 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. -- The optimiser must accept rolling-state input rather than only baseline state — a generalisation of today's single-shot pass. -- 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. -- 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. +- A **Scenario** carries one set of permitted measure types (no ordered phases). +- 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. +- The terms **Scenario Phase**, **Plan Phase**, and **Rolled-over Options** are removed from `CONTEXT.md`. + +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: []` with all measure types allowed — i.e. exactly the single-phase product we are now building directly, without the phase machinery. diff --git a/docs/adr/0009-deterministic-sap-calculator.md b/docs/adr/0009-deterministic-sap-calculator.md index a41456e5..821944f7 100644 --- a/docs/adr/0009-deterministic-sap-calculator.md +++ b/docs/adr/0009-deterministic-sap-calculator.md @@ -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. | | 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. | -| 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 diff --git a/docs/adr/0013-calculator-produces-effective-performance-shadow-first.md b/docs/adr/0013-calculator-produces-effective-performance-shadow-first.md index 6dd9a044..7012194c 100644 --- a/docs/adr/0013-calculator-produces-effective-performance-shadow-first.md +++ b/docs/adr/0013-calculator-produces-effective-performance-shadow-first.md @@ -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 *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. + +## 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). diff --git a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md index cf01b02a..0d195f77 100644 --- a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md +++ b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md @@ -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. - **JSON `bill_breakdown` block** — rejected: end-uses are fixed-cardinality, so flat columns are 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. diff --git a/docs/adr/0015-mappers-own-cert-normalization.md b/docs/adr/0015-mappers-own-cert-normalization.md new file mode 100644 index 00000000..3ce14694 --- /dev/null +++ b/docs/adr/0015-mappers-own-cert-normalization.md @@ -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. diff --git a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md new file mode 100644 index 00000000..121b91ef --- /dev/null +++ b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md @@ -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. diff --git a/docs/adr/0017-plan-persistence-evolve-live-tables.md b/docs/adr/0017-plan-persistence-evolve-live-tables.md new file mode 100644 index 00000000..44fd6ef8 --- /dev/null +++ b/docs/adr/0017-plan-persistence-evolve-live-tables.md @@ -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. diff --git a/docs/adr/0018-valuation-uplift-percentage-primary.md b/docs/adr/0018-valuation-uplift-percentage-primary.md new file mode 100644 index 00000000..b6328adb --- /dev/null +++ b/docs/adr/0018-valuation-uplift-percentage-primary.md @@ -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. diff --git a/docs/adr/0019-wall-insulation-eligibility.md b/docs/adr/0019-wall-insulation-eligibility.md new file mode 100644 index 00000000..e77eebee --- /dev/null +++ b/docs/adr/0019-wall-insulation-eligibility.md @@ -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. diff --git a/docs/adr/0020-conservation-status-as-property-attributes.md b/docs/adr/0020-conservation-status-as-property-attributes.md new file mode 100644 index 00000000..fd966170 --- /dev/null +++ b/docs/adr/0020-conservation-status-as-property-attributes.md @@ -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. diff --git a/docs/adr/0021-roof-insulation-eligibility.md b/docs/adr/0021-roof-insulation-eligibility.md new file mode 100644 index 00000000..e6c9c3ed --- /dev/null +++ b/docs/adr/0021-roof-insulation-eligibility.md @@ -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. diff --git a/docs/adr/0022-glazing-eligibility-and-overlay.md b/docs/adr/0022-glazing-eligibility-and-overlay.md new file mode 100644 index 00000000..47a216d6 --- /dev/null +++ b/docs/adr/0022-glazing-eligibility-and-overlay.md @@ -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. diff --git a/docs/adr/0023-lighting-eligibility-and-overlay.md b/docs/adr/0023-lighting-eligibility-and-overlay.md new file mode 100644 index 00000000..68030e3d --- /dev/null +++ b/docs/adr/0023-lighting-eligibility-and-overlay.md @@ -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. diff --git a/docs/adr/0024-heating-eligibility-and-overlay.md b/docs/adr/0024-heating-eligibility-and-overlay.md new file mode 100644 index 00000000..baac2f01 --- /dev/null +++ b/docs/adr/0024-heating-eligibility-and-overlay.md @@ -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. diff --git a/docs/adr/0025-ashp-bundle-costing.md b/docs/adr/0025-ashp-bundle-costing.md new file mode 100644 index 00000000..75c618de --- /dev/null +++ b/docs/adr/0025-ashp-bundle-costing.md @@ -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. diff --git a/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md b/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md new file mode 100644 index 00000000..eacfc78f --- /dev/null +++ b/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md @@ -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). diff --git a/docs/adr/0027-boiler-and-tune-up-costing.md b/docs/adr/0027-boiler-and-tune-up-costing.md new file mode 100644 index 00000000..21fae1a4 --- /dev/null +++ b/docs/adr/0027-boiler-and-tune-up-costing.md @@ -0,0 +1,56 @@ +# Boiler-Upgrade & System-Tune-Up Costing — Composite, Component-Mirrored + +The boiler-upgrade and system-tune-up Options (the Heating & Hot Water expansion to ADR-0024) currently carry flat placeholder catalogue scalars (boiler £3000; tune-up £500/£900). Like the ASHP bundle (ADR-0025), their real cost varies per dwelling — a tune-up that only fits TRVs on a dwelling that already has a programmer + room thermostat costs a fraction of a full controls-from-scratch job, and the Optimiser picks least-cost-to-band, so a flat number mis-ranks them against each other and against ASHP/HHRSH. We therefore **compose each cost per dwelling**, mirroring ADR-0025's architecture and reusing the (research-validated) legacy `recommendations/Costs.py` figures as the rate source. + +## Decision + +**The cost mirrors the Simulation Overlay component-for-component.** The overlay is the source of truth for what is installed; the cost prices exactly those components, using the *same conditional predicates* the overlay uses to decide what to write. This guarantees cost↔overlay consistency — we never charge for a thermostat the overlay didn't add, nor omit controls the boiler did upgrade — and keeps "one Option = one composite Plan line" honest. + +``` +tune-up (standard) = standard_controls + cylinder_fixes +tune-up (zone) = zone_controls + cylinder_fixes +boiler upgrade = boiler(£3200 all-in) + + standard_controls (only when the boiler fired a controls upgrade) + + cylinder_fixes (only when a cylinder is present) +``` + +`standard_controls` and `cylinder_fixes` are **shared** between boiler and tune-up. + +- **standard_controls = incremental** — price only the parts missing to reach SAP code 2106, read from a Table 4e Group-1 → `(has_programmer, has_room_thermostat, has_TRVs)` feature map: `programmer £120` + `room_thermostat £150` + `TRV £35 × radiators`, each charged only when absent. (A 7-radiator dwelling from "no controls" = £515, matching the Energy Saving Trust's ~£550 quoted figure; the £35 TRV is the *marginal* in-bundle rate, since the drain-down labour is shared once.) +- **zone_controls = full smart-zone kit, not incremental** — `smart_thermostat hub £205` + `smart_TRV £50 × radiators`. A smart zone system replaces whatever's there, and **the smart TRV is itself the per-room sensor** — so there is **no** separate per-room temperature-sensor line (the legacy double-counted it; corrected after research). +- **cylinder_fixes** — `jacket £50` (when under-insulated) + `cylinder_thermostat £150` (when absent), each conditional, and only when a cylinder exists. +- **boiler = £3200 all-in** (condensing gas boiler, flue + labour included). **No system-change extras** (radiators / separate flue / pipework): boiler-upgrade eligibility already requires an *existing wet boiler* (SAP code 101-141 / 151-161, electric 191-196 excluded, mains gas present), so every upgrade is a like-for-like swap that reuses the existing wet distribution — the dry→wet conversion lines can never fire under the gate, so they are not modelled. + +**`Products` owns the catalogue math; the modelling layer owns the dwelling interpretation** (ADR-0025 unchanged). New `Products.boiler_bundle_cost(BoilerCostInputs)` and `tune_up_cost(TuneUpCostInputs)` sum the applicable lines from a `HeatingRates` table (data, loaded from `heating_rates.json`) into a `Cost`, staying free of `EpcPropertyData` / `Sap10Calculator`. The modelling-layer interpreters read the dwelling (radiator count via the existing `_radiator_count` proxy; existing control features from the SAP control code; cylinder insulation/thermostat state) into those typed inputs. Per-radiator items (TRVs, smart TRVs) scale on `_radiator_count`; everything else is fixed per dwelling. + +**Fully-loaded totals, separate contingency** (Model B — the ADR-0025 shape, *not* the legacy VAT/preliminaries engine). The legacy per-item £ figures are reused as fully-loaded rates and summed; one `contingency_rate` is applied on top (boiler 0.26; both tune-ups 0.10, per legacy `Costs.CONTINGENCIES`). The legacy's separate VAT-on-labour / preliminaries arithmetic is *not* reproduced — the cost exists for Optimiser *ranking*, where those scale near-uniformly and don't change the order, and the per-item figures are themselves estimates, so sub-£100 tax precision is false fidelity. + +**Rate table (8 lines, research-validated 2025/26 UK installed figures):** + +| line | £ | driver | +|---|---|---| +| programmer | 120 | fixed | +| room_thermostat | 150 | fixed | +| trv_per_radiator | 35 | per radiator | +| zone_hub (smart thermostat) | 205 | fixed | +| smart_trv_per_radiator | 50 | per radiator | +| cylinder_thermostat | 150 | fixed | +| cylinder_jacket | 50 | fixed | +| boiler | 3200 | fixed | + +## Considered options + +- **Replicate the legacy VAT/labour/preliminaries arithmetic exactly.** Rejected: re-introduces a tax engine ADR-0025 deliberately avoided; false precision over rough estimates; ranking is insensitive to near-uniform tax/preliminaries. +- **Flat catalogue scalar (the placeholder).** Rejected: a tune-up's cost varies ~£200–£900 with what's already fitted and the radiator count; a flat number mis-ranks it against the boiler upgrade and ASHP. +- **Price controls as a flat job (no per-radiator term).** Rejected after research: TRVs and smart TRVs are genuinely per-radiator; a flat job over- or under-charges with dwelling size, and the per-radiator marginal rate is what makes the bundle sum match the EST reference. +- **Keep the legacy zone-control build-up (per-room sensor + per-radiator smart TRV).** Rejected after research: the smart TRV *is* the room sensor in real multi-zone systems (Tado/Wiser/evohome); the separate sensor line double-counts. +- **Keep the dry→wet system-change extras for robustness.** Rejected: dead code under the eligibility gate (existing wet boiler required); ADR-0025 likewise declined to price extras the data path can't reach. +- **Boiler cost stays boiler-only; controls/cylinder priced as separate measures.** Rejected: they're folded into the one Option's overlay, so pricing them separately would split one Plan line and risk double-charging against a tune-up. + +## Consequences + +- New `HeatingRates` + `heating_rates.json`, `BoilerCostInputs` / `TuneUpCostInputs`, and `Products.boiler_bundle_cost` / `tune_up_cost`; the boiler/tune-up Options swap their flat scalar for the composite (the catalogue row is still read for its `id`, as ASHP does). Contingencies for the two tune-up types drop 0.15 → 0.10 to match the legacy reference. +- A new **Table 4e Group-1 control-feature map** lives in the modelling interpreter — the single place that reads "what controls does this dwelling already have" from a SAP code. An unrecognised/absent control code defaults to "no parts present" (charge the full standard kit) — conservative, and the standard option is only offered when the control is improvable anyway. +- The figures are research-validated installed UK estimates, not a contractor rate sheet (unlike the ASHP Southern Housing lines). When a real boiler/controls rate sheet arrives it replaces `heating_rates.json` with no code change — the rates are data. +- Cost↔overlay consistency is structural: both read the same cylinder/control predicates, so they cannot drift (e.g. the overlay adding a thermostat the cost forgot). +- All dwelling reads (radiator count, existing control parts, cylinder state) are whole-dwelling proxies standing in for a survey, documented at each call site. diff --git a/docs/adr/0028-secondary-heating-removal.md b/docs/adr/0028-secondary-heating-removal.md new file mode 100644 index 00000000..3ccd1f78 --- /dev/null +++ b/docs/adr/0028-secondary-heating-removal.md @@ -0,0 +1,33 @@ +# Secondary Heating Removal — standalone recommendation, eligibility, overlay, costing + +We model "removal of secondary heating" — stripping a dwelling's lodged secondary heating system so the main system serves 100% of space heating — as a **standalone, co-selectable Recommendation** (`MeasureType.SECONDARY_HEATING_REMOVAL`), built API-inputs-first. This records the four load-bearing, non-obvious choices made designing it. + +## Status + +Accepted. + +## Decisions + +### 1. Standalone Recommendation, not an Option in the Heating & Hot Water rec + +The heating expansion (ADR-0024) consolidated heating into one rec whose Options are **mutually-exclusive** (the Optimiser picks ≤1) because they are whole-system replacements you would never combine. Secondary-heating removal is different in kind: it is independent of, and freely combinable with, a tune-up or a boiler upgrade (you can remove a panel heater *and* upgrade the controls). Making it an Option would falsely force mutual exclusivity with the partial heating upgrades. As a standalone rec it composes additively like loft/cavity/glazing. + +The redundancy risk with a whole-system bundle is self-resolving: an ASHP is calculator-category 4 → secondary fraction is already 0.00, so removal adds **zero marginal SAP** on top of it, and a SAP-maximising Optimiser never pays for a zero-gain measure. The two overlays touch disjoint fields, so there is no double-*SAP*-counting either. + +### 2. Eligibility is physical only — offer iff a secondary is lodged; no effectiveness gate + +Offered **iff `sap_heating.secondary_heating_type is not None`** (a surveyor-lodged secondary exists to remove). Per ADR-0024's principle — *eligibility encodes only physical/planning installability; the Optimiser owns the economics* — we deliberately do **not** gate out the cases where removal cannot move SAP. + +The surprising case: on an **electric-storage main** (SAP codes 401–407/409/421), RdSAP §A.2.2 **forces a default secondary (693, portable electric) back** even after the lodged one is removed, so removal is a guaranteed zero-SAP no-op. That is an *effectiveness* fact, not an installability one — so eligibility still offers it, and the Optimiser de-selects it (zero gain, real cost). This is why our only example cert (001431, main 402) shows F35→F35 unchanged, matching Elmhurst exactly. + +### 3. A dedicated `SecondaryHeatingOverlay` that *clears* fields + +Every other Simulation Overlay obeys "a `None` field means leave the baseline unchanged" and writes **target states**. Removal is the opposite — it must set `secondary_heating_type` and `secondary_fuel_type` *to* `None`, which that convention structurally cannot express. Rather than wart the absolute-target-state `HeatingOverlay` with a remove-flag, removal gets its own minimal overlay surface (`SecondaryHeatingOverlay`, with an explicit `remove` flag) + `EpcSimulation` slot + `_fold_secondary_heating`, mirroring the one-overlay-per-measure-family pattern. It is the one overlay that sets a value to *absent*. + +### 4. Flat per-dwelling decommission cost, not room-scaled + +Legacy `recommendations/SecondaryHeating.py` scaled cost by a room count (`habitable − heated`). We price a **flat per-dwelling scalar (~£250, contingency 0.25)** instead. Two reasons: (a) the EPC lodges a *single* secondary system with **no heater count**, so the legacy room proxy is unfounded; (b) because RdSAP only records a secondary when a **fixed** emitter is present (portable plug-in heaters are ignored), a lodged secondary is by definition a fixed unit — its removal is a real but roughly-fixed job (one electrician visit to disconnect/isolate a hard-wired panel/convector/radiant heater + a blanking plate + localised making-good of the wall). The contingency absorbs the unknown count / hard-wire status / repaint extent. No composite `Products` machinery and no `rates.json` entry — a future data-only upgrade if a real per-unit rate sheet arrives. + +## Validation + +The before/after example (cert 001431, main 402) is a forced-secondary delta-0 case, and shares the boiler-fixtures' Summary-path roof-fidelity gap, so the cascade pin asserts the **field-delta** (`score(before + remove-overlay) == score(before)` at delta 0), proving the overlay clears the fields and the calculator correctly re-forces the §A.2.2 default. A **synthetic unit test** recasts 001431's main to a non-forced gas-boiler code and asserts removal yields a *positive* SAP delta (Table 11 fraction → 0), exercising the value path without a second real cert. diff --git a/docs/migrations/property-baseline-performance-table.md b/docs/migrations/property-baseline-performance-table.md index d4846843..0aebba83 100644 --- a/docs/migrations/property-baseline-performance-table.md +++ b/docs/migrations/property-baseline-performance-table.md @@ -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 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 | |---|---|---| -| `fuel_rates_period` | text | which Fuel Rates snapshot priced this bill (e.g. `"2026-04 to 2026-06"`) — provenance | -| `heating_kwh` | float | delivered fuel kWh (main + secondary heating) | -| `heating_cost_gbp` | float | priced at the heating fuel's current rate | -| `hot_water_kwh` | float | | -| `hot_water_cost_gbp` | float | | -| `lighting_kwh` | float | | -| `lighting_cost_gbp` | float | | -| `appliances_kwh` | float | unregulated load — **0 until the appliances/cooking fields land on `SapResult`** (ADR-0014 TODO) | -| `appliances_cost_gbp` | float | | -| `cooking_kwh` | float | unregulated load — 0 until `SapResult` carries it | -| `cooking_cost_gbp` | float | | -| `pumps_fans_kwh` | float | | -| `pumps_fans_cost_gbp` | float | | -| `cooling_kwh` | float | mostly 0 in UK homes; carried for completeness as it affects the bill | -| `cooling_cost_gbp` | float | | -| `standing_charges_gbp` | float | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) | -| `seg_credit_gbp` | float | SEG export credit on PV (subtracted) | -| `total_annual_bill_gbp` | float | Σ section costs + standing charges − SEG | +| `bill_heating_kwh` | float, nullable | delivered fuel kWh (main + main-2 + secondary heating) | +| `bill_heating_cost_gbp` | float, nullable | priced at the heating fuel's current rate | +| `bill_hot_water_kwh` | float, nullable | | +| `bill_hot_water_cost_gbp` | float, nullable | | +| `bill_lighting_kwh` | float, nullable | | +| `bill_lighting_cost_gbp` | float, nullable | | +| `bill_appliances_kwh` | float, nullable | unregulated load — `None` until the appliances field lands on `SapResult` | +| `bill_appliances_cost_gbp` | float, nullable | | +| `bill_cooking_kwh` | float, nullable | unregulated load — `None` until `SapResult` carries it | +| `bill_cooking_cost_gbp` | float, nullable | | +| `bill_pumps_fans_kwh` | float, nullable | | +| `bill_pumps_fans_cost_gbp` | float, nullable | | +| `bill_cooling_kwh` | float, nullable | mostly absent in UK homes; carried for completeness as it affects the bill | +| `bill_cooling_cost_gbp` | float, nullable | | +| `bill_standing_charges_gbp` | float, nullable | 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) | +| `bill_total_annual_bill_gbp` | float, nullable | Σ section costs + standing charges − SEG; the not-null discriminator for a persisted bill | 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 @@ -65,7 +71,8 @@ batch rather than persisting a wrong row. ### Population timing -The bill columns are **defined now so the FE can create them**, but are populated only once the -`SapResult` → `EnergyBreakdown` adapter + `BillDerivation` wiring land (gated on the appliances / -cooking `SapResult` fields). Until then the SQLModel mirror in `infrastructure/postgres/` adds these -columns as nullable; the Drizzle migration can create them nullable in parallel. +The bill columns are now **populated**: the `PropertyBaselineOrchestrator` reads the current Fuel +Rates snapshot, builds a `BillDerivation`, and prices every scored property's `SapResult` → +`EnergyBreakdown` into a `Bill` that `from_domain` flattens onto these columns. They stay `None` +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. diff --git a/docs/migrations/recommendation-material-id.md b/docs/migrations/recommendation-material-id.md new file mode 100644 index 00000000..eee00195 --- /dev/null +++ b/docs/migrations/recommendation-material-id.md @@ -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; +``` diff --git a/docs/migrations/recommendation-plan-id.md b/docs/migrations/recommendation-plan-id.md new file mode 100644 index 00000000..887f788f --- /dev/null +++ b/docs/migrations/recommendation-plan-id.md @@ -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. diff --git a/domain/billing/__init__.py b/domain/billing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/billing/bill.py b/domain/billing/bill.py new file mode 100644 index 00000000..5aff24cf --- /dev/null +++ b/domain/billing/bill.py @@ -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) diff --git a/domain/property_baseline/bill_derivation.py b/domain/billing/bill_derivation.py similarity index 98% rename from domain/property_baseline/bill_derivation.py rename to domain/billing/bill_derivation.py index 2aceeeb3..c1a09c64 100644 --- a/domain/property_baseline/bill_derivation.py +++ b/domain/billing/bill_derivation.py @@ -5,7 +5,7 @@ from typing import Final from domain.fuel_rates.fuel import Fuel from domain.fuel_rates.fuel_rates import FuelRates -from domain.property_baseline.bill import ( +from domain.billing.bill import ( Bill, BillSection, BillSectionCost, diff --git a/domain/property_baseline/sap_fuel.py b/domain/billing/sap_fuel.py similarity index 100% rename from domain/property_baseline/sap_fuel.py rename to domain/billing/sap_fuel.py diff --git a/domain/building_geometry.py b/domain/building_geometry.py new file mode 100644 index 00000000..b6e535fe --- /dev/null +++ b/domain/building_geometry.py @@ -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) diff --git a/domain/geospatial/planning_restrictions.py b/domain/geospatial/planning_restrictions.py new file mode 100644 index 00000000..941458b2 --- /dev/null +++ b/domain/geospatial/planning_restrictions.py @@ -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 diff --git a/domain/geospatial/spatial_reference.py b/domain/geospatial/spatial_reference.py new file mode 100644 index 00000000..b8cec774 --- /dev/null +++ b/domain/geospatial/spatial_reference.py @@ -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 diff --git a/domain/modelling/__init__.py b/domain/modelling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/modelling/ashp_rates.json b/domain/modelling/ashp_rates.json new file mode 100644 index 00000000..eb0ac43a --- /dev/null +++ b/domain/modelling/ashp_rates.json @@ -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 +} diff --git a/domain/modelling/considered_measures.py b/domain/modelling/considered_measures.py new file mode 100644 index 00000000..530ca053 --- /dev/null +++ b/domain/modelling/considered_measures.py @@ -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 diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py new file mode 100644 index 00000000..4163b9ec --- /dev/null +++ b/domain/modelling/contingencies.py @@ -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 diff --git a/domain/modelling/generators/__init__.py b/domain/modelling/generators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/modelling/generators/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py new file mode 100644 index 00000000..9e3815ba --- /dev/null +++ b/domain/modelling/generators/floor_recommendation.py @@ -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,)) diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py new file mode 100644 index 00000000..897b011c --- /dev/null +++ b/domain/modelling/generators/glazing_recommendation.py @@ -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,)) diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py new file mode 100644 index 00000000..410eb5e6 --- /dev/null +++ b/domain/modelling/generators/heating_recommendation.py @@ -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 diff --git a/domain/modelling/generators/lighting_recommendation.py b/domain/modelling/generators/lighting_recommendation.py new file mode 100644 index 00000000..d1628461 --- /dev/null +++ b/domain/modelling/generators/lighting_recommendation.py @@ -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,)) diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py new file mode 100644 index 00000000..2595e3f9 --- /dev/null +++ b/domain/modelling/generators/roof_recommendation.py @@ -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,)) diff --git a/domain/modelling/generators/secondary_heating_recommendation.py b/domain/modelling/generators/secondary_heating_recommendation.py new file mode 100644 index 00000000..16e40e38 --- /dev/null +++ b/domain/modelling/generators/secondary_heating_recommendation.py @@ -0,0 +1,57 @@ +"""The Secondary Heating Removal Recommendation Generator (ADR-0028). + +Offers to strip a dwelling's lodged secondary heating system so the main system +serves 100% of space heating. A **standalone, co-selectable** Recommendation — +not an Option in the Heating & Hot Water rec — because removing a secondary +heater is independent of (and combinable with) a tune-up or boiler upgrade. + +Eligibility is purely physical: offered **iff a secondary is lodged** +(`sap_heating.secondary_heating_type` is set). RdSAP only records a secondary +when a *fixed* emitter is present (portable plug-in heaters are ignored), so a +lodged secondary is by definition a fixed unit worth removing. There is no +effectiveness gate — on an electric-storage main RdSAP §A.2.2 forces a default +secondary back, making removal a no-op, but that is the Optimiser's call (it +owns the economics), not eligibility's. Detection + pricing only; impact is +produced later by scoring (ADR-0016). + +Priced at a flat per-dwelling decommission cost (one electrician visit to +disconnect a fixed/hard-wired heater + localised making-good), not scaled by +room count — the EPC lodges one secondary system with no heater count (ADR-0028). +""" + +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_type import MeasureType +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, SecondaryHeatingOverlay +from repositories.product.product_repository import ProductRepository + +_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE: Final[MeasureType] = ( + MeasureType.SECONDARY_HEATING_REMOVAL +) + + +def recommend_secondary_heating_removal( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a Secondary Heating Removal Recommendation — its single Option + clears the lodged secondary system — else None when no secondary is lodged + (nothing physical to remove).""" + if epc.sap_heating.secondary_heating_type is None: + return None + + product = products.get(_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE) + overlay = EpcSimulation(secondary_heating=SecondaryHeatingOverlay()) + cost = Cost( + total=product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + option = MeasureOption( + measure_type=_SECONDARY_HEATING_REMOVAL_MEASURE_TYPE, + description="Remove the secondary heating system", + overlay=overlay, + cost=cost, + material_id=product.id, + ) + return Recommendation(surface="Secondary Heating", options=(option,)) diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py new file mode 100644 index 00000000..b74c3b63 --- /dev/null +++ b/domain/modelling/generators/solar_recommendation.py @@ -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 diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py new file mode 100644 index 00000000..dff55b66 --- /dev/null +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -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) diff --git a/domain/modelling/generators/ventilation_recommendation.py b/domain/modelling/generators/ventilation_recommendation.py new file mode 100644 index 00000000..ff6d598c --- /dev/null +++ b/domain/modelling/generators/ventilation_recommendation.py @@ -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 + ) diff --git a/domain/modelling/generators/wall_recommendation.py b/domain/modelling/generators/wall_recommendation.py new file mode 100644 index 00000000..2c562241 --- /dev/null +++ b/domain/modelling/generators/wall_recommendation.py @@ -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,)) diff --git a/domain/modelling/heating_rates.json b/domain/modelling/heating_rates.json new file mode 100644 index 00000000..f588e5aa --- /dev/null +++ b/domain/modelling/heating_rates.json @@ -0,0 +1,10 @@ +{ + "programmer": 120, + "room_thermostat": 150, + "trv_per_radiator": 35, + "zone_hub": 205, + "smart_trv_per_radiator": 50, + "cylinder_thermostat": 150, + "cylinder_jacket": 50, + "boiler": 3200 +} diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py new file mode 100644 index 00000000..a1882853 --- /dev/null +++ b/domain/modelling/measure_type.py @@ -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" diff --git a/domain/modelling/optimisation/__init__.py b/domain/modelling/optimisation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/modelling/optimisation/measure_dependency.py b/domain/modelling/optimisation/measure_dependency.py new file mode 100644 index 00000000..bd7e9d1f --- /dev/null +++ b/domain/modelling/optimisation/measure_dependency.py @@ -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 diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py new file mode 100644 index 00000000..0e577657 --- /dev/null +++ b/domain/modelling/optimisation/optimiser.py @@ -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 diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py new file mode 100644 index 00000000..9e8349e9 --- /dev/null +++ b/domain/modelling/plan.py @@ -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 + ) diff --git a/domain/modelling/portfolio_goal.py b/domain/modelling/portfolio_goal.py new file mode 100644 index 00000000..9785fd2e --- /dev/null +++ b/domain/modelling/portfolio_goal.py @@ -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" diff --git a/domain/modelling/product.py b/domain/modelling/product.py new file mode 100644 index 00000000..afd46897 --- /dev/null +++ b/domain/modelling/product.py @@ -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 diff --git a/domain/modelling/products.py b/domain/modelling/products.py new file mode 100644 index 00000000..ef6ed03b --- /dev/null +++ b/domain/modelling/products.py @@ -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 diff --git a/domain/modelling/recommendation.py b/domain/modelling/recommendation.py new file mode 100644 index 00000000..5f9b10a5 --- /dev/null +++ b/domain/modelling/recommendation.py @@ -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, ...] diff --git a/domain/modelling/scenario.py b/domain/modelling/scenario.py new file mode 100644 index 00000000..07f95ecb --- /dev/null +++ b/domain/modelling/scenario.py @@ -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 diff --git a/domain/modelling/scoring/__init__.py b/domain/modelling/scoring/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py new file mode 100644 index 00000000..c11285ca --- /dev/null +++ b/domain/modelling/scoring/overlay_applicator.py @@ -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 diff --git a/domain/modelling/scoring/package_scorer.py b/domain/modelling/scoring/package_scorer.py new file mode 100644 index 00000000..23010572 --- /dev/null +++ b/domain/modelling/scoring/package_scorer.py @@ -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, + ) diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py new file mode 100644 index 00000000..ea995380 --- /dev/null +++ b/domain/modelling/scoring/scoring.py @@ -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 diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py new file mode 100644 index 00000000..083f8898 --- /dev/null +++ b/domain/modelling/simulation.py @@ -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 diff --git a/domain/modelling/solar_potential.py b/domain/modelling/solar_potential.py new file mode 100644 index 00000000..1d90e14f --- /dev/null +++ b/domain/modelling/solar_potential.py @@ -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, + ) diff --git a/domain/modelling/solar_rates.json b/domain/modelling/solar_rates.json new file mode 100644 index 00000000..44eaf86d --- /dev/null +++ b/domain/modelling/solar_rates.json @@ -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 +} diff --git a/domain/modelling/valuation.py b/domain/modelling/valuation.py new file mode 100644 index 00000000..cc574acd --- /dev/null +++ b/domain/modelling/valuation.py @@ -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, + ) diff --git a/domain/property/property.py b/domain/property/property.py index 856eb3e3..f6b8957d 100644 --- a/domain/property/property.py +++ b/domain/property/property.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Literal, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.property.site_notes import SiteNotes SourcePath = Literal["site_notes", "epc_with_overlay"] @@ -37,6 +38,12 @@ class Property: identity: PropertyIdentity epc: Optional[EpcPropertyData] = 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 def source_path(self) -> SourcePath: diff --git a/domain/property_baseline/bill.py b/domain/property_baseline/bill.py deleted file mode 100644 index fcc49329..00000000 --- a/domain/property_baseline/bill.py +++ /dev/null @@ -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 diff --git a/domain/property_baseline/calculator_rebaseliner.py b/domain/property_baseline/calculator_rebaseliner.py index 184f56b0..6ed95c4e 100644 --- a/domain/property_baseline/calculator_rebaseliner.py +++ b/domain/property_baseline/calculator_rebaseliner.py @@ -4,7 +4,7 @@ import logging from typing import TYPE_CHECKING, Optional 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: from datatypes.epc.domain.epc_property_data import EpcPropertyData @@ -12,12 +12,19 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# The calculator targets SAP 10.2 (14-03-2025). A cert lodged below this carries -# a superseded methodology and is rebaselined to the calculator's output; at or -# above it, the API's lodged figures are kept and the calculator only validates. -_SAP10_2_FLOOR = 10.2 -_SAP_ABS_TOL = 0.5 -_REL_TOL = 0.01 +# Lodged figures are trusted from SAP 10.2 (14-03-2025) onward — the version the +# calculator targets. A cert lodged below this carries a superseded methodology, +# so the calculator's output replaces it; at or above it the lodged figures are +# kept and the calculator only validates against them. +_MIN_TRUSTED_SAP_VERSION = 10.2 + +# 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 @@ -44,17 +51,23 @@ class CalculatorRebaseliner(Rebaseliner): def rebaseline( self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance - ) -> tuple[Performance, RebaselineReason]: + ) -> RebaselineResult: # 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) sap_version: Optional[float] = effective_epc.sap_version - if sap_version is not None and sap_version < _SAP10_2_FLOOR: - return Performance.from_sap_result(result), "pre_sap10" + if sap_version is not None and sap_version < _MIN_TRUSTED_SAP_VERSION: + return RebaselineResult( + effective=Performance.from_sap_result(result), + reason="pre_sap10", + sap_result=result, + ) self._log_divergence( 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( self, @@ -64,15 +77,15 @@ class CalculatorRebaseliner(Rebaseliner): result: "SapResult", lodged: Performance, ) -> 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) - 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( property_id, sap_version, "primary_energy_intensity", lodged.primary_energy_intensity, result.primary_energy_kwh_per_m2, ) 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) def _warn( diff --git a/domain/property_baseline/property_baseline_performance.py b/domain/property_baseline/property_baseline_performance.py index 8da9bbf2..6fee9858 100644 --- a/domain/property_baseline/property_baseline_performance.py +++ b/domain/property_baseline/property_baseline_performance.py @@ -1,7 +1,9 @@ from __future__ import annotations 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.rebaseliner import RebaselineReason @@ -17,8 +19,10 @@ class PropertyBaselinePerformance: Carries the part of the energy block that needs no derivation: annual ``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 @@ -26,3 +30,4 @@ class PropertyBaselinePerformance: rebaseline_reason: RebaselineReason space_heating_kwh: float water_heating_kwh: float + bill: Optional[Bill] = None diff --git a/domain/property_baseline/rebaseliner.py b/domain/property_baseline/rebaseliner.py index 2fd60df9..e5d94d3f 100644 --- a/domain/property_baseline/rebaseliner.py +++ b/domain/property_baseline/rebaseliner.py @@ -1,15 +1,20 @@ from __future__ import annotations 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 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"] # 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 @@ -23,40 +28,60 @@ class RebaselineNotImplemented(Exception): """ -class Rebaseliner(ABC): - """Produces a Property's Effective Performance from its Effective EPC. +@dataclass(frozen=True) +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 - EPC was lodged pre-SAP10 or its physical state diverged from the lodged EPC; + ``sap_result`` is the scored picture (ADR-0013 amendment) — a first-class + 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 - PropertyBaselineOrchestrator (ADR-0011) so the ML adapter can swap in without - touching the orchestrator, and so the single-property re-score-on-override - flow reuses the same port. + PropertyBaselineOrchestrator (ADR-0011) so the implementation can swap + without touching the orchestrator, and so the single-property + re-score-on-override flow reuses the same port. """ @abstractmethod def rebaseline( self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance - ) -> tuple[Performance, RebaselineReason]: ... + ) -> RebaselineResult: ... class StubRebaseliner(Rebaseliner): """A no-calculator stub for tests that don't want the real calculator. SAP10 certs pass through untouched — Effective Performance equals Lodged, - reason ``"none"``. A pre-SAP10 cert genuinely needs rebaselining, which this - stub does not do, so it raises rather than fabricating a "none". Production - uses ``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013 - amendment); this stub stays for orchestrator/repo unit tests. + reason ``"none"``, and ``sap_result`` is ``None`` (no calculator ran). A + pre-SAP10 cert genuinely needs rebaselining, which this stub does not do, so + it raises rather than fabricating a "none". Production uses + ``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( self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance - ) -> tuple[Performance, RebaselineReason]: + ) -> RebaselineResult: sap_version = effective_epc.sap_version if sap_version is not None and sap_version < _SAP10_FLOOR: raise RebaselineNotImplemented( 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) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5298b0c5..37bb80f7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -744,6 +744,19 @@ def _pv_annual_s_kwh_per_m2( total += days * s_m 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 # 5th "Severe" bucket; the four RdSAP codes map directly: # 1 = very little / none → 1.0 diff --git a/harness/__init__.py b/harness/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/harness/cohort.py b/harness/cohort.py new file mode 100644 index 00000000..a56aacb0 --- /dev/null +++ b/harness/cohort.py @@ -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) diff --git a/harness/console.py b/harness/console.py new file mode 100644 index 00000000..dcb0c541 --- /dev/null +++ b/harness/console.py @@ -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 diff --git a/harness/epc_bulk.py b/harness/epc_bulk.py new file mode 100644 index 00000000..83b8e541 --- /dev/null +++ b/harness/epc_bulk.py @@ -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-.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() diff --git a/harness/plan_table.py b/harness/plan_table.py new file mode 100644 index 00000000..7c6e96f1 --- /dev/null +++ b/harness/plan_table.py @@ -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]) diff --git a/harness/report.py b/harness/report.py new file mode 100644 index 00000000..c9bd13a2 --- /dev/null +++ b/harness/report.py @@ -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) diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json new file mode 100644 index 00000000..1eb5ad62 --- /dev/null +++ b/harness/sample_catalogue.json @@ -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 } +} diff --git a/infrastructure/postgres/modelling/__init__.py b/infrastructure/postgres/modelling/__init__.py new file mode 100644 index 00000000..2e0eda4b --- /dev/null +++ b/infrastructure/postgres/modelling/__init__.py @@ -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", +] diff --git a/infrastructure/postgres/modelling/installed_measure_table.py b/infrastructure/postgres/modelling/installed_measure_table.py new file mode 100644 index 00000000..c213b5d2 --- /dev/null +++ b/infrastructure/postgres/modelling/installed_measure_table.py @@ -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 diff --git a/infrastructure/postgres/modelling/plan_table.py b/infrastructure/postgres/modelling/plan_table.py new file mode 100644 index 00000000..e1281f49 --- /dev/null +++ b/infrastructure/postgres/modelling/plan_table.py @@ -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, + ) diff --git a/infrastructure/postgres/modelling/recommendation_table.py b/infrastructure/postgres/modelling/recommendation_table.py new file mode 100644 index 00000000..62b3ff6f --- /dev/null +++ b/infrastructure/postgres/modelling/recommendation_table.py @@ -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) diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py new file mode 100644 index 00000000..47b40b73 --- /dev/null +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -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, + ) diff --git a/infrastructure/postgres/product_table.py b/infrastructure/postgres/product_table.py new file mode 100644 index 00000000..b353b300 --- /dev/null +++ b/infrastructure/postgres/product_table.py @@ -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) diff --git a/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 0e5e1792..1327f63b 100644 --- a/infrastructure/postgres/property_baseline_performance_table.py +++ b/infrastructure/postgres/property_baseline_performance_table.py @@ -5,10 +5,22 @@ from typing import ClassVar, Optional, cast from sqlmodel import Field, SQLModel 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.performance import Performance 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): """The ``property_baseline_performance`` row — one per Property (ADR-0004). @@ -38,11 +50,32 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): space_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 def from_domain( cls, baseline: PropertyBaselinePerformance, property_id: int ) -> "PropertyBaselinePerformanceModel": - return cls( + model = cls( property_id=property_id, lodged_sap_score=baseline.lodged.sap_score, lodged_epc_band=baseline.lodged.epc_band.value, @@ -56,6 +89,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): space_heating_kwh=baseline.space_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: return PropertyBaselinePerformance( @@ -74,4 +127,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): rebaseline_reason=cast(RebaselineReason, self.rebaseline_reason), space_heating_kwh=self.space_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, ) diff --git a/infrastructure/postgres/property_details_spatial_table.py b/infrastructure/postgres/property_details_spatial_table.py new file mode 100644 index 00000000..9a970833 --- /dev/null +++ b/infrastructure/postgres/property_details_spatial_table.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import ClassVar, Optional + +from sqlmodel import Field, SQLModel + + +class PropertyDetailsSpatialRow(SQLModel, table=True): + """Per-UPRN cache of the Ordnance Survey spatial reference data. + + The OS Open-UPRN set is tens of millions of rows — too large for Postgres — + so Ingestion resolves it from S3 and writes the row it used here, keyed by + UPRN (one shared row per UPRN, not per Property). The front-end reads the + planning flags off this table to show why a Property did or did not get a + given measure; Modelling hydrates them onto the Property (ADR-0020). Coords + are retained for parity with the legacy ``property_details_spatial`` shape. + """ + + __tablename__: ClassVar[str] = "property_details_spatial" # pyright: ignore[reportIncompatibleVariableOverride] + + id: Optional[int] = Field(default=None, primary_key=True) + uprn: int = Field(index=True, unique=True) + x_coordinate: Optional[float] = Field(default=None) + y_coordinate: Optional[float] = Field(default=None) + latitude: Optional[float] = Field(default=None) + longitude: Optional[float] = Field(default=None) + conservation_status: Optional[bool] = Field(default=None) + is_listed_building: Optional[bool] = Field(default=None) + is_heritage_building: Optional[bool] = Field(default=None) diff --git a/orchestration/ara_first_run_pipeline.py b/orchestration/ara_first_run_pipeline.py index ed507d6e..c17f88be 100644 --- a/orchestration/ara_first_run_pipeline.py +++ b/orchestration/ara_first_run_pipeline.py @@ -38,7 +38,9 @@ class PropertyBaselineStage(Protocol): class ModellingStage(Protocol): """Stage 3 — scores each Property against its Scenarios into Plans.""" - def run(self, property_ids: list[int], scenario_ids: list[int]) -> None: ... + def run( + self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int + ) -> None: ... class AraFirstRunPipeline: @@ -67,4 +69,6 @@ class AraFirstRunPipeline: def run(self, command: AraFirstRunCommand) -> None: self._ingestion.run(command.property_ids) self._baseline.run(command.property_ids) - self._modelling.run(command.property_ids, command.scenario_ids) + self._modelling.run( + command.property_ids, command.scenario_ids, command.portfolio_id + ) diff --git a/orchestration/ingestion_orchestrator.py b/orchestration/ingestion_orchestrator.py index 1662ecf9..6263af0b 100644 --- a/orchestration/ingestion_orchestrator.py +++ b/orchestration/ingestion_orchestrator.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any, Optional, Protocol from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.spatial_reference import SpatialReference from repositories.geospatial.geospatial_repository import GeospatialRepository from repositories.unit_of_work import UnitOfWork @@ -28,20 +29,24 @@ class _Fetched: """One property's externally-fetched source data, awaiting the write phase.""" property_id: int + uprn: int epc: Optional[EpcPropertyData] solar_insights: Optional[dict[str, Any]] + spatial: Optional[SpatialReference] class IngestionOrchestrator: """Stage 1: acquire a batch's external source data and persist it. Runs in two phases so a DB connection is never held during external IO - (ADR-0012): **fetch** the whole batch — read each UPRN, fetch its EPC, resolve - coordinates from the Geospatial reference Repo, thread those into the Solar - fetcher — with *no unit open*; then **write** the batch in one Unit of Work - and commit once. Fetchers never call each other (ADR-0011); the orchestrator - threads the coordinate. Coordinates are reference data (deterministic from - UPRN), resolved transiently to drive the Solar fetch, never persisted. + (ADR-0012): **fetch** the whole batch — read each UPRN, fetch its EPC, + resolve its spatial reference (coordinates + planning protections) from the + Geospatial reference Repo, thread the coordinates into the Solar fetcher — + with *no unit open*; then **write** the batch in one Unit of Work and commit + once. Fetchers never call each other (ADR-0011); the orchestrator threads + the coordinate. The coordinates drive the Solar fetch transiently; the whole + spatial reference is cached per-UPRN in the transactional store so Modelling + reads the planning protections back off the Property (ADR-0020). The geospatial repo reads S3 reference data, not the transactional store, so it is injected separately rather than taken from the unit. @@ -77,15 +82,17 @@ class IngestionOrchestrator: ] def _fetch(self, property_id: int, uprn: int) -> _Fetched: - # No unit open here — this is the external-IO phase. + # No unit open here — this is the external-IO phase. One spatial + # reference lookup yields the coordinates (which drive the Solar fetch) + # and the planning protections (cached for Modelling, ADR-0020). epc = self._epc_fetcher.get_by_uprn(uprn) solar_insights: Optional[dict[str, Any]] = None - coordinates = self._geospatial_repo.coordinates_for(uprn) - if coordinates is not None: + spatial: Optional[SpatialReference] = self._geospatial_repo.spatial_for(uprn) + if spatial is not None and spatial.coordinates is not None: solar_insights = self._solar_fetcher.get_building_insights( - coordinates.longitude, coordinates.latitude + spatial.coordinates.longitude, spatial.coordinates.latitude ) - return _Fetched(property_id, epc, solar_insights) + return _Fetched(property_id, uprn, epc, solar_insights, spatial) def _persist(self, fetched: list[_Fetched]) -> None: with self._unit_of_work() as uow: @@ -94,4 +101,6 @@ class IngestionOrchestrator: uow.epc.save(item.epc, property_id=item.property_id) if item.solar_insights is not None: uow.solar.save(item.property_id, item.solar_insights) + if item.spatial is not None: + uow.spatial.save(item.uprn, item.spatial) uow.commit() diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 48f70b19..50be53a4 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -1,29 +1,356 @@ from __future__ import annotations -from repositories.materials.materials_repository import MaterialsRepository -from repositories.scenario.scenario_repository import ScenarioRepository +from collections.abc import Callable +from typing import Final, Optional + +from datatypes.epc.domain.epc import Epc +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.billing.bill import Bill, EnergyBreakdown +from domain.billing.bill_derivation import BillDerivation +from domain.modelling.considered_measures import restrict_to_considered_measures +from domain.modelling.generators.floor_recommendation import recommend_floor_insulation +from domain.modelling.measure_type import MeasureType +from domain.modelling.optimisation.measure_dependency import ventilation_dependency +from domain.modelling.optimisation.optimiser import ( + MeasureDependency, + OptimisedPackage, + ScoredOption, + optimise_package, +) +from domain.modelling.scoring.package_scorer import PackageScorer, Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.generators.roof_recommendation import recommend_roof_insulation +from domain.modelling.scenario import Scenario +from domain.modelling.scoring.scoring import ( + MeasureImpact, + cascade_scores, + independent_option_impacts, + marginals_from_scores, +) +from domain.modelling.generators.wall_recommendation import recommend_cavity_wall +from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall +from domain.modelling.generators.glazing_recommendation import recommend_glazing +from domain.modelling.generators.lighting_recommendation import recommend_lighting +from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) +from domain.modelling.generators.solar_recommendation import recommend_solar +from domain.modelling.solar_potential import SolarPotential +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.sap10_calculator.calculator import SapCalculator +from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository +from repositories.product.product_repository import ProductRepository +from repositories.solar.solar_repository import SolarRepository +from repositories.unit_of_work import UnitOfWork + +# The PortfolioGoal value that targets a SAP band (cf. +# backend.app.db.models.portfolio.PortfolioGoal.INCREASING_EPC). Other goals +# (Energy Savings, Reducing CO2 emissions) don't yet set a SAP repair target — +# the optimiser just maximises SAP gain within budget for them (later slice). +_INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" + +# Best-practice install sequence for the role-3 attribution cascade (ADR-0016): +# walls → roof → ventilation → floor, per the legacy `Recommendations` class. +# Ventilation sits after the fabric that triggers it so its (negative) marginal +# is attributed against the insulated envelope. +_BEST_PRACTICE_ORDER: Final[tuple[str, ...]] = ( + "cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "mechanical_ventilation", + "suspended_floor_insulation", + "solid_floor_insulation", +) class ModellingOrchestrator: - """Stage 3 — scores each baselined Property against its Scenarios, producing - Recommendations -> an Optimised Package per Scenario Phase -> Plans - (CONTEXT.md: Modelling). + """Stage 3 — scores each baselined Property against its Scenarios into Plans + and persists them (CONTEXT.md: Modelling; ADR-0011 / ADR-0012 / ADR-0016 / + ADR-0017). - Stub at this stage (#1136): ``run`` reads its inputs through repos (it takes - only ``property_ids`` + ``scenario_ids``, never an in-memory hand-off from - Baseline) but does no scoring yet. Full Modelling lands via later TDD slices - + per-service grills. The Scenario / Materials repos are injected now so the - composition and wiring are real even while the body is empty. + Runs the whole batch in **one** Unit of Work and commits once. For each + (Property × Scenario) it reads the Property's Effective EPC and the Scenario + through repos, generates the candidate Recommendations (wall / roof / + floor), scores each Option independently (role 1), runs the grouped-knapsack + Optimiser + whole-package re-score + greedy repair toward the Scenario's SAP + target (role 2, ADR-0016), attributes each selected measure via the + best-practice marginal cascade (role 3), and persists a **Plan** with its + **Plan Measures**. Single-phase — multi-phase is deferred (ADR-0005). + + Reads only through repos and threads only IDs (`property_ids`, + `scenario_ids`, `portfolio_id`) — never an in-memory hand-off from Baseline + (ADR-0011). The injected `SapCalculator` is the scoring-engine seam. """ def __init__( self, *, - scenario_repo: ScenarioRepository, - materials_repo: MaterialsRepository, + unit_of_work: Callable[[], UnitOfWork], + calculator: SapCalculator, + fuel_rates: FuelRatesRepository, ) -> None: - self._scenario_repo = scenario_repo - self._materials_repo = materials_repo + self._unit_of_work = unit_of_work + self._calculator = calculator + self._fuel_rates = fuel_rates - def run(self, property_ids: list[int], scenario_ids: list[int]) -> None: + def run( + self, + property_ids: list[int], + scenario_ids: list[int], + portfolio_id: int, + *, + considered_measures: Optional[frozenset[MeasureType]] = None, + ) -> None: + """Model the batch. ``considered_measures`` restricts the run to those + measure types (mirroring the legacy `inclusions`); None considers every + modelled measure.""" + scorer = PackageScorer(self._calculator) + # Resolve Fuel Rates once and reuse the BillDerivation across the batch, + # so every baseline/post bill is priced at the same snapshot (ADR-0014). + bill_derivation = BillDerivation(self._fuel_rates.get_current()) + with self._unit_of_work() as uow: + properties = uow.property.get_many(property_ids) + scenarios: list[Scenario] = uow.scenario.get_many(scenario_ids) + for property_id, prop in zip(property_ids, properties, strict=True): + effective_epc: EpcPropertyData = prop.effective_epc + # The Property's Google Solar potential (raw buildingInsights + # JSON persisted by Ingestion), projected once per Property and + # threaded into the solar Generator (ADR-0026). None when no + # solar data was fetched — the Generator then offers nothing. + solar_potential: Optional[SolarPotential] = _solar_potential_for( + uow.solar, property_id + ) + for scenario in scenarios: + plan = self._plan_for( + scorer, + bill_derivation, + effective_epc, + uow.product, + scenario, + current_market_value=prop.current_market_value, + planning_restrictions=prop.planning_restrictions, + solar_potential=solar_potential, + considered_measures=considered_measures, + ) + uow.plan.save( + plan, + property_id=property_id, + scenario_id=scenario.id, + portfolio_id=portfolio_id, + is_default=scenario.is_default, + ) + uow.commit() + + def _plan_for( + self, + scorer: PackageScorer, + bill_derivation: BillDerivation, + effective_epc: EpcPropertyData, + products: ProductRepository, + scenario: Scenario, + *, + current_market_value: Optional[float], + planning_restrictions: PlanningRestrictions, + solar_potential: Optional[SolarPotential], + considered_measures: Optional[frozenset[MeasureType]], + ) -> Plan: + """Generate → score → optimise → re-score/repair → attribute → bill → + assemble the Plan for one Property + Scenario.""" + groups: list[list[ScoredOption]] = _scored_candidate_groups( + scorer, + effective_epc, + products, + planning_restrictions, + solar_potential, + considered_measures, + ) + # Forced Measure Dependencies (ventilation) are excluded from the pool + # but injected into the package before the re-score (ADR-0016). + dependencies: list[MeasureDependency] = _measure_dependencies( + effective_epc, products, considered_measures + ) + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=effective_epc, + budget=scenario.budget, + target_sap=_target_sap(scenario), + dependencies=dependencies, + ) + + # Role-3 attribution: re-apply the *selected* set in best-practice order + # so each measure's marginal telescopes to the truthful package total. + ordered: list[MeasureOption] = sorted( + (scored.option for scored in package.selected), key=_best_practice_key + ) + # Score the baseline + every cumulative prefix once (cascade[0] is the + # baseline, cascade[-1] the whole package), then reuse those Scores for + # both the marginal attribution and the per-measure bill cascade. + cascade: list[Score] = cascade_scores( + scorer, effective_epc, [option.overlay for option in ordered] + ) + impacts: list[MeasureImpact] = marginals_from_scores(cascade) + # Bill every prefix at one Fuel Rates snapshot; consecutive Bill deltas + # are each measure's marginal energy/cost saving — negative for + # ventilation — telescoping exactly to the Plan totals (ADR-0014). The + # Plan's baseline/post Bills are the cascade endpoints, so the + # per-measure savings and the headline savings share one source. + bills: list[Bill] = [_bill_for(bill_derivation, score) for score in cascade] + measures: tuple[PlanMeasure, ...] = tuple( + _plan_measure(option, impact, before, after) + for option, impact, before, after in zip( + ordered, impacts, bills[:-1], bills[1:], strict=True + ) + ) + return Plan( + measures=measures, + baseline=cascade[0], + post_retrofit=package.score, + baseline_bill=bills[0], + post_bill=bills[-1], + current_market_value=current_market_value, + ) + + +def _bill_for(bill_derivation: BillDerivation, score: Score) -> Bill: + """Derive the annual Bill for a scored end-state, pricing the delivered + energy off the Score's SapResult. The real PackageScorer always attaches the + SapResult; a missing one is a wiring error, so raise rather than bill at a + default (ADR-0014).""" + if score.sap_result is None: + raise ValueError( + "cannot derive a bill: the Score carries no SapResult to price" + ) + return bill_derivation.derive(EnergyBreakdown.from_sap_result(score.sap_result)) + + +def _solar_potential_for( + solar_repo: SolarRepository, property_id: int +) -> Optional[SolarPotential]: + """Project the Property's persisted Google Solar `buildingInsights` JSON + into a typed `SolarPotential` (ADR-0026), or None when none was fetched / + the lookup returned an error payload (no `solarPotential` block).""" + insights = solar_repo.get(property_id) + if not insights or "solarPotential" not in insights: return None + return SolarPotential.from_building_insights(insights) + + +def _candidate_recommendations( + effective_epc: EpcPropertyData, + products: ProductRepository, + planning_restrictions: PlanningRestrictions, + solar_potential: Optional[SolarPotential], + considered_measures: Optional[frozenset[MeasureType]], +) -> list[Recommendation]: + """Run every Recommendation Generator; keep the ones that apply. Solid-wall + insulation, glazing, heating and solar are additionally gated by the + Property's planning protections (ADR-0019 / ADR-0022 / ADR-0024 / ADR-0026); + solar also needs the Property's Google solar potential. ``considered_measures`` + then restricts the survivors to the run's allowlist (None = all).""" + found = ( + recommend_cavity_wall(effective_epc, products), + recommend_solid_wall(effective_epc, products, planning_restrictions), + recommend_roof_insulation(effective_epc, products), + recommend_floor_insulation(effective_epc, products), + recommend_glazing(effective_epc, products, planning_restrictions), + recommend_lighting(effective_epc, products), + recommend_heating(effective_epc, products, planning_restrictions), + recommend_secondary_heating_removal(effective_epc, products), + recommend_solar( + effective_epc, products, solar_potential, planning_restrictions + ), + ) + applicable = [ + recommendation for recommendation in found if recommendation is not None + ] + return restrict_to_considered_measures(applicable, considered_measures) + + +def _measure_dependencies( + effective_epc: EpcPropertyData, + products: ProductRepository, + considered_measures: Optional[frozenset[MeasureType]], +) -> list[MeasureDependency]: + """The forced Measure Dependencies for this Property — currently just + ventilation, suppressed when the dwelling is already mechanically + ventilated (ADR-0016). A dependency whose required measure is outside the + run's allowlist is also suppressed, so a restricted run forces nothing it is + not considering.""" + dependency: Optional[MeasureDependency] = ventilation_dependency( + effective_epc, products + ) + if dependency is None: + return [] + if ( + considered_measures is not None + and dependency.required.option.measure_type not in considered_measures + ): + return [] + return [dependency] + + +def _scored_candidate_groups( + scorer: PackageScorer, + effective_epc: EpcPropertyData, + products: ProductRepository, + planning_restrictions: PlanningRestrictions, + solar_potential: Optional[SolarPotential], + considered_measures: Optional[frozenset[MeasureType]], +) -> list[list[ScoredOption]]: + """One group per Recommendation: each Option scored independently against + the baseline (role-1 warm-start signal, ADR-0016).""" + groups: list[list[ScoredOption]] = [] + for recommendation in _candidate_recommendations( + effective_epc, products, planning_restrictions, solar_potential, considered_measures + ): + options = list(recommendation.options) + impacts: list[MeasureImpact] = independent_option_impacts( + scorer, effective_epc, options + ) + groups.append( + [ + ScoredOption(option=option, sap_gain=impact.sap_points) + for option, impact in zip(options, impacts, strict=True) + ] + ) + return groups + + +def _target_sap(scenario: Scenario) -> Optional[float]: + """The SAP rating the Optimiser repairs toward — the floor of the goal + band for an INCREASING_EPC goal, else None (no SAP target).""" + if scenario.goal != _INCREASING_EPC_GOAL: + return None + return float(Epc(scenario.goal_value).sap_lower_bound()) + + +def _best_practice_key(option: MeasureOption) -> int: + try: + return _BEST_PRACTICE_ORDER.index(option.measure_type) + except ValueError: + return len(_BEST_PRACTICE_ORDER) + + +def _plan_measure( + option: MeasureOption, impact: MeasureImpact, before: Bill, after: Bill +) -> PlanMeasure: + """Assemble a Plan Measure, attributing this measure's marginal bill saving + as the delta between the running package Bill before and after it (delivered + kWh and £). Signed so positive is a saving; ventilation is negative.""" + if option.cost is None: + raise ValueError( + f"measure option {option.measure_type!r} has no cost; cannot persist" + ) + return PlanMeasure( + measure_type=option.measure_type, + description=option.description, + cost=option.cost, + impact=impact, + kwh_savings=before.total_consumption_kwh - after.total_consumption_kwh, + energy_cost_savings=before.total_gbp - after.total_gbp, + material_id=option.material_id, + ) diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index bf82a514..6c749e36 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -6,9 +6,12 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, RenewableHeatIncentive, ) +from domain.billing.bill import EnergyBreakdown +from domain.billing.bill_derivation import BillDerivation from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property_baseline.performance import lodged_performance from domain.property_baseline.rebaseliner import Rebaseliner +from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository from repositories.unit_of_work import UnitOfWork @@ -32,26 +35,43 @@ class PropertyBaselineOrchestrator: *, unit_of_work: Callable[[], UnitOfWork], rebaseliner: Rebaseliner, + fuel_rates: FuelRatesRepository, ) -> None: self._unit_of_work = unit_of_work self._rebaseliner = rebaseliner + self._fuel_rates = fuel_rates def run(self, property_ids: list[int]) -> None: + # The Fuel Rates snapshot is a committed static file (no DB), so read it + # once before the unit opens and reuse the BillDerivation across the + # batch — every property prices against the same snapshot. + fuel_rates = self._fuel_rates.get_current() + bill_derivation = BillDerivation(fuel_rates) with self._unit_of_work() as uow: properties = uow.property.get_many(property_ids) for property_id, prop in zip(property_ids, properties, strict=True): effective_epc = prop.effective_epc lodged = lodged_performance(effective_epc) - effective, reason = self._rebaseliner.rebaseline( + rebaselined = self._rebaseliner.rebaseline( property_id, effective_epc, lodged ) + # No SapResult (the stub path) means no scored picture to price, + # so the bill stays None. + bill = ( + bill_derivation.derive( + EnergyBreakdown.from_sap_result(rebaselined.sap_result) + ) + if rebaselined.sap_result is not None + else None + ) rhi = _require_rhi(effective_epc) baseline = PropertyBaselinePerformance( lodged=lodged, - effective=effective, - rebaseline_reason=reason, + effective=rebaselined.effective, + rebaseline_reason=rebaselined.reason, space_heating_kwh=rhi.space_heating_kwh, water_heating_kwh=rhi.water_heating_kwh, + bill=bill, ) uow.property_baseline.save(baseline, property_id) uow.commit() diff --git a/repositories/fuel_rates/data/fuel_rates_2026_q2.json b/repositories/fuel_rates/data/fuel_rates_2026_q2.json index 2b81bd30..c92af0dc 100644 --- a/repositories/fuel_rates/data/fuel_rates_2026_q2.json +++ b/repositories/fuel_rates/data/fuel_rates_2026_q2.json @@ -1,9 +1,9 @@ { "period": "2026-04 to 2026-06", - "basis": "GB national average; Ofgem price cap (gas/electricity), DESNZ/NEP May 2026 (off-gas fuels)", + "basis": "GB national average; fuel-input / metered p/kWh (NOT useful-heat). Ofgem price cap (gas/electricity); NEP Apr 2026 retail prices converted with DESNZ DUKES gross calorific values (off-gas fuels)", "sources": { "gas_electricity": "Ofgem energy price cap unit rates and standing charges, announced 2026-02-25, cap period Apr-Jun 2026", - "off_gas": "DESNZ QEP petroleum table (oil, May 2026) + Nottingham Energy Partnership May 2026 comparison (LPG, smokeless, wood)", + "off_gas": "Nottingham Energy Partnership Apr 2026 retail prices (oil, smokeless, wood; LPG bottled-propane proxy) / DESNZ DUKES 2024 gross calorific values; see _assumptions", "seg": "Solar Energy UK SEG league table, updated 2026-05-12" }, "seg_export_p_per_kwh": 15.0, @@ -11,17 +11,27 @@ "MAINS_GAS": { "unit_rate_p_per_kwh": 5.74, "standing_charge_p_per_day": 29.09 }, "ELECTRICITY": { "unit_rate_p_per_kwh": 24.67, "standing_charge_p_per_day": 57.21 }, "ELECTRICITY_OFF_PEAK": { "day_p_per_kwh": 29.73, "night_p_per_kwh": 13.89, "standing_charge_p_per_day": 56.99 }, - "OIL": { "unit_rate_p_per_kwh": 9.16, "standing_charge_p_per_day": 0.0 }, - "LPG": { "unit_rate_p_per_kwh": 17.61, "standing_charge_p_per_day": 0.0 }, - "SMOKELESS": { "unit_rate_p_per_kwh": 10.0, "standing_charge_p_per_day": 0.0 }, - "WOOD_LOGS": { "unit_rate_p_per_kwh": 8.83, "standing_charge_p_per_day": 0.0 }, - "WOOD_PELLETS": { "unit_rate_p_per_kwh": 7.99, "standing_charge_p_per_day": 0.0, "_note": "bagged pellets; blown bulk is 6.76 p/kWh" }, - "COAL": null, - "HEAT_NETWORK": null + "OIL": { "unit_rate_p_per_kwh": 12.11, "standing_charge_p_per_day": 0.0 }, + "LPG": { "unit_rate_p_per_kwh": 17.61, "standing_charge_p_per_day": 0.0, "_note": "bottled-propane basis; bulk tank LPG is materially lower" }, + "SMOKELESS": { "unit_rate_p_per_kwh": 8.69, "standing_charge_p_per_day": 0.0 }, + "WOOD_LOGS": { "unit_rate_p_per_kwh": 8.25, "standing_charge_p_per_day": 0.0 }, + "WOOD_PELLETS": { "unit_rate_p_per_kwh": 7.38, "standing_charge_p_per_day": 0.0, "_note": "bagged pellets (NEP Apr 2026 / DUKES GCV); blown bulk is lower" }, + "COAL": { "unit_rate_p_per_kwh": 7.13, "standing_charge_p_per_day": 0.0, "proxy": true, "_note": "PROXY, not a market rate. No current GB retail house-coal price (NEP Apr 2026 blank; domestic house-coal sale restricted since 2021). NEP Nov 2025 coal 48.50p/kg uprated by smokeless movement -> 52.39p/kg / DUKES house-coal GCV 7.3502 kWh/kg = 7.13 p/kWh." }, + "HEAT_NETWORK": { "unit_rate_p_per_kwh": 16.0, "standing_charge_p_per_day": 69.4, "indicative": true, "_note": "INDICATIVE, not a regulated rate. Delivered-heat charge; no national tariff/cap. Insite Energy Nov 2024 operator sample avg 16.03 p/kWh + 69.42 p/day; schemes vary widely (~8-30 p/kWh per CMA/Heat Trust)." } }, "_gaps": { - "COAL": "no standard domestic price (traditional house coal sale for domestic use is illegal in England)", - "HEAT_NETWORK": "scheme-specific; no national tariff or price-cap unit rate", + "COAL": "PROXY rate (see _note): no current national retail price; sense-check estimate so coal-heated certs model rather than erroring.", + "HEAT_NETWORK": "INDICATIVE rate (see _note): scheme-specific, no national tariff/cap; treat the bill as indicative.", "ELECTRICITY_OFF_PEAK": "day/night split; priced once the off-peak slice adds the day/night accessor" + }, + "_proxy_research": "COAL + HEAT_NETWORK proxies sourced 2026-06 via deep research (NEP retail + DESNZ DUKES gross CVs for coal; Insite Energy / CMA / Heat Trust for heat networks). Fuel-input basis. The off-gas retail fuels (oil/smokeless/wood) were refreshed onto the same NEP-Apr-2026 / DUKES-GCV basis at the same time; gas/electricity (Ofgem cap) and SEG (Solar Energy UK) unchanged.", + "_assumptions": { + "OIL": "NEP Apr 2026 kerosene 124.32 p/litre / DUKES burning-oil 10.2698 kWh/litre = 12.11 p/kWh", + "LPG": "NEP Apr 2026 propane 127.16 p/litre / DUKES LPG 7.3241 kWh/litre = 17.36 (kept prior 17.61, within noise; bulk LPG would be lower)", + "SMOKELESS": "NEP Apr 2026 71.42 p/kg / DUKES manufactured-solid 8.2179 kWh/kg = 8.69 p/kWh", + "WOOD_LOGS": "NEP Apr 2026 kiln-dried 37.26 p/kg / DUKES wood 4.5156 kWh/kg = 8.25 p/kWh", + "WOOD_PELLETS": "NEP Apr 2026 bagged 38.33 p/kg / DUKES pellet 5.1928 kWh/kg = 7.38 p/kWh", + "COAL": "NEP Nov 2025 48.50 p/kg uprated x(71.42/66.12) = 52.39 p/kg / DUKES house-coal 7.3502 kWh/kg = 7.13 p/kWh", + "HEAT_NETWORK": "Insite Energy Nov 2024 operator sample avg 16.03 p/kWh + 69.42 p/day (delivered heat)" } } diff --git a/repositories/fuel_rates/static_file_fuel_rates_repository.py b/repositories/fuel_rates/fuel_rates_static_file_repository.py similarity index 96% rename from repositories/fuel_rates/static_file_fuel_rates_repository.py rename to repositories/fuel_rates/fuel_rates_static_file_repository.py index cbfd5062..1f53617d 100644 --- a/repositories/fuel_rates/static_file_fuel_rates_repository.py +++ b/repositories/fuel_rates/fuel_rates_static_file_repository.py @@ -11,7 +11,7 @@ from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository _DEFAULT_SNAPSHOT = Path(__file__).parent / "data" / "fuel_rates_2026_q2.json" -class StaticFileFuelRatesRepository(FuelRatesRepository): +class FuelRatesStaticFileRepository(FuelRatesRepository): """Reads Fuel Rates from a committed JSON snapshot (ADR-0014). Only **single-rate** fuels (those lodging a ``unit_rate_p_per_kwh``) are diff --git a/repositories/geospatial/geospatial_repository.py b/repositories/geospatial/geospatial_repository.py index 558216bb..b9dbff17 100644 --- a/repositories/geospatial/geospatial_repository.py +++ b/repositories/geospatial/geospatial_repository.py @@ -4,6 +4,8 @@ from abc import ABC, abstractmethod from typing import Optional from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference class GeospatialRepository(ABC): @@ -15,3 +17,17 @@ class GeospatialRepository(ABC): @abstractmethod def coordinates_for(self, uprn: int) -> Optional[Coordinates]: ... + + def spatial_for(self, uprn: int) -> Optional[SpatialReference]: + """The Property's coordinates and planning protections together, in one + reference lookup (ADR-0020) — Ingestion uses the coordinates to drive + the Solar fetch and persists the whole reference. Defaults to None so + reference sources that don't carry the flags need not implement it.""" + return None + + def planning_restrictions_for(self, uprn: int) -> Optional[PlanningRestrictions]: + """The Property's planning protections (conservation/listed/heritage), + co-located with the coordinates in the reference data (ADR-0020). + Defaults to None (unknown → unrestricted) so reference sources that + don't carry the flags need not implement it.""" + return None diff --git a/repositories/geospatial/geospatial_s3_repository.py b/repositories/geospatial/geospatial_s3_repository.py index c91a57e1..39946f2b 100644 --- a/repositories/geospatial/geospatial_s3_repository.py +++ b/repositories/geospatial/geospatial_s3_repository.py @@ -1,11 +1,13 @@ from __future__ import annotations from collections.abc import Callable -from typing import Optional +from typing import Any, Optional import pandas as pd from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference from repositories.geospatial.geospatial_repository import GeospatialRepository ParquetReader = Callable[[str], pd.DataFrame] @@ -25,7 +27,9 @@ class GeospatialS3Repository(GeospatialRepository): def __init__(self, read_parquet: ParquetReader) -> None: self._read_parquet = read_parquet - def coordinates_for(self, uprn: int) -> Optional[Coordinates]: + def _row_for(self, uprn: int) -> Optional["pd.Series[Any]"]: + """The Open-UPRN partition row for ``uprn`` (coordinates + co-located + planning flags), or None when no partition covers it / it is absent.""" meta = self._read_parquet(_META_KEY) covering = meta[(meta["lower"] <= uprn) & (meta["upper"] >= uprn)] if covering.empty: @@ -36,8 +40,28 @@ class GeospatialS3Repository(GeospatialRepository): rows = partition[partition["UPRN"] == uprn] if rows.empty: return None - row = rows.iloc[0] - return Coordinates( - longitude=float(row["LONGITUDE"]), - latitude=float(row["LATITUDE"]), + return rows.iloc[0] + + def spatial_for(self, uprn: int) -> Optional[SpatialReference]: + row = self._row_for(uprn) + if row is None: + return None + return SpatialReference( + coordinates=Coordinates( + longitude=float(row["LONGITUDE"]), + latitude=float(row["LATITUDE"]), + ), + restrictions=PlanningRestrictions( + in_conservation_area=bool(row["conservation_status"]), + is_listed=bool(row["is_listed_building"]), + is_heritage=bool(row["is_heritage_building"]), + ), ) + + def coordinates_for(self, uprn: int) -> Optional[Coordinates]: + reference: Optional[SpatialReference] = self.spatial_for(uprn) + return reference.coordinates if reference is not None else None + + def planning_restrictions_for(self, uprn: int) -> Optional[PlanningRestrictions]: + reference: Optional[SpatialReference] = self.spatial_for(uprn) + return reference.restrictions if reference is not None else None diff --git a/repositories/plan/__init__.py b/repositories/plan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/plan/plan_postgres_repository.py b/repositories/plan/plan_postgres_repository.py new file mode 100644 index 00000000..376cf8b8 --- /dev/null +++ b/repositories/plan/plan_postgres_repository.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from sqlmodel import Session, col, delete + +from domain.modelling.plan import Plan +from infrastructure.postgres.modelling import PlanModel, RecommendationModel +from repositories.plan.plan_repository import PlanRepository + + +class PlanPostgresRepository(PlanRepository): + """Maps a Plan and its Plan Measures onto the live ``plan`` / + ``recommendation`` tables (ADR-0017). Does not commit — the Unit of Work + owns the transaction (ADR-0012).""" + + def __init__(self, session: Session) -> None: + self._session = session + + def save( + self, + plan: Plan, + *, + property_id: int, + scenario_id: int, + portfolio_id: int, + is_default: bool, + ) -> int: + # Idempotent replace for (property_id, scenario_id): deleting the Plan + # cascades to its recommendation rows via the plan_id FK (ON DELETE + # CASCADE), so a re-run overwrites rather than duplicating (ADR-0012). + self._session.exec( # type: ignore[call-overload] + delete(PlanModel).where( + col(PlanModel.property_id) == property_id, + col(PlanModel.scenario_id) == scenario_id, + ) + ) + + plan_row = PlanModel.from_domain( + plan, + property_id=property_id, + scenario_id=scenario_id, + portfolio_id=portfolio_id, + is_default=is_default, + ) + self._session.add(plan_row) + self._session.flush() + if plan_row.id is None: + raise ValueError("plan row did not receive an id") + + for measure in plan.measures: + self._session.add( + RecommendationModel.from_domain( + measure, property_id=property_id, plan_id=plan_row.id + ) + ) + return plan_row.id diff --git a/repositories/plan/plan_repository.py b/repositories/plan/plan_repository.py new file mode 100644 index 00000000..02bafe25 --- /dev/null +++ b/repositories/plan/plan_repository.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from domain.modelling.plan import Plan + + +class PlanRepository(ABC): + """Persists a Plan (and its Plan Measures) for a Property + Scenario. + + One Plan per (Property, Scenario). The write is idempotent on re-run: it + replaces the existing Plan for that pair rather than duplicating (ADR-0012 + / ADR-0017). `portfolio_id` and `is_default` are supplied by the + orchestrator (the former from the trigger, the latter from the Scenario). + """ + + @abstractmethod + def save( + self, + plan: Plan, + *, + property_id: int, + scenario_id: int, + portfolio_id: int, + is_default: bool, + ) -> int: + """Persist ``plan`` and return its Plan id, replacing any existing Plan + for ``(property_id, scenario_id)``.""" + ... diff --git a/repositories/postgres_unit_of_work.py b/repositories/postgres_unit_of_work.py index da91604b..fd4489bf 100644 --- a/repositories/postgres_unit_of_work.py +++ b/repositories/postgres_unit_of_work.py @@ -10,10 +10,18 @@ from repositories.property_baseline.property_baseline_postgres_repository import PropertyBaselinePostgresRepository, ) from repositories.epc.epc_postgres_repository import EpcPostgresRepository +from repositories.plan.plan_postgres_repository import PlanPostgresRepository +from repositories.product.product_postgres_repository import ( + ProductPostgresRepository, +) from repositories.property.property_postgres_repository import ( PropertyPostgresRepository, ) +from repositories.scenario.scenario_postgres_repository import ( + ScenarioPostgresRepository, +) from repositories.solar.solar_postgres_repository import SolarPostgresRepository +from repositories.spatial.spatial_postgres_repository import SpatialPostgresRepository from repositories.unit_of_work import UnitOfWork @@ -32,10 +40,17 @@ class PostgresUnitOfWork(UnitOfWork): def __enter__(self) -> "PostgresUnitOfWork": self._session = self._session_factory() epc_repo = EpcPostgresRepository(self._session) - self.property = PropertyPostgresRepository(self._session, epc_repo) + spatial_repo = SpatialPostgresRepository(self._session) + self.property = PropertyPostgresRepository( + self._session, epc_repo, spatial_repo + ) self.epc = epc_repo self.solar = SolarPostgresRepository(self._session) + self.spatial = spatial_repo self.property_baseline = PropertyBaselinePostgresRepository(self._session) + self.scenario = ScenarioPostgresRepository(self._session) + self.product = ProductPostgresRepository(self._session) + self.plan = PlanPostgresRepository(self._session) return self def __exit__( diff --git a/repositories/product/__init__.py b/repositories/product/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/product/product_json_repository.py b/repositories/product/product_json_repository.py new file mode 100644 index 00000000..902f931f --- /dev/null +++ b/repositories/product/product_json_repository.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, cast + +from domain.modelling.contingencies import contingency_rate +from domain.modelling.product import Product +from repositories.product.product_repository import ProductRepository + + +class ProductJsonRepository(ProductRepository): + """Reads Products from a JSON catalogue file — the stopgap source for + costs the ETL does not yet supply, behind the same `ProductRepository` + port as the Postgres-backed catalogue. + + The file maps each Measure Type to its fully-loaded unit cost:: + + {"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}, ...} + + The per-Measure-Type contingency is joined from config (not stored in the + file), exactly as `ProductPostgresRepository` joins it — config stays the + single source of truth for contingency. + """ + + def __init__(self, path: Path) -> None: + with path.open(encoding="utf-8") as handle: + loaded: Any = json.load(handle) + if not isinstance(loaded, dict): + raise ValueError(f"product catalogue {path} is not a JSON object") + self._entries: dict[str, Any] = loaded + + def get(self, measure_type: str) -> Product: + entry: Any = self._entries.get(measure_type) + if entry is None: + raise ValueError(f"no product for measure type {measure_type!r}") + if not isinstance(entry, dict): + raise ValueError(f"product {measure_type!r} entry is not an object") + typed_entry: dict[str, Any] = cast("dict[str, Any]", entry) + unit_cost: Any = typed_entry.get("unit_cost_per_m2") + if isinstance(unit_cost, bool) or not isinstance(unit_cost, (int, float)): + raise ValueError( + f"product {measure_type!r} has no numeric unit_cost_per_m2" + ) + return Product( + measure_type=measure_type, + unit_cost_per_m2=float(unit_cost), + contingency_rate=contingency_rate(measure_type), + ) diff --git a/repositories/product/product_postgres_repository.py b/repositories/product/product_postgres_repository.py new file mode 100644 index 00000000..7f57baa9 --- /dev/null +++ b/repositories/product/product_postgres_repository.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from sqlmodel import Session, col, select + +from domain.modelling.contingencies import contingency_rate +from domain.modelling.product import Product +from infrastructure.postgres.product_table import MaterialRow +from repositories.product.product_repository import ProductRepository + + +class ProductPostgresRepository(ProductRepository): + """Reads the ``material`` catalogue table and maps an active row to a + Product: `total_cost` becomes the fully-loaded `unit_cost_per_m2`, and the + per-Measure-Type contingency is joined from config.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def get(self, measure_type: str) -> Product: + # The live catalogue holds many active rows per type; order by id so the + # pick is deterministic (a re-seed prices the same) rather than relying + # on the database's physical row order. + row: MaterialRow | None = self._session.exec( + select(MaterialRow) + .where( + col(MaterialRow.type) == measure_type, + col(MaterialRow.is_active).is_(True), + ) + .order_by(col(MaterialRow.id)) + ).first() + if row is None: + raise ValueError(f"no active product for measure type {measure_type!r}") + if row.total_cost is None: + raise ValueError(f"product {measure_type!r} has no total_cost") + return Product( + measure_type=measure_type, + unit_cost_per_m2=row.total_cost, + contingency_rate=contingency_rate(measure_type), + id=row.id, + ) diff --git a/repositories/product/product_repository.py b/repositories/product/product_repository.py new file mode 100644 index 00000000..eab7b202 --- /dev/null +++ b/repositories/product/product_repository.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from domain.modelling.product import Product + + +class ProductRepository(ABC): + """Loads Products from the catalogue, abstracting the data source (a + Postgres-backed materials table today; a JSON file for costs the ETL does + not yet supply, behind the same port later). Maps the raw source row into + the `Product` domain object, joining the per-Measure-Type contingency.""" + + @abstractmethod + def get(self, measure_type: str) -> Product: + """Return the Product for a Measure Type, raising if there is no active + catalogue entry.""" + ... diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py index 9f8c4dd4..af036426 100644 --- a/repositories/property/property_postgres_repository.py +++ b/repositories/property/property_postgres_repository.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import Any, Optional, cast +from typing import Optional, cast from sqlalchemy import Table from sqlalchemy import select as sa_select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlmodel import Session, col, select +from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.property.properties import Properties from domain.property.property import Property, PropertyIdentity from infrastructure.postgres.property_table import PropertyRow @@ -15,23 +16,30 @@ from repositories.property.property_repository import ( PropertyIdentityInsert, PropertyRepository, ) +from repositories.spatial.spatial_repository import SpatialRepository class PropertyPostgresRepository(PropertyRepository): """Postgres adapter for the ``property`` table — reads and writes (ADR-0003). Reads hydrate the Property aggregate from the FE-owned row plus the EPC slice - (via an injected `EpcRepository`), so a hydrated Property is a pure function of - repository state. ``epc_repo`` is optional: the Finalise write path - (``insert_all``) creates new identity rows and never hydrates, so callers that - only insert construct this with a session alone. + (via an injected `EpcRepository`) and the planning protections (via an + injected `SpatialRepository`, keyed by UPRN — ADR-0020), so a hydrated + Property is a pure function of repository state. ``epc_repo`` / ``spatial_repo`` + are optional: the Finalise write path (``insert_all``) creates new identity + rows and never hydrates, so callers that only insert construct this with a + session alone. """ def __init__( - self, session: Session, epc_repo: Optional[EpcRepository] = None + self, + session: Session, + epc_repo: Optional[EpcRepository] = None, + spatial_repo: Optional[SpatialRepository] = None, ) -> None: self._session = session self._epc_repo = epc_repo + self._spatial_repo = spatial_repo # ``__table__`` is injected at runtime on table=True classes but the stubs # don't expose it; pin to ``Table`` so the dialect insert is typed. self._table: Table = cast(Table, getattr(PropertyRow, "__table__")) @@ -58,9 +66,13 @@ class PropertyPostgresRepository(PropertyRepository): uprn=row.uprn, landlord_property_id=row.landlord_property_id, ) + restrictions: dict[int, PlanningRestrictions] = self._restrictions_for( + [row.uprn] if row.uprn is not None else [] + ) return Property( identity=identity, epc=self._epc().get_for_property(property_id), + planning_restrictions=_restrictions_of(row.uprn, restrictions), ) def get_many(self, property_ids: list[int]) -> Properties: @@ -71,6 +83,9 @@ class PropertyPostgresRepository(PropertyRepository): ).all() row_by_id = {row.id: row for row in rows} epcs = self._epc().get_for_properties(property_ids) + restrictions: dict[int, PlanningRestrictions] = self._restrictions_for( + [row.uprn for row in rows if row.uprn is not None] + ) items: list[Property] = [] for property_id in property_ids: row = row_by_id.get(property_id) @@ -86,10 +101,18 @@ class PropertyPostgresRepository(PropertyRepository): landlord_property_id=row.landlord_property_id, ), epc=epcs.get(property_id), + planning_restrictions=_restrictions_of(row.uprn, restrictions), ) ) return Properties(items) + def _restrictions_for(self, uprns: list[int]) -> dict[int, PlanningRestrictions]: + # No spatial repo (the write-only Finalise path) → no cached protections; + # `_restrictions_of` then defaults every UPRN to unrestricted. + if not uprns or self._spatial_repo is None: + return {} + return self._spatial_repo.get_for_uprns(uprns) + def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: if not rows: return 0 @@ -119,9 +142,10 @@ class PropertyPostgresRepository(PropertyRepository): ) # SQLModel re-exports SQLAlchemy's Session.execute; one overload is marked - # deprecated in the stubs but the INSERT path is supported. + # deprecated in the stubs, and they resolve the INSERT to a bare + # ``Result`` (no ``rowcount``) — both are stub limitations, not real. result = self._session.execute(stmt) # pyright: ignore[reportDeprecated] - return cast(int, result.rowcount) + return cast(int, result.rowcount) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] def ids_by_uprn(self, portfolio_id: int, uprns: list[int]) -> dict[int, int]: if not uprns: @@ -132,3 +156,13 @@ class PropertyPostgresRepository(PropertyRepository): ) rows = self._session.execute(stmt).all() # pyright: ignore[reportDeprecated] return {int(uprn): int(pid) for uprn, pid in rows if uprn is not None} + + +def _restrictions_of( + uprn: Optional[int], by_uprn: dict[int, PlanningRestrictions] +) -> PlanningRestrictions: + """The cached protections for a UPRN, defaulting to unrestricted when the + UPRN is absent or uncached (per legacy `empty_spatial_df`; ADR-0020).""" + if uprn is None: + return PlanningRestrictions() + return by_uprn.get(uprn, PlanningRestrictions()) diff --git a/repositories/scenario/scenario_postgres_repository.py b/repositories/scenario/scenario_postgres_repository.py new file mode 100644 index 00000000..2afa07a5 --- /dev/null +++ b/repositories/scenario/scenario_postgres_repository.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from sqlmodel import Session, col, select + +from domain.modelling.scenario import Scenario +from infrastructure.postgres.modelling import ScenarioModel +from repositories.scenario.scenario_repository import ScenarioRepository + + +class ScenarioPostgresRepository(ScenarioRepository): + """Reads the live ``scenario`` table (via the ``ScenarioModel`` mirror) and + maps each row to the thin domain ``Scenario`` the Modelling stage uses + (ADR-0017). The legacy file-path / aggregate columns are not read.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def get_many(self, scenario_ids: list[int]) -> list[Scenario]: + rows = self._session.exec( + select(ScenarioModel).where(col(ScenarioModel.id).in_(scenario_ids)) + ).all() + by_id: dict[int, ScenarioModel] = { + row.id: row for row in rows if row.id is not None + } + scenarios: list[Scenario] = [] + for scenario_id in scenario_ids: + row = by_id.get(scenario_id) + if row is None: + raise ValueError(f"no scenario with id {scenario_id}") + scenarios.append(row.to_domain()) + return scenarios diff --git a/repositories/scenario/scenario_repository.py b/repositories/scenario/scenario_repository.py index f560db14..f5d0c252 100644 --- a/repositories/scenario/scenario_repository.py +++ b/repositories/scenario/scenario_repository.py @@ -1,14 +1,20 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod + +from domain.modelling.scenario import Scenario class ScenarioRepository(ABC): - """Loads the Scenarios (and Scenario Snapshots) the Modelling stage scores - a Property against. + """Loads the Scenarios the Modelling stage scores a Property against. - Seam only at this stage (#1136): the method shape is deferred to the - Modelling per-service grill, where Scenario / Scenario Phase / Scenario - Snapshot are designed (CONTEXT.md). Declared now so the pipeline can be - composed end-to-end with Modelling stubbed. + The FE creates a Scenario in the scenario-builder and passes only its id + to the pipeline (#1130); the orchestrator reads it back through this port + at modelling time. Bulk read by id, load-whole per ADR-0012. """ + + @abstractmethod + def get_many(self, scenario_ids: list[int]) -> list[Scenario]: + """Return the Scenarios for ``scenario_ids``, in the same order, + raising if any id has no row.""" + ... diff --git a/repositories/spatial/__init__.py b/repositories/spatial/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/spatial/spatial_postgres_repository.py b/repositories/spatial/spatial_postgres_repository.py new file mode 100644 index 00000000..b7688bfd --- /dev/null +++ b/repositories/spatial/spatial_postgres_repository.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from sqlmodel import Session, col, select + +from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference +from infrastructure.postgres.property_details_spatial_table import ( + PropertyDetailsSpatialRow, +) +from repositories.spatial.spatial_repository import SpatialRepository + + +class SpatialPostgresRepository(SpatialRepository): + def __init__(self, session: Session) -> None: + self._session = session + + def save(self, uprn: int, reference: SpatialReference) -> None: + existing: PropertyDetailsSpatialRow | None = self._session.exec( + select(PropertyDetailsSpatialRow).where( + PropertyDetailsSpatialRow.uprn == uprn + ) + ).first() + row = existing if existing is not None else PropertyDetailsSpatialRow(uprn=uprn) + coordinates: Coordinates | None = reference.coordinates + row.latitude = coordinates.latitude if coordinates is not None else None + row.longitude = coordinates.longitude if coordinates is not None else None + row.conservation_status = reference.restrictions.in_conservation_area + row.is_listed_building = reference.restrictions.is_listed + row.is_heritage_building = reference.restrictions.is_heritage + self._session.add(row) + + def get_for_uprns(self, uprns: list[int]) -> dict[int, PlanningRestrictions]: + if not uprns: + return {} + rows = self._session.exec( + select(PropertyDetailsSpatialRow).where( + col(PropertyDetailsSpatialRow.uprn).in_(uprns) + ) + ).all() + return {row.uprn: _restrictions_from(row) for row in rows} + + +def _restrictions_from(row: PropertyDetailsSpatialRow) -> PlanningRestrictions: + """A cached row's planning protections; a null flag reads as unrestricted.""" + return PlanningRestrictions( + in_conservation_area=bool(row.conservation_status), + is_listed=bool(row.is_listed_building), + is_heritage=bool(row.is_heritage_building), + ) diff --git a/repositories/spatial/spatial_repository.py b/repositories/spatial/spatial_repository.py new file mode 100644 index 00000000..f3ef6125 --- /dev/null +++ b/repositories/spatial/spatial_repository.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference + + +class SpatialRepository(ABC): + """Caches the OS spatial reference data (coordinates + planning flags) by + UPRN — a per-UPRN write-through cache of the S3 reference lookup (ADR-0020). + + Written by Ingestion, read by Modelling (which hydrates the planning + protections onto the Property). One shared row per UPRN; ``save`` upserts. + """ + + @abstractmethod + def save(self, uprn: int, reference: SpatialReference) -> None: ... + + @abstractmethod + def get_for_uprns( + self, uprns: list[int] + ) -> dict[int, PlanningRestrictions]: + """The planning protections for each covered UPRN, keyed by UPRN. + UPRNs with no cached row are omitted (the caller defaults them to + unrestricted).""" + ... diff --git a/repositories/unit_of_work.py b/repositories/unit_of_work.py index cb1cc1d8..1478855c 100644 --- a/repositories/unit_of_work.py +++ b/repositories/unit_of_work.py @@ -6,8 +6,12 @@ from typing import Optional from repositories.property_baseline.property_baseline_repository import PropertyBaselineRepository from repositories.epc.epc_repository import EpcRepository +from repositories.plan.plan_repository import PlanRepository +from repositories.product.product_repository import ProductRepository from repositories.property.property_repository import PropertyRepository +from repositories.scenario.scenario_repository import ScenarioRepository from repositories.solar.solar_repository import SolarRepository +from repositories.spatial.spatial_repository import SpatialRepository class UnitOfWork(ABC): @@ -25,7 +29,15 @@ class UnitOfWork(ABC): property: PropertyRepository epc: EpcRepository solar: SolarRepository + # Per-UPRN cache of the OS spatial reference data, written in Ingestion + # alongside the EPC/solar enrichments (ADR-0020). + spatial: SpatialRepository property_baseline: PropertyBaselineRepository + # Modelling-stage repos (ADR-0017): read the Scenario, read the Product + # catalogue, write the Plan + its Plan Measures — all on the one session. + scenario: ScenarioRepository + product: ProductRepository + plan: PlanRepository @abstractmethod def commit(self) -> None: ... diff --git a/scripts/fetch_epc_bulk_sample.py b/scripts/fetch_epc_bulk_sample.py new file mode 100644 index 00000000..e91546f8 --- /dev/null +++ b/scripts/fetch_epc_bulk_sample.py @@ -0,0 +1,127 @@ +"""Sample a year of EPCs from the gov bulk export into a dump dir, offline-ready. + +Streams `certificates-.json` out of the live bulk ZIP via HTTP range +requests (see `harness.epc_bulk`), keeps the first N records matching the wanted +SAP version, and writes each cert's inner `document` to +`/.json` — exactly the shape +`scripts.run_property_report` (and `from_api_response`) reads. Because it stops +after N matches, only the compressed prefix of the year is downloaded, never the +15.7 GB archive. + + # 100 SAP-10.2 certs from 2026 (guarantees SAP 10.2) into epc_dump/ + python -m scripts.fetch_epc_bulk_sample --year 2026 --limit 100 + + python -m scripts.fetch_epc_bulk_sample --year 2026 --limit 250 --out epc_dump_2026 + +Reads the Bearer token from OPEN_EPC_API_TOKEN (backend/.env). Run from the +worktree root (import trap). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import zipfile +from pathlib import Path + +import httpx +from dotenv import load_dotenv + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap + +from harness.epc_bulk import RangeFile, is_sap_version, parse_bulk_line # noqa: E402 + +_BULK_URL = ( + "https://api.get-energy-performance-data.communities.gov.uk/api/files/domestic/json" +) +_DEFAULT_OUT = _REPO_ROOT / "epc_dump" +# Read the deflate stream in ~4 MB compressed chunks. +_CHUNK = 4 * 1024 * 1024 + + +def _fresh_s3_object(token: str) -> tuple[str, int]: + """Resolve the bulk endpoint's 302 to its temporary S3 URL and total size.""" + redirect = httpx.get( + _BULK_URL, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=30, + follow_redirects=False, + ) + if redirect.status_code != 302: + raise SystemExit(f"expected 302 from bulk endpoint, got {redirect.status_code}") + s3_url: str = redirect.headers["location"] + probe = httpx.get(s3_url, headers={"Range": "bytes=0-0"}, timeout=60) + total: int = int(probe.headers["content-range"].split("/")[-1]) + return s3_url, total + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sample a year from the EPC bulk export.") + parser.add_argument("--year", default="2026", help="certificate year (default 2026)") + parser.add_argument("--limit", type=int, default=100, help="certs to keep") + parser.add_argument("--sap-version", default="10.2", help="SAP version filter") + parser.add_argument("--out", type=Path, default=_DEFAULT_OUT, help="dump directory") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + load_dotenv(_REPO_ROOT / "backend" / ".env") + token = os.environ.get("OPEN_EPC_API_TOKEN") + if not token: + print("OPEN_EPC_API_TOKEN is not set (backend/.env) — cannot fetch") + return 2 + + out: Path = args.out + out.mkdir(parents=True, exist_ok=True) + member = f"certificates-{args.year}.json" + + print(f"resolving bulk archive for {member} (SAP {args.sap_version}, first {args.limit})...") + s3_url, total = _fresh_s3_object(token) + print(f"archive {total / 1e9:.2f} GB — streaming {member} (range requests, early stop)\n") + + written = 0 + scanned = 0 + skipped_version = 0 + range_file = RangeFile(s3_url, total) + archive = zipfile.ZipFile(range_file) + buffer = "" + with archive.open(member) as stream: + while written < args.limit: + compressed = stream.read(_CHUNK) + if not compressed: + break + buffer += compressed.decode("utf-8", errors="replace") + lines = buffer.split("\n") + buffer = lines.pop() # keep the trailing partial line for the next chunk + for line in lines: + parsed = parse_bulk_line(line) + if parsed is None: + continue + scanned += 1 + cert_number, document = parsed + if not is_sap_version(document, args.sap_version): + skipped_version += 1 + continue + (out / f"{cert_number}.json").write_text( + json.dumps(document), encoding="utf-8" + ) + written += 1 + if written >= args.limit: + break + + print( + f"wrote {written} certs ({scanned} scanned, {skipped_version} non-SAP-{args.sap_version}) " + f"-> {out.resolve()}\n" + f"transferred ~{range_file.bytes_read / 1e6:.0f} MB (early stop; full archive is {total / 1e9:.1f} GB)" + ) + print(f"\nnow run: python -m scripts.run_property_report {out}") + range_file.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fetch_epc_dump.py b/scripts/fetch_epc_dump.py new file mode 100644 index 00000000..bc22b35d --- /dev/null +++ b/scripts/fetch_epc_dump.py @@ -0,0 +1,153 @@ +"""Fetch a dump of raw EPC API JSON for a property set, to feed the report. + +Given UPRNs and/or postcodes, hits the live gov.uk EPC API, picks the latest +certificate per match, and writes its raw inner `data` payload — identical in +shape to the committed golden fixtures — to one JSON per cert under a dump dir. +`scripts.run_property_report` then runs that dump offline. + +Keeping the raw JSON (not just the mapped EPC) is what the report's calculator- +error section needs: the cert's lodged `energy_rating_current` lives on it. + + python -m scripts.fetch_epc_dump --uprn 100023336956 100023336957 + python -m scripts.fetch_epc_dump --postcode "SW1A 1AA" --out epc_dump + +Reads the Bearer token from `OPEN_EPC_API_TOKEN` (backend/.env). The API rate- +limits (429); `call_with_retry` backs off, and existing files are skipped, so a +re-run resumes a partial dump. Run from the worktree root (import trap). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any, Optional + +import httpx +from dotenv import load_dotenv + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap + +from infrastructure.epc_client._retry import call_with_retry # noqa: E402 +from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402 +from infrastructure.epc_client.exceptions import ( # noqa: E402 + EpcApiError, + EpcNotFoundError, + EpcRateLimitError, +) + +_DEFAULT_OUT = _REPO_ROOT / "epc_dump" + + +def _headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + +def _latest_cert_for_uprn(token: str, uprn: int) -> Optional[str]: + """Search the API and return the most-recent certificate number for the + UPRN (by registration date), or None when nothing is lodged.""" + resp = httpx.get( + f"{EpcClientService.BASE_URL}/api/domestic/search", + params={"uprn": uprn}, + headers=_headers(token), + timeout=EpcClientService.REQUEST_TIMEOUT, + ) + if resp.status_code == 404: + return None + if resp.status_code == 429: + raise EpcRateLimitError("Rate limited by EPC API") + if not resp.is_success: + raise EpcApiError(f"EPC API search error {resp.status_code}: {resp.text}") + + rows: list[dict[str, Any]] = resp.json().get("data", []) + if not rows: + return None + latest: dict[str, Any] = max(rows, key=lambda row: row["registrationDate"]) + cert: str = latest["certificateNumber"] + return cert + + +def _fetch_raw(token: str, cert_num: str) -> dict[str, Any]: + resp = httpx.get( + f"{EpcClientService.BASE_URL}/api/certificate", + params={"certificate_number": cert_num}, + headers=_headers(token), + timeout=EpcClientService.REQUEST_TIMEOUT, + ) + if resp.status_code == 404: + raise EpcNotFoundError(cert_num) + if resp.status_code == 429: + raise EpcRateLimitError("Rate limited by EPC API") + if not resp.is_success: + raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}") + payload: dict[str, Any] = resp.json()["data"] + return payload + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fetch raw EPC API JSON into a dump dir.") + parser.add_argument("--uprn", nargs="*", type=int, default=[], help="UPRNs to fetch") + parser.add_argument( + "--postcode", nargs="*", default=[], help="postcodes to fetch (all certs)" + ) + parser.add_argument("--out", type=Path, default=_DEFAULT_OUT, help="dump directory") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + if not args.uprn and not args.postcode: + print("give at least one --uprn or --postcode") + return 2 + + load_dotenv(_REPO_ROOT / "backend" / ".env") + token = os.environ.get("OPEN_EPC_API_TOKEN") + if not token: + print("OPEN_EPC_API_TOKEN is not set (backend/.env) — cannot fetch") + return 2 + + out: Path = args.out + out.mkdir(parents=True, exist_ok=True) + + # (kind, value) work-list — UPRNs resolve to one cert, postcodes to many. + cert_nums: list[str] = [] + for uprn in args.uprn: + cert = call_with_retry(lambda u=uprn: _latest_cert_for_uprn(token, u)) + if cert is None: + print(f"no cert uprn={uprn}") + continue + cert_nums.append(cert) + for postcode in args.postcode: + client = EpcClientService(token) + results = call_with_retry(lambda pc=postcode: client.search_by_postcode(pc)) + cert_nums.extend(result.certificate_number for result in results) + + fetched = 0 + skipped = 0 + missing = 0 + for cert_num in cert_nums: + out_path = out / f"{cert_num}.json" + if out_path.exists(): + print(f"skip {cert_num}") + skipped += 1 + continue + try: + raw = call_with_retry(lambda c=cert_num: _fetch_raw(token, c)) + except EpcNotFoundError: + print(f"404 {cert_num}") + missing += 1 + continue + out_path.write_text(json.dumps(raw, indent=2)) + print(f"fetch {cert_num}") + fetched += 1 + + print(f"\nfetched={fetched} skipped={skipped} missing={missing} -> {out.resolve()}") + print(f"now run: python -m scripts.run_property_report {out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run_modelling_cohort.py b/scripts/run_modelling_cohort.py new file mode 100644 index 00000000..d43cc66a --- /dev/null +++ b/scripts/run_modelling_cohort.py @@ -0,0 +1,71 @@ +"""Run an EPC-JSON dump through Modelling offline — print tables + write a CSV. + +The files must be API-shaped EPC JSON (identical to the EPC API response — what +`from_api_response` parses). No database, no network. Run from the worktree root +so imports resolve to this checkout, not /workspaces/model. + + # no args -> the committed golden cohort (57 real API certs) + python -m scripts.run_modelling_cohort + + # your dump, optional goal band (default C) + python -m scripts.run_modelling_cohort path/to/dump C + +Prints a sense-check table for the first measure-bearing certs (a preview, so a +huge dump doesn't flood the terminal), the cohort summary, and writes the full +per-cert results to modelling_cohort.csv for browsing in a spreadsheet. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap + +from harness.cohort import ( # noqa: E402 + format_cohort_csv, + format_cohort_summary, + run_cohort, +) +from harness.plan_table import format_plan_table # noqa: E402 + +_DEFAULT_DIR = _REPO_ROOT / "tests/domain/sap10_calculator/rdsap/fixtures/golden" +_PREVIEW_TABLES = 10 +_CSV_PATH = Path("modelling_cohort.csv") + + +def main() -> None: + args = sys.argv[1:] + directory = Path(args[0]) if args else _DEFAULT_DIR + goal_band = args[1] if len(args) > 1 else "C" + paths = sorted(directory.glob("*.json")) + if not paths: + print(f"no *.json files under {directory}") + raise SystemExit(1) + + print( + f"modelling {len(paths)} EPC JSON(s) from {directory} " + f"(goal band {goal_band}), offline — no database...\n" + ) + results = run_cohort(paths, goal_band=goal_band) + + shown = 0 + for result in results: + if result.plan is not None and result.measures and shown < _PREVIEW_TABLES: + print(f"=== {result.name} ===") + print(format_plan_table(result.plan)) + print() + shown += 1 + measure_bearing = sum(1 for result in results if result.measures) + if measure_bearing > shown: + print(f"... and {measure_bearing - shown} more measure-bearing certs (see CSV)\n") + + print(format_cohort_summary(results)) + + _CSV_PATH.write_text(format_cohort_csv(results) + "\n", encoding="utf-8") + print(f"\nwrote per-cert CSV -> {_CSV_PATH.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py new file mode 100644 index 00000000..e38fdb63 --- /dev/null +++ b/scripts/run_modelling_e2e.py @@ -0,0 +1,398 @@ +"""Run Modelling end-to-end for specific Properties (by ``property_id``) and +print the recommendations for inspection. + +The local DB's Properties have no linked, ingested EPC yet (Ingestion's source +clients are still stubbed — #1136), so this script does the ingestion step +inline: it reads each Property's UPRN from the DB, fetches the latest EPC +**live** from the gov EPC API by UPRN, resolves the UPRN's spatial reference +from S3, and fetches Google Solar — then runs the Modelling stage (every +Recommendation Generator → the Optimiser → a costed, attributed Plan). The same +local computation runs whether or not you store the result: by default it +persists **nothing** (the run is for inspecting recommendations); pass +`--persist` to write the inputs + the Plan to the DB. + +To keep the inspected recommendations identical to what gets stored, **both +modes price against the live ``material`` catalogue (read-only)** and model +against a real **Scenario** read from the DB — not the JSON sample catalogue. +Pass `--scenario-id` to target a real Scenario (its ``goal_value`` drives the +band); without it the run synthesises an Increasing-EPC-to-``--goal`` Scenario. +``--measures`` restricts the run to a comma-separated set of measure types +(mirroring the legacy `inclusions`) — e.g. only HHRSH + Solar PV. + +Config: loads `backend/.env` for the DB creds (`DB_*`), the EPC API token +(`OPEN_EPC_API_TOKEN` — the Bearer token for the new gov API), the Google Solar +key (`GOOGLE_SOLAR_API_KEY`) and the S3 +reference bucket (`DATA_BUCKET`) — the agent never sees the secrets. AWS creds +come from the ambient `~/.aws` profile. Run from the worktree root: + + # inspect only (no DB writes), HHRSH + Solar PV, against Scenario 1263: + python -m scripts.run_modelling_e2e --scenario-id 1263 \ + --measures high_heat_retention_storage_heaters,solar_pv 115 116 117 + # same run, but persist the Plans (needs --portfolio-id): + python -m scripts.run_modelling_e2e --scenario-id 1263 --portfolio-id 4 \ + --measures high_heat_retention_storage_heaters,solar_pv --persist 115 116 117 + python -m scripts.run_modelling_e2e --no-solar 115 116 # skip the Google leg + +Per Property the spatial reference (S3 Open-UPRN parquet) gives the planning +protections (conservation/listed/heritage — gate the wall + solar measures) and +the coordinates that drive the Google Solar fetch (ADR-0026). Buildings S3 +doesn't cover, or that Google has no solar coverage for, fall back to +unrestricted / no-solar and are still modelled. Pass `--no-solar` to skip the +Google leg. +""" + +from __future__ import annotations + +import argparse +import io +import os +import sys +from pathlib import Path +from typing import Any, Optional, cast + +import boto3 +import pandas as pd + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap + +from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402 +from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402 +from domain.geospatial.spatial_reference import SpatialReference # noqa: E402 +from domain.modelling.measure_type import MeasureType # noqa: E402 +from domain.modelling.plan import Plan, PlanMeasure # noqa: E402 +from domain.modelling.scenario import Scenario # noqa: E402 +from harness.console import run_modelling # noqa: E402 +from harness.plan_table import format_plan_table # noqa: E402 +from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402 +from infrastructure.solar.google_solar_api_client import ( # noqa: E402 + BuildingInsightsNotFoundError, + GoogleSolarApiClient, +) +from repositories.geospatial.geospatial_s3_repository import ( # noqa: E402 + GeospatialS3Repository, + ParquetReader, +) +from repositories.product.product_postgres_repository import ( # noqa: E402 + ProductPostgresRepository, +) +from repositories.postgres_unit_of_work import PostgresUnitOfWork # noqa: E402 +from repositories.scenario.scenario_postgres_repository import ( # noqa: E402 + ScenarioPostgresRepository, +) +from sqlalchemy import Engine, create_engine, text # noqa: E402 +from sqlmodel import Session # noqa: E402 + +_ENV_PATH = _REPO_ROOT / "backend" / ".env" +_MARKDOWN_PATH = Path("modelling_e2e.md") +_CSV_PATH = Path("modelling_e2e.csv") + + +def _load_env(path: Path) -> None: + """Load `KEY=value` lines from `backend/.env` into the environment (without + overriding anything already set), so the DB creds + EPC token are present.""" + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def _db_url() -> str: + """The connection string from the FastAPI-layer `DB_*` env vars.""" + env = os.environ + return ( + f"postgresql+psycopg2://{env['DB_USERNAME']}:{env['DB_PASSWORD']}" + f"@{env['DB_HOST']}:{env['DB_PORT']}/{env['DB_NAME']}" + ) + + +def _s3_parquet_reader(bucket: str) -> ParquetReader: + """A `ParquetReader` (key -> DataFrame) backed by `bucket` in S3, for the + `GeospatialS3Repository`. AWS creds come from the ambient `~/.aws` profile; + pyarrow reads the parquet bytes (s3fs is not installed here).""" + # boto3 ships only partial type stubs, so the client is an untyped boundary. + client = cast(Any, boto3.client("s3")) # pyright: ignore[reportUnknownMemberType] + + def read(key: str) -> pd.DataFrame: + body = cast(bytes, client.get_object(Bucket=bucket, Key=key)["Body"].read()) + return pd.read_parquet(io.BytesIO(body)) + + return read + + +def _spatial_for( + repo: GeospatialS3Repository, uprn: int +) -> Optional[SpatialReference]: + """The UPRN's spatial reference (coordinates + planning protections), or + None when S3 doesn't cover it — a missing reference must not abort the run, + so a lookup error degrades to None (unrestricted, no solar).""" + try: + return repo.spatial_for(uprn) + except Exception as error: # noqa: BLE001 — S3/parquet hiccup is non-fatal + print(f" spatial lookup failed for uprn {uprn}: {type(error).__name__}: {error}") + return None + + +def _solar_insights_for( + solar_client: GoogleSolarApiClient, spatial: Optional[SpatialReference] +) -> Optional[dict[str, Any]]: + """The raw Google Solar `buildingInsights` for the reference's coordinates, + or None when there are no coordinates / Google has no coverage there.""" + if spatial is None or spatial.coordinates is None: + return None + try: + return solar_client.get_building_insights( + spatial.coordinates.longitude, spatial.coordinates.latitude + ) + except BuildingInsightsNotFoundError: + return None # no Google solar coverage at this point — model without it + + +def _engine() -> Engine: + """A connection-pooled engine to DevAssessmentModelDB (DB_* creds).""" + return create_engine( + _db_url(), pool_pre_ping=True, connect_args={"connect_timeout": 10} + ) + + +def _uprns_for(engine: Engine, property_ids: list[int]) -> dict[int, Optional[int]]: + """Read each Property's UPRN from the DB (read-only).""" + with engine.connect() as conn: + rows = conn.execute( + text("SELECT id, uprn FROM property WHERE id = ANY(:ids)"), + {"ids": property_ids}, + ).fetchall() + return {int(pid): (int(uprn) if uprn is not None else None) for pid, uprn in rows} + + +def _scenario_for(session: Session, scenario_id: int) -> Scenario: + """Read the Scenario the run targets (read-only). An Increasing-EPC Scenario + must carry a ``goal_value`` (band) — the old null-band rows were a fixed bug + and crash the Optimiser's target — so reject one that does not.""" + scenario: Scenario = ScenarioPostgresRepository(session).get_many([scenario_id])[0] + if scenario.goal == "Increasing EPC" and not scenario.goal_value: + raise ValueError( + f"scenario {scenario_id} has no goal_value (band); pick a recent one" + ) + return scenario + + +def _parse_measures(raw: Optional[str]) -> Optional[frozenset[MeasureType]]: + """Parse `--measures a,b,c` into a `considered_measures` allowlist, or None + (consider every modelled measure) when unset. Raises on an unknown type.""" + if raw is None: + return None + return frozenset(MeasureType(token.strip()) for token in raw.split(",") if token.strip()) + + +def _context_summary( + spatial: Optional[SpatialReference], solar_insights: Optional[dict[str, Any]] +) -> str: + """A one-line note on what the geospatial leg contributed: which planning + protections gated the measures, and whether Google Solar potential fired.""" + if spatial is None: + restrictions_note = "no spatial reference" + else: + flags = [ + name + for name, on in ( + ("conservation", spatial.restrictions.in_conservation_area), + ("listed", spatial.restrictions.is_listed), + ("heritage", spatial.restrictions.is_heritage), + ) + if on + ] + restrictions_note = ", ".join(flags) if flags else "unrestricted" + solar_note = "solar ✓" if solar_insights is not None else "no solar" + return f"{restrictions_note}; {solar_note}" + + +def _measure_summary(measure: PlanMeasure) -> str: + return ( + f" - {measure.measure_type}: " + f"+{measure.impact.sap_points:.2f} SAP · £{measure.cost.total:,.0f} " + f"— {measure.description}" + ) + + +def _persist( + engine: Engine, + *, + property_id: int, + uprn: int, + portfolio_id: int, + scenario: Scenario, + epc: EpcPropertyData, + spatial: Optional[SpatialReference], + solar_insights: Optional[dict[str, Any]], + plan: Plan, +) -> None: + """Write the run's inputs (EPC + spatial + solar) and the computed Plan to + the DB in one Unit of Work, then commit. ``PlanPostgresRepository`` replaces + any existing Plan for ``(property_id, scenario.id)`` (idempotent re-run).""" + with PostgresUnitOfWork(lambda: Session(engine)) as uow: + uow.epc.save(epc, property_id=property_id, portfolio_id=portfolio_id) + if spatial is not None: + uow.spatial.save(uprn, spatial) + if solar_insights is not None: + uow.solar.save(property_id, solar_insights) + uow.plan.save( + plan, + property_id=property_id, + scenario_id=scenario.id, + portfolio_id=portfolio_id, + is_default=scenario.is_default, + ) + uow.commit() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("property_ids", type=int, nargs="+", help="Property ids to model") + parser.add_argument("--goal", default="C", help="target band when no --scenario-id (default C)") + parser.add_argument( + "--scenario-id", type=int, default=None, help="model against this DB Scenario" + ) + parser.add_argument( + "--measures", + default=None, + help="comma-separated measure types to consider (default: all)", + ) + parser.add_argument( + "--portfolio-id", type=int, default=None, help="portfolio id (required for --persist)" + ) + parser.add_argument( + "--persist", + action="store_true", + help="WRITE the inputs + Plan to the DB (default: inspect only, no writes)", + ) + parser.add_argument( + "--no-solar", + action="store_true", + help="skip the live Google Solar fetch (no Solar PV Options)", + ) + args = parser.parse_args() + + if args.persist and (args.scenario_id is None or args.portfolio_id is None): + parser.error("--persist requires --scenario-id and --portfolio-id") + + _load_env(_ENV_PATH) + # The new gov EPC API (Bearer) authenticates with OPEN_EPC_API_TOKEN — the + # name is misleading; EPC_AUTH_TOKEN is dead (403). Verified against the + # /api/domestic/search endpoint. + epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"]) + geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"])) + solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"]) + engine = _engine() + considered = _parse_measures(args.measures) + uprns = _uprns_for(engine, args.property_ids) + # One read-only session for the live `material` catalogue, reused across the + # batch so both store and no-store runs price against the same DB rows. + catalogue_session = Session(engine) + products = ProductPostgresRepository(catalogue_session) + scenario: Optional[Scenario] = ( + _scenario_for(catalogue_session, args.scenario_id) + if args.scenario_id is not None + else None + ) + + target = ( + f"scenario {scenario.id} (band {scenario.goal_value})" + if scenario is not None + else f"synthesised Increasing-EPC band {args.goal}" + ) + measures_note = ",".join(sorted(considered)) if considered else "all measures" + mode = "PERSISTING to DB" if args.persist else "no DB writes" + print( + f"modelling {len(args.property_ids)} propertie(s) · {target} · {measures_note} · " + f"{mode} (DB material catalogue, live EPC/solar)...\n" + ) + + md_lines: list[str] = [f"# Modelling recommendations ({target}, {measures_note})\n"] + csv_rows: list[str] = [ + "property_id,uprn,baseline_sap,post_sap,measures,measure_types,cost_of_works" + ] + + for property_id in args.property_ids: + uprn = uprns.get(property_id) + try: + if uprn is None: + raise ValueError("no UPRN on the property row") + epc: Optional[EpcPropertyData] = epc_client.get_by_uprn(uprn) + if epc is None: + raise ValueError(f"no EPC found for UPRN {uprn}") + spatial: Optional[SpatialReference] = _spatial_for(geospatial, uprn) + restrictions: PlanningRestrictions = ( + spatial.restrictions if spatial is not None else PlanningRestrictions() + ) + solar_insights: Optional[dict[str, Any]] = ( + None if args.no_solar else _solar_insights_for(solar_client, spatial) + ) + plan: Plan = run_modelling( + epc, + goal_band=args.goal, + planning_restrictions=restrictions, + solar_insights=solar_insights, + considered_measures=considered, + products=products, + scenario=scenario, + print_table=False, + ) + if args.persist: + assert scenario is not None # guaranteed by the --persist guard + _persist( + engine, + property_id=property_id, + uprn=uprn, + portfolio_id=args.portfolio_id, + scenario=scenario, + epc=epc, + spatial=spatial, + solar_insights=solar_insights, + plan=plan, + ) + except Exception as error: # noqa: BLE001 — one bad property must not stop the run + line = f"property {property_id} (uprn {uprn}): ERROR — {type(error).__name__}: {error}" + print(line + "\n") + md_lines.append(f"## Property {property_id}\n\n`{line}`\n") + csv_rows.append(f"{property_id},{uprn or ''},,,,ERROR,") + continue + + measure_types = [m.measure_type for m in plan.measures] + context = _context_summary(spatial, solar_insights) + header = ( + f"=== Property {property_id} (uprn {uprn}) === " + f"SAP {plan.baseline.sap_continuous:.1f} -> {plan.post_sap_continuous:.1f} " + f"· {len(plan.measures)} measure(s) · £{plan.cost_of_works:,.0f} · {context}" + ) + print(header) + print(format_plan_table(plan)) + print() + + md_lines.append(f"## Property {property_id} (uprn {uprn})\n") + md_lines.append( + f"SAP {plan.baseline.sap_continuous:.1f} → {plan.post_sap_continuous:.1f} " + f"· {len(plan.measures)} measure(s) · cost £{plan.cost_of_works:,.0f} " + f"· {context}\n" + ) + md_lines.extend(_measure_summary(m) for m in plan.measures) + md_lines.append("") + csv_rows.append( + f"{property_id},{uprn},{plan.baseline.sap_continuous:.2f}," + f"{plan.post_sap_continuous:.2f},{len(plan.measures)}," + f"{'|'.join(measure_types)},{plan.cost_of_works:.0f}" + ) + + catalogue_session.close() + _MARKDOWN_PATH.write_text("\n".join(md_lines) + "\n", encoding="utf-8") + _CSV_PATH.write_text("\n".join(csv_rows) + "\n", encoding="utf-8") + print(f"wrote {_MARKDOWN_PATH.resolve()}") + print(f"wrote {_CSV_PATH.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_property_report.py b/scripts/run_property_report.py new file mode 100644 index 00000000..c40e7420 --- /dev/null +++ b/scripts/run_property_report.py @@ -0,0 +1,70 @@ +"""Build the per-property inspection report over an EPC-JSON dump, offline. + +Reads a directory of API-shaped EPC JSON (identical to the EPC API response — +what `from_api_response` parses), runs each cert through the Modelling harness, +and writes the three-section report (calculator error vs lodged SAP, Plans + +costings, recommended measures + their triggers) as Markdown and CSV. No +database, no network — run it against a cached dump fetched by +`scripts.fetch_epc_dump`. Run from the worktree root so imports resolve to this +checkout, not /workspaces/model. + + # no args -> the committed golden cohort (57 real API certs) + python -m scripts.run_property_report + + # your fetched dump, optional goal band (default C) + python -m scripts.run_property_report epc_dump C +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap + +from harness.report import ( # noqa: E402 + build_property_reports, + format_report_csv, + format_report_markdown, + parity_report_for, +) + +_DEFAULT_DIR = _REPO_ROOT / "tests/domain/sap10_calculator/rdsap/fixtures/golden" +_MARKDOWN_PATH = Path("property_report.md") +_CSV_PATH = Path("property_report.csv") + + +def main() -> None: + args = sys.argv[1:] + directory = Path(args[0]) if args else _DEFAULT_DIR + goal_band = args[1] if len(args) > 1 else "C" + paths = sorted(directory.glob("*.json")) + if not paths: + print(f"no *.json files under {directory}") + raise SystemExit(1) + + print( + f"building inspection report over {len(paths)} EPC JSON(s) from " + f"{directory} (goal band {goal_band}), offline — no database...\n" + ) + reports = build_property_reports(paths, goal_band=goal_band) + + parity = parity_report_for(reports) + flagged = sum(1 for report in reports if report.sap_error_exceeds_threshold) + errored = sum(1 for report in reports if report.calculator_error is not None) + print( + f"calculator parity: {parity.case_count} scorable · " + f"MAE {parity.global_mae:.2f} · bias {parity.global_bias:+.2f}\n" + f"flagged |Δ|>0.5 : {flagged}\n" + f"calculator errors: {errored}" + ) + + _MARKDOWN_PATH.write_text(format_report_markdown(reports), encoding="utf-8") + _CSV_PATH.write_text(format_report_csv(reports) + "\n", encoding="utf-8") + print(f"\nwrote {_MARKDOWN_PATH.resolve()}") + print(f"wrote {_CSV_PATH.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/tests/datatypes/__init__.py b/tests/datatypes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/datatypes/epc/__init__.py b/tests/datatypes/epc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/datatypes/epc/domain/__init__.py b/tests/datatypes/epc/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/datatypes/epc/domain/test_epc.py b/tests/datatypes/epc/domain/test_epc.py new file mode 100644 index 00000000..474c5e89 --- /dev/null +++ b/tests/datatypes/epc/domain/test_epc.py @@ -0,0 +1,26 @@ +"""Behaviour of the Epc band enum's SAP mapping — the band a SAP rating falls +in, and the minimum SAP rating of a band (the inverse, used as an optimisation +target).""" + +from __future__ import annotations + +import pytest + +from datatypes.epc.domain.epc import Epc + + +def test_sap_lower_bound_returns_the_band_floor() -> None: + # Act / Assert — the standard SAP10 band floors. + assert Epc.A.sap_lower_bound() == 92 + assert Epc.B.sap_lower_bound() == 81 + assert Epc.C.sap_lower_bound() == 69 + assert Epc.D.sap_lower_bound() == 55 + assert Epc.E.sap_lower_bound() == 39 + assert Epc.F.sap_lower_bound() == 21 + assert Epc.G.sap_lower_bound() == 1 + + +@pytest.mark.parametrize("band", list(Epc)) +def test_band_floor_round_trips_through_from_sap_score(band: Epc) -> None: + # Act / Assert — a band's floor scores back to that band. + assert Epc.from_sap_score(band.sap_lower_bound()) is band diff --git a/tests/datatypes/epc/domain/test_mapper_cylinder_insulation.py b/tests/datatypes/epc/domain/test_mapper_cylinder_insulation.py new file mode 100644 index 00000000..01869baa --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_cylinder_insulation.py @@ -0,0 +1,49 @@ +"""Mapper boundary: the Elmhurst §15.1 "Insulated" cylinder label. + +A cylinder lodged "No Insulation" is an uninsulated cylinder, not a mapper +gap. Per the no-misleading-insulation convention it maps to +`cylinder_insulation_type = None` (don't name an insulation material on an +uninsulated surface) rather than raising `UnmappedElmhurstLabel`. This +unblocks parsing every solar example cert (the solar `before` cert lodges +"No Insulation"). +""" + +from datatypes.epc.domain.mapper import ( + UnmappedElmhurstLabel, + _elmhurst_cylinder_insulation_code, # pyright: ignore[reportPrivateUsage] +) + + +def test_no_insulation_label_maps_to_none() -> None: + # Arrange + label = "No Insulation" + + # Act + code = _elmhurst_cylinder_insulation_code(label, cylinder_present=True) + + # Assert + assert code is None + + +def test_foam_label_still_maps_to_factory_code() -> None: + # Arrange + label = "Foam" + + # Act + code = _elmhurst_cylinder_insulation_code(label, cylinder_present=True) + + # Assert + assert code == 1 + + +def test_unknown_label_still_raises() -> None: + # Arrange + label = "Spray-on unicorn felt" + + # Act / Assert + try: + _elmhurst_cylinder_insulation_code(label, cylinder_present=True) + raised = False + except UnmappedElmhurstLabel: + raised = True + assert raised diff --git a/tests/domain/billing/__init__.py b/tests/domain/billing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/property_baseline/test_bill_derivation.py b/tests/domain/billing/test_bill_derivation.py similarity index 95% rename from tests/domain/property_baseline/test_bill_derivation.py rename to tests/domain/billing/test_bill_derivation.py index 73239d0f..cce045ee 100644 --- a/tests/domain/property_baseline/test_bill_derivation.py +++ b/tests/domain/billing/test_bill_derivation.py @@ -4,8 +4,8 @@ import pytest from domain.fuel_rates.fuel import Fuel, UnpricedFuel from domain.fuel_rates.fuel_rates import FuelRate, FuelRates -from domain.property_baseline.bill import BillSection, EnergyBreakdown, EnergyLine -from domain.property_baseline.bill_derivation import BillDerivation +from domain.billing.bill import BillSection, EnergyBreakdown, EnergyLine +from domain.billing.bill_derivation import BillDerivation def _rates() -> FuelRates: diff --git a/tests/domain/billing/test_energy_breakdown.py b/tests/domain/billing/test_energy_breakdown.py new file mode 100644 index 00000000..4c64da29 --- /dev/null +++ b/tests/domain/billing/test_energy_breakdown.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import pytest + +from domain.fuel_rates.fuel import Fuel +from domain.billing.bill import BillSection, EnergyBreakdown +from domain.sap10_calculator.calculator import SapResult + + +def _sap_result( + *, + main_heating_fuel_kwh_per_yr: float = 0.0, + main_heating_fuel_code: int | None = None, + main_2_heating_fuel_kwh_per_yr: float = 0.0, + main_2_heating_fuel_code: int | None = None, + secondary_heating_fuel_kwh_per_yr: float = 0.0, + secondary_heating_fuel_code: int | None = None, + hot_water_kwh_per_yr: float = 0.0, + hot_water_fuel_code: int | None = None, + space_cooling_fuel_kwh_per_yr: float = 0.0, + pumps_fans_kwh_per_yr: float = 0.0, + lighting_kwh_per_yr: float = 0.0, + appliances_kwh_per_yr: float = 0.0, + cooking_kwh_per_yr: float = 0.0, + pv_exported_kwh_per_yr: float = 0.0, +) -> SapResult: + return SapResult( + sap_score=72, + sap_score_continuous=72.0, + ecf=0.0, + total_fuel_cost_gbp=0.0, + co2_kg_per_yr=0.0, + space_heating_kwh_per_yr=0.0, + space_cooling_kwh_per_yr=0.0, + fabric_energy_efficiency_kwh_per_m2_yr=0.0, + main_heating_fuel_kwh_per_yr=main_heating_fuel_kwh_per_yr, + main_2_heating_fuel_kwh_per_yr=main_2_heating_fuel_kwh_per_yr, + secondary_heating_fuel_kwh_per_yr=secondary_heating_fuel_kwh_per_yr, + space_cooling_fuel_kwh_per_yr=space_cooling_fuel_kwh_per_yr, + hot_water_kwh_per_yr=hot_water_kwh_per_yr, + pumps_fans_kwh_per_yr=pumps_fans_kwh_per_yr, + lighting_kwh_per_yr=lighting_kwh_per_yr, + appliances_kwh_per_yr=appliances_kwh_per_yr, + cooking_kwh_per_yr=cooking_kwh_per_yr, + main_heating_fuel_code=main_heating_fuel_code, + main_2_heating_fuel_code=main_2_heating_fuel_code, + secondary_heating_fuel_code=secondary_heating_fuel_code, + hot_water_fuel_code=hot_water_fuel_code, + pv_exported_kwh_per_yr=pv_exported_kwh_per_yr, + primary_energy_kwh_per_yr=0.0, + primary_energy_kwh_per_m2=0.0, + monthly=(), + intermediate={}, + ) + + +def test_each_positive_end_use_becomes_a_line_at_its_fuel() -> None: + # Arrange — a gas-boiler home with an electric secondary heater: HEATING + # carries two lines on different fuels; HW is gas; the rest are electricity. + result = _sap_result( + main_heating_fuel_kwh_per_yr=8000.0, + main_heating_fuel_code=1, # mains gas + secondary_heating_fuel_kwh_per_yr=300.0, + secondary_heating_fuel_code=30, # electricity + hot_water_kwh_per_yr=2500.0, + hot_water_fuel_code=1, # mains gas + lighting_kwh_per_yr=400.0, + appliances_kwh_per_yr=1900.0, + cooking_kwh_per_yr=300.0, + ) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + lines = {(line.section, line.fuel): line.kwh for line in breakdown.lines} + assert lines == { + (BillSection.HEATING, Fuel.MAINS_GAS): 8000.0, + (BillSection.HEATING, Fuel.ELECTRICITY): 300.0, + (BillSection.HOT_WATER, Fuel.MAINS_GAS): 2500.0, + (BillSection.LIGHTING, Fuel.ELECTRICITY): 400.0, + (BillSection.APPLIANCES, Fuel.ELECTRICITY): 1900.0, + (BillSection.COOKING, Fuel.ELECTRICITY): 300.0, + } + + +def test_zero_kwh_end_uses_emit_no_line() -> None: + # Arrange — only lighting has energy; everything else is zero. + result = _sap_result(lighting_kwh_per_yr=350.0) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert — exactly one line, no empty HEATING / HOT_WATER / COOLING entries. + assert len(breakdown.lines) == 1 + assert breakdown.lines[0].section == BillSection.LIGHTING + + +def test_cooling_is_billed_as_electricity() -> None: + # Arrange — a home with fixed cooling. + result = _sap_result(space_cooling_fuel_kwh_per_yr=450.0) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + assert len(breakdown.lines) == 1 + line = breakdown.lines[0] + assert (line.section, line.fuel, line.kwh) == ( + BillSection.COOLING, + Fuel.ELECTRICITY, + 450.0, + ) + + +def test_pv_export_carries_to_exported_kwh() -> None: + # Arrange + result = _sap_result(lighting_kwh_per_yr=400.0, pv_exported_kwh_per_yr=1200.0) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + assert breakdown.exported_kwh == 1200.0 + + +def test_raw_api_fuel_code_is_normalized_to_its_billing_fuel() -> None: + # Arrange — the calculator can carry a raw gov-API fuel code (26 = mains gas). + result = _sap_result( + main_heating_fuel_kwh_per_yr=9000.0, main_heating_fuel_code=26 + ) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + assert breakdown.lines[0].fuel == Fuel.MAINS_GAS + + +def test_positive_heating_kwh_with_no_fuel_code_raises() -> None: + # Arrange — energy with no resolvable fuel is a data gap, not a default. + result = _sap_result( + main_heating_fuel_kwh_per_yr=8000.0, main_heating_fuel_code=None + ) + + # Act / Assert + with pytest.raises(ValueError, match="no fuel code"): + EnergyBreakdown.from_sap_result(result) diff --git a/tests/domain/property_baseline/test_sap_fuel.py b/tests/domain/billing/test_sap_fuel.py similarity index 96% rename from tests/domain/property_baseline/test_sap_fuel.py rename to tests/domain/billing/test_sap_fuel.py index dacdb075..ae9dd28f 100644 --- a/tests/domain/property_baseline/test_sap_fuel.py +++ b/tests/domain/billing/test_sap_fuel.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest from domain.fuel_rates.fuel import Fuel -from domain.property_baseline.sap_fuel import sap_code_to_fuel +from domain.billing.sap_fuel import sap_code_to_fuel from domain.sap10_calculator.exceptions import UnmappedSapCode diff --git a/tests/domain/modelling/__init__.py b/tests/domain/modelling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/modelling/_elmhurst_recommendation.py b/tests/domain/modelling/_elmhurst_recommendation.py new file mode 100644 index 00000000..9797f144 --- /dev/null +++ b/tests/domain/modelling/_elmhurst_recommendation.py @@ -0,0 +1,76 @@ +"""Parse an Elmhurst *recommendation* Summary PDF into an EpcPropertyData. + +The Modelling cascade pins use Elmhurst's own before/after measure +re-lodgements as deterministic test vectors: each measure folder under +`sap worksheets/Recommendations Elmhurst Files/` holds a `before` Summary +(the baseline cert) and an `after` Summary (the same cert re-lodged with the +measure applied). Applying the matching Recommendation Generator's overlay to +the parsed `before` must reproduce the calculator's score on the parsed +`after` at delta 0 — proving the overlay is the exact field change Elmhurst +made. + +This routes the Summary PDF through the same extractor + mapper chain the +worksheet e2e fixtures use (`_elmhurst_worksheet_001431.build_epc`), NOT the +Textract `parse_site_notes_pdf` path — that path has an unrelated window +extraction bug on cert 001431. The before/after Summaries are mirrored into +`tests/domain/modelling/fixtures/` so the pins do not depend on the unstaged +workspace. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +_FIXTURES_DIR: Final[Path] = Path(__file__).resolve().parent / "fixtures" + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + `ElmhurstSiteNotesExtractor` expects (label\\nvalue sequences). + + Mirror of the helper in `_elmhurst_worksheet_001431.py`: `pdftotext + -layout` preserves the spatial label/value pairing on each line; we split + on 2+ spaces to surface the tokens, then rejoin newline-delimited. + """ + info: str = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + match = re.search(r"Pages:\s+(\d+)", info) + if match is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(match.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout: str = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def parse_recommendation_summary(fixture_name: str) -> EpcPropertyData: + """Parse a before/after recommendation Summary fixture (by file name in + `tests/domain/modelling/fixtures/`) into an EpcPropertyData.""" + pdf_path: Path = _FIXTURES_DIR / fixture_name + pages: list[str] = _summary_pdf_to_textract_style_pages(pdf_path) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/modelling/fixtures/ashp_from_gas_boiler_001431_before.pdf b/tests/domain/modelling/fixtures/ashp_from_gas_boiler_001431_before.pdf new file mode 100644 index 00000000..f67db819 Binary files /dev/null and b/tests/domain/modelling/fixtures/ashp_from_gas_boiler_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/ashp_from_gas_boiler_instant_hw_001431_before.pdf b/tests/domain/modelling/fixtures/ashp_from_gas_boiler_instant_hw_001431_before.pdf new file mode 100644 index 00000000..0208fb61 Binary files /dev/null and b/tests/domain/modelling/fixtures/ashp_from_gas_boiler_instant_hw_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/ashp_from_system_boiler_with_cylinder_001431_after.pdf b/tests/domain/modelling/fixtures/ashp_from_system_boiler_with_cylinder_001431_after.pdf new file mode 100644 index 00000000..76ed5e22 Binary files /dev/null and b/tests/domain/modelling/fixtures/ashp_from_system_boiler_with_cylinder_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/ashp_from_system_boiler_with_cylinder_001431_before.pdf b/tests/domain/modelling/fixtures/ashp_from_system_boiler_with_cylinder_001431_before.pdf new file mode 100644 index 00000000..9d5906c2 Binary files /dev/null and b/tests/domain/modelling/fixtures/ashp_from_system_boiler_with_cylinder_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_gas_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_after.pdf new file mode 100644 index 00000000..784ab1ca Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_gas_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_before.pdf new file mode 100644 index 00000000..a8fc1809 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_oil_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_after.pdf new file mode 100644 index 00000000..67894d88 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_oil_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_before.pdf new file mode 100644 index 00000000..9f7a6994 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf new file mode 100644 index 00000000..53e1c8d4 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_before.pdf new file mode 100644 index 00000000..de84ab54 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf new file mode 100644 index 00000000..e61f3466 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf new file mode 100644 index 00000000..7403bf93 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf new file mode 100644 index 00000000..6dc4fdf8 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf new file mode 100644 index 00000000..2b7a7287 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/cavity_wall_001431_after.pdf b/tests/domain/modelling/fixtures/cavity_wall_001431_after.pdf new file mode 100644 index 00000000..e4263745 Binary files /dev/null and b/tests/domain/modelling/fixtures/cavity_wall_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/cavity_wall_001431_before.pdf b/tests/domain/modelling/fixtures/cavity_wall_001431_before.pdf new file mode 100644 index 00000000..e08bad0a Binary files /dev/null and b/tests/domain/modelling/fixtures/cavity_wall_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/double_glazing_001431_after.pdf b/tests/domain/modelling/fixtures/double_glazing_001431_after.pdf new file mode 100644 index 00000000..12084c4f Binary files /dev/null and b/tests/domain/modelling/fixtures/double_glazing_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/double_glazing_001431_before.pdf b/tests/domain/modelling/fixtures/double_glazing_001431_before.pdf new file mode 100644 index 00000000..f58a0217 Binary files /dev/null and b/tests/domain/modelling/fixtures/double_glazing_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/flat_roof_001431_after.pdf b/tests/domain/modelling/fixtures/flat_roof_001431_after.pdf new file mode 100644 index 00000000..1457baf6 Binary files /dev/null and b/tests/domain/modelling/fixtures/flat_roof_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/flat_roof_001431_before.pdf b/tests/domain/modelling/fixtures/flat_roof_001431_before.pdf new file mode 100644 index 00000000..2220f616 Binary files /dev/null and b/tests/domain/modelling/fixtures/flat_roof_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/google_building_insights_001431.json b/tests/domain/modelling/fixtures/google_building_insights_001431.json new file mode 100644 index 00000000..76893f69 --- /dev/null +++ b/tests/domain/modelling/fixtures/google_building_insights_001431.json @@ -0,0 +1,2064 @@ +{ + "name": "buildings/ChIJu6gRkhsadkgR3ZyfYd7sHug", + "center": { + "latitude": 51.5930771, + "longitude": -0.17263489999999998 + }, + "regionCode": "GB", + "boundingBox": { + "ne": { + "latitude": 51.5931594, + "longitude": -0.17251819999999998 + }, + "sw": { + "latitude": 51.5930075, + "longitude": -0.172758 + } + }, + "imageryDate": { + "day": 12, + "year": 2022, + "month": 5 + }, + "imageryQuality": "HIGH", + "solarPotential": { + "solarPanels": [ + { + "center": { + "latitude": 51.5930397, + "longitude": -0.1726569 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 406.4439 + }, + { + "center": { + "latitude": 51.59305080000001, + "longitude": -0.1726366 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 402.52658 + }, + { + "center": { + "latitude": 51.593044899999995, + "longitude": -0.17262819999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 404.62482 + }, + { + "center": { + "latitude": 51.5930561, + "longitude": -0.17260789999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 403.4238 + }, + { + "center": { + "latitude": 51.593061999999996, + "longitude": -0.1726163 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 402.6263 + }, + { + "center": { + "latitude": 51.5930732, + "longitude": -0.172596 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 401.73557 + }, + { + "center": { + "latitude": 51.593067299999994, + "longitude": -0.17258750000000003 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 400.45892 + }, + { + "center": { + "latitude": 51.593038899999996, + "longitude": -0.1726197 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 397.46198 + }, + { + "center": { + "latitude": 51.593020599999996, + "longitude": -0.1726711 + }, + "orientation": "PORTRAIT", + "segmentIndex": 3, + "yearlyEnergyDcKwh": 397.16306 + }, + { + "center": { + "latitude": 51.5930501, + "longitude": -0.1725994 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 1, + "yearlyEnergyDcKwh": 396.7709 + }, + { + "center": { + "latitude": 51.5930275, + "longitude": -0.17268129999999998 + }, + "orientation": "PORTRAIT", + "segmentIndex": 3, + "yearlyEnergyDcKwh": 394.17886 + }, + { + "center": { + "latitude": 51.5930344, + "longitude": -0.1726915 + }, + "orientation": "PORTRAIT", + "segmentIndex": 3, + "yearlyEnergyDcKwh": 394.34616 + }, + { + "center": { + "latitude": 51.59307510000001, + "longitude": -0.1725536 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 2, + "yearlyEnergyDcKwh": 329.602 + }, + { + "center": { + "latitude": 51.593051599999995, + "longitude": -0.17268 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 306.43567 + }, + { + "center": { + "latitude": 51.593080199999996, + "longitude": -0.1725441 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 2, + "yearlyEnergyDcKwh": 304.82623 + }, + { + "center": { + "latitude": 51.593092999999996, + "longitude": -0.17256169999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 2, + "yearlyEnergyDcKwh": 307.24442 + }, + { + "center": { + "latitude": 51.5930628, + "longitude": -0.1726598 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 301.32895 + }, + { + "center": { + "latitude": 51.593068599999995, + "longitude": -0.1726681 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 305.9167 + }, + { + "center": { + "latitude": 51.5930799, + "longitude": -0.1726479 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 304.88898 + }, + { + "center": { + "latitude": 51.593057, + "longitude": -0.17265139999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 304.3315 + }, + { + "center": { + "latitude": 51.5930682, + "longitude": -0.17263119999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 305.38583 + }, + { + "center": { + "latitude": 51.593091099999995, + "longitude": -0.1726277 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 303.91528 + }, + { + "center": { + "latitude": 51.5931024, + "longitude": -0.1726074 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 303.26538 + }, + { + "center": { + "latitude": 51.593108199999996, + "longitude": -0.17261579999999999 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 304.74707 + }, + { + "center": { + "latitude": 51.593114, + "longitude": -0.1726242 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 302.9333 + }, + { + "center": { + "latitude": 51.59307450000001, + "longitude": -0.17267649999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 302.04916 + }, + { + "center": { + "latitude": 51.593074099999995, + "longitude": -0.1726395 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 301.7327 + }, + { + "center": { + "latitude": 51.5930853, + "longitude": -0.1726193 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 301.3263 + }, + { + "center": { + "latitude": 51.5930574, + "longitude": -0.1726883 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 301.02057 + }, + { + "center": { + "latitude": 51.59309700000001, + "longitude": -0.17263599999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 300.82922 + }, + { + "center": { + "latitude": 51.593085699999996, + "longitude": -0.17265629999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 300.44504 + }, + { + "center": { + "latitude": 51.593096599999996, + "longitude": -0.1725991 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 299.17642 + }, + { + "center": { + "latitude": 51.5931028, + "longitude": -0.1726444 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 296.2391 + }, + { + "center": { + "latitude": 51.5930916, + "longitude": -0.1726646 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 296.18088 + }, + { + "center": { + "latitude": 51.593063199999996, + "longitude": -0.17269669999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 293.59402 + }, + { + "center": { + "latitude": 51.59305200000001, + "longitude": -0.17271689999999998 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 305.23026 + }, + { + "center": { + "latitude": 51.5930578, + "longitude": -0.1727253 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 301.22372 + }, + { + "center": { + "latitude": 51.5930691, + "longitude": -0.1727051 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 292.2862 + }, + { + "center": { + "latitude": 51.5930803, + "longitude": -0.1726848 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 290.64398 + }, + { + "center": { + "latitude": 51.593119900000005, + "longitude": -0.1726325 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 289.80667 + }, + { + "center": { + "latitude": 51.5931086, + "longitude": -0.1726527 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 288.4848 + }, + { + "center": { + "latitude": 51.5931253, + "longitude": -0.1726039 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 282.09225 + }, + { + "center": { + "latitude": 51.593063699999995, + "longitude": -0.1727336 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 279.7994 + }, + { + "center": { + "latitude": 51.593074900000005, + "longitude": -0.1727134 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 275.29916 + }, + { + "center": { + "latitude": 51.5930981, + "longitude": -0.1725522 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 2, + "yearlyEnergyDcKwh": 275.05432 + }, + { + "center": { + "latitude": 51.593097400000005, + "longitude": -0.172673 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 268.4053 + }, + { + "center": { + "latitude": 51.5930861, + "longitude": -0.1726932 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 268.11542 + }, + { + "center": { + "latitude": 51.5931109, + "longitude": -0.1725698 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 2, + "yearlyEnergyDcKwh": 263.44928 + }, + { + "center": { + "latitude": 51.5931311, + "longitude": -0.1726123 + }, + "orientation": "LANDSCAPE", + "segmentIndex": 0, + "yearlyEnergyDcKwh": 257.0994 + } + ], + "buildingStats": { + "areaMeters2": 184.19221, + "groundAreaMeters2": 142.64, + "sunshineQuantiles": [ + 313.60382, + 508.0561, + 668.3278, + 728.89105, + 742.7094, + 752.1113, + 761.09265, + 780.59656, + 976.15955, + 1005.2664, + 1051.9331 + ] + }, + "wholeRoofStats": { + "areaMeters2": 141.65375, + "groundAreaMeters2": 118.49, + "sunshineQuantiles": [ + 331.97247, + 666.4743, + 728.30896, + 740.458, + 749.04724, + 756.74945, + 764.77496, + 821.7157, + 995.1525, + 1007.2804, + 1051.9331 + ] + }, + "panelWidthMeters": 1.045, + "roofSegmentStats": [ + { + "stats": { + "areaMeters2": 81.86243, + "groundAreaMeters2": 68.14, + "sunshineQuantiles": [ + 331.97247, + 647.7773, + 723.54095, + 731.9598, + 739.6582, + 745.87164, + 750.24554, + 755.70526, + 761.72235, + 773.11017, + 1019.3838 + ] + }, + "center": { + "latitude": 51.5930848, + "longitude": -0.1726575 + }, + "boundingBox": { + "ne": { + "latitude": 51.593143299999994, + "longitude": -0.1725812 + }, + "sw": { + "latitude": 51.5930404, + "longitude": -0.1727561 + } + }, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "planeHeightAtCenterMeters": 95.861626 + }, + { + "stats": { + "areaMeters2": 28.93977, + "groundAreaMeters2": 24.57, + "sunshineQuantiles": [ + 515.37933, + 862.21326, + 989.1015, + 999.8666, + 1002.4724, + 1004.9659, + 1007.32605, + 1009.34906, + 1011.6587, + 1014.892, + 1051.9331 + ] + }, + "center": { + "latitude": 51.59305320000001, + "longitude": -0.17261469999999998 + }, + "boundingBox": { + "ne": { + "latitude": 51.59309, + "longitude": -0.17257 + }, + "sw": { + "latitude": 51.593024799999995, + "longitude": -0.1726705 + } + }, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "planeHeightAtCenterMeters": 96.91601 + }, + { + "stats": { + "areaMeters2": 18.239426, + "groundAreaMeters2": 15.14, + "sunshineQuantiles": [ + 353.97595, + 418.49237, + 657.0197, + 750.6194, + 756.22955, + 760.17053, + 764.10645, + 768.5595, + 775.32855, + 804.11694, + 1034.555 + ] + }, + "center": { + "latitude": 51.5930914, + "longitude": -0.1725575 + }, + "boundingBox": { + "ne": { + "latitude": 51.593123899999995, + "longitude": -0.17252019999999998 + }, + "sw": { + "latitude": 51.5930659, + "longitude": -0.17258569999999998 + } + }, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "planeHeightAtCenterMeters": 96.632774 + }, + { + "stats": { + "areaMeters2": 12.612122, + "groundAreaMeters2": 10.64, + "sunshineQuantiles": [ + 491.0724, + 674.4169, + 747.51666, + 955.5824, + 982.1831, + 991.83075, + 995.9849, + 997.93945, + 1001.9863, + 1016.61017, + 1039.1875 + ] + }, + "center": { + "latitude": 51.593027799999994, + "longitude": -0.17268129999999998 + }, + "boundingBox": { + "ne": { + "latitude": 51.593043599999994, + "longitude": -0.1726451 + }, + "sw": { + "latitude": 51.593010400000004, + "longitude": -0.172722 + } + }, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "planeHeightAtCenterMeters": 96.54124 + } + ], + "panelHeightMeters": 1.879, + "solarPanelConfigs": [ + { + "panelsCount": 4, + "yearlyEnergyDcKwh": 1617.0192, + "roofSegmentSummaries": [ + { + "panelsCount": 4, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 1617.0192 + } + ] + }, + { + "panelsCount": 5, + "yearlyEnergyDcKwh": 2019.6454, + "roofSegmentSummaries": [ + { + "panelsCount": 5, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 2019.6455 + } + ] + }, + { + "panelsCount": 6, + "yearlyEnergyDcKwh": 2421.3809, + "roofSegmentSummaries": [ + { + "panelsCount": 6, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 2421.381 + } + ] + }, + { + "panelsCount": 7, + "yearlyEnergyDcKwh": 2821.8398, + "roofSegmentSummaries": [ + { + "panelsCount": 7, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 2821.84 + } + ] + }, + { + "panelsCount": 8, + "yearlyEnergyDcKwh": 3219.3018, + "roofSegmentSummaries": [ + { + "panelsCount": 8, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3219.302 + } + ] + }, + { + "panelsCount": 9, + "yearlyEnergyDcKwh": 3616.4648, + "roofSegmentSummaries": [ + { + "panelsCount": 8, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3219.302 + }, + { + "panelsCount": 1, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 397.16306 + } + ] + }, + { + "panelsCount": 10, + "yearlyEnergyDcKwh": 4013.2358, + "roofSegmentSummaries": [ + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 1, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 397.16306 + } + ] + }, + { + "panelsCount": 11, + "yearlyEnergyDcKwh": 4407.4146, + "roofSegmentSummaries": [ + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 2, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 791.3419 + } + ] + }, + { + "panelsCount": 12, + "yearlyEnergyDcKwh": 4801.7607, + "roofSegmentSummaries": [ + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 13, + "yearlyEnergyDcKwh": 5131.363, + "roofSegmentSummaries": [ + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 1, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 329.602 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 14, + "yearlyEnergyDcKwh": 5437.7983, + "roofSegmentSummaries": [ + { + "panelsCount": 1, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 306.43567 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 1, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 329.602 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 15, + "yearlyEnergyDcKwh": 5742.6245, + "roofSegmentSummaries": [ + { + "panelsCount": 1, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 306.43567 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 2, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 634.4282 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 16, + "yearlyEnergyDcKwh": 6049.869, + "roofSegmentSummaries": [ + { + "panelsCount": 1, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 306.43567 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 17, + "yearlyEnergyDcKwh": 6351.198, + "roofSegmentSummaries": [ + { + "panelsCount": 2, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 607.7646 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 18, + "yearlyEnergyDcKwh": 6657.1147, + "roofSegmentSummaries": [ + { + "panelsCount": 3, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 913.6813 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 19, + "yearlyEnergyDcKwh": 6962.004, + "roofSegmentSummaries": [ + { + "panelsCount": 4, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 1218.5702 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 20, + "yearlyEnergyDcKwh": 7266.3354, + "roofSegmentSummaries": [ + { + "panelsCount": 5, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 1522.9017 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 21, + "yearlyEnergyDcKwh": 7571.721, + "roofSegmentSummaries": [ + { + "panelsCount": 6, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 1828.2876 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 22, + "yearlyEnergyDcKwh": 7875.636, + "roofSegmentSummaries": [ + { + "panelsCount": 7, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 2132.203 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 23, + "yearlyEnergyDcKwh": 8178.902, + "roofSegmentSummaries": [ + { + "panelsCount": 8, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 2435.4683 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 24, + "yearlyEnergyDcKwh": 8483.648, + "roofSegmentSummaries": [ + { + "panelsCount": 9, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 2740.2153 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 25, + "yearlyEnergyDcKwh": 8786.582, + "roofSegmentSummaries": [ + { + "panelsCount": 10, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 3043.1487 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 26, + "yearlyEnergyDcKwh": 9088.631, + "roofSegmentSummaries": [ + { + "panelsCount": 11, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 3345.1978 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 27, + "yearlyEnergyDcKwh": 9390.364, + "roofSegmentSummaries": [ + { + "panelsCount": 12, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 3646.9304 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 28, + "yearlyEnergyDcKwh": 9691.69, + "roofSegmentSummaries": [ + { + "panelsCount": 13, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 3948.2566 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 29, + "yearlyEnergyDcKwh": 9992.711, + "roofSegmentSummaries": [ + { + "panelsCount": 14, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 4249.2773 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 30, + "yearlyEnergyDcKwh": 10293.54, + "roofSegmentSummaries": [ + { + "panelsCount": 15, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 4550.1064 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 31, + "yearlyEnergyDcKwh": 10593.985, + "roofSegmentSummaries": [ + { + "panelsCount": 16, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 4850.5513 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 32, + "yearlyEnergyDcKwh": 10893.161, + "roofSegmentSummaries": [ + { + "panelsCount": 17, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 5149.7275 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 33, + "yearlyEnergyDcKwh": 11189.4, + "roofSegmentSummaries": [ + { + "panelsCount": 18, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 5445.967 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 34, + "yearlyEnergyDcKwh": 11485.581, + "roofSegmentSummaries": [ + { + "panelsCount": 19, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 5742.1475 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 35, + "yearlyEnergyDcKwh": 11779.176, + "roofSegmentSummaries": [ + { + "panelsCount": 20, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 6035.7417 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 36, + "yearlyEnergyDcKwh": 12084.406, + "roofSegmentSummaries": [ + { + "panelsCount": 21, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 6340.972 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 37, + "yearlyEnergyDcKwh": 12385.63, + "roofSegmentSummaries": [ + { + "panelsCount": 22, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 6642.196 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 38, + "yearlyEnergyDcKwh": 12677.916, + "roofSegmentSummaries": [ + { + "panelsCount": 23, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 6934.482 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 39, + "yearlyEnergyDcKwh": 12968.56, + "roofSegmentSummaries": [ + { + "panelsCount": 24, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 7225.126 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 40, + "yearlyEnergyDcKwh": 13258.366, + "roofSegmentSummaries": [ + { + "panelsCount": 25, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 7514.9326 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 41, + "yearlyEnergyDcKwh": 13546.852, + "roofSegmentSummaries": [ + { + "panelsCount": 26, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 7803.4175 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 42, + "yearlyEnergyDcKwh": 13828.943, + "roofSegmentSummaries": [ + { + "panelsCount": 27, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 8085.51 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 43, + "yearlyEnergyDcKwh": 14108.743, + "roofSegmentSummaries": [ + { + "panelsCount": 28, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 8365.31 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 44, + "yearlyEnergyDcKwh": 14384.042, + "roofSegmentSummaries": [ + { + "panelsCount": 29, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 8640.608 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 3, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 941.67267 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 45, + "yearlyEnergyDcKwh": 14659.097, + "roofSegmentSummaries": [ + { + "panelsCount": 29, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 8640.608 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 4, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 1216.727 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 46, + "yearlyEnergyDcKwh": 14927.502, + "roofSegmentSummaries": [ + { + "panelsCount": 30, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 8909.014 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 4, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 1216.727 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 47, + "yearlyEnergyDcKwh": 15195.617, + "roofSegmentSummaries": [ + { + "panelsCount": 31, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 9177.129 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 4, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 1216.727 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 48, + "yearlyEnergyDcKwh": 15459.066, + "roofSegmentSummaries": [ + { + "panelsCount": 31, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 9177.129 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 5, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 1480.1764 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + }, + { + "panelsCount": 49, + "yearlyEnergyDcKwh": 15716.166, + "roofSegmentSummaries": [ + { + "panelsCount": 32, + "pitchDegrees": 33.65681, + "segmentIndex": 0, + "azimuthDegrees": 316.0337, + "yearlyEnergyDcKwh": 9434.229 + }, + { + "panelsCount": 9, + "pitchDegrees": 31.896425, + "segmentIndex": 1, + "azimuthDegrees": 136.27895, + "yearlyEnergyDcKwh": 3616.073 + }, + { + "panelsCount": 5, + "pitchDegrees": 33.894073, + "segmentIndex": 2, + "azimuthDegrees": 47.24721, + "yearlyEnergyDcKwh": 1480.1764 + }, + { + "panelsCount": 3, + "pitchDegrees": 32.474247, + "segmentIndex": 3, + "azimuthDegrees": 225.10983, + "yearlyEnergyDcKwh": 1185.6881 + } + ] + } + ], + "panelCapacityWatts": 400, + "panelLifetimeYears": 20, + "maxArrayAreaMeters2": 96.21419, + "maxArrayPanelsCount": 49, + "maxSunshineHoursPerYear": 1015.6337, + "carbonOffsetFactorKgPerMwh": 478.99942 + }, + "imageryProcessedDate": { + "day": 18, + "year": 2024, + "month": 4 + } +} \ No newline at end of file diff --git a/tests/domain/modelling/fixtures/hhr_storage_001431_after.pdf b/tests/domain/modelling/fixtures/hhr_storage_001431_after.pdf new file mode 100644 index 00000000..d23b2024 Binary files /dev/null and b/tests/domain/modelling/fixtures/hhr_storage_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/hhr_storage_from_electric_storage_001431_before.pdf b/tests/domain/modelling/fixtures/hhr_storage_from_electric_storage_001431_before.pdf new file mode 100644 index 00000000..f70e0568 Binary files /dev/null and b/tests/domain/modelling/fixtures/hhr_storage_from_electric_storage_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/hhr_storage_from_no_system_001431_before.pdf b/tests/domain/modelling/fixtures/hhr_storage_from_no_system_001431_before.pdf new file mode 100644 index 00000000..797e9a6b Binary files /dev/null and b/tests/domain/modelling/fixtures/hhr_storage_from_no_system_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/loft_001431_after.pdf b/tests/domain/modelling/fixtures/loft_001431_after.pdf new file mode 100644 index 00000000..1b76e032 Binary files /dev/null and b/tests/domain/modelling/fixtures/loft_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/loft_001431_before.pdf b/tests/domain/modelling/fixtures/loft_001431_before.pdf new file mode 100644 index 00000000..6280eca5 Binary files /dev/null and b/tests/domain/modelling/fixtures/loft_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf b/tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf new file mode 100644 index 00000000..a568b1e5 Binary files /dev/null and b/tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf b/tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf new file mode 100644 index 00000000..86b436d1 Binary files /dev/null and b/tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/low_energy_lighting_some_leds_001431_after.pdf b/tests/domain/modelling/fixtures/low_energy_lighting_some_leds_001431_after.pdf new file mode 100644 index 00000000..3bdeda52 Binary files /dev/null and b/tests/domain/modelling/fixtures/low_energy_lighting_some_leds_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/low_energy_lighting_some_leds_001431_before.pdf b/tests/domain/modelling/fixtures/low_energy_lighting_some_leds_001431_before.pdf new file mode 100644 index 00000000..c472af0c Binary files /dev/null and b/tests/domain/modelling/fixtures/low_energy_lighting_some_leds_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/low_energy_lighting_zero_leds_001431_after.pdf b/tests/domain/modelling/fixtures/low_energy_lighting_zero_leds_001431_after.pdf new file mode 100644 index 00000000..3bdeda52 Binary files /dev/null and b/tests/domain/modelling/fixtures/low_energy_lighting_zero_leds_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/low_energy_lighting_zero_leds_001431_before.pdf b/tests/domain/modelling/fixtures/low_energy_lighting_zero_leds_001431_before.pdf new file mode 100644 index 00000000..248b52ef Binary files /dev/null and b/tests/domain/modelling/fixtures/low_energy_lighting_zero_leds_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/secondary_glazing_001431_after.pdf b/tests/domain/modelling/fixtures/secondary_glazing_001431_after.pdf new file mode 100644 index 00000000..d7b1df60 Binary files /dev/null and b/tests/domain/modelling/fixtures/secondary_glazing_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/secondary_glazing_001431_before.pdf b/tests/domain/modelling/fixtures/secondary_glazing_001431_before.pdf new file mode 100644 index 00000000..f58a0217 Binary files /dev/null and b/tests/domain/modelling/fixtures/secondary_glazing_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/sloping_ceiling_001431_after.pdf b/tests/domain/modelling/fixtures/sloping_ceiling_001431_after.pdf new file mode 100644 index 00000000..4d506a80 Binary files /dev/null and b/tests/domain/modelling/fixtures/sloping_ceiling_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/sloping_ceiling_001431_before.pdf b/tests/domain/modelling/fixtures/sloping_ceiling_001431_before.pdf new file mode 100644 index 00000000..9e346c34 Binary files /dev/null and b/tests/domain/modelling/fixtures/sloping_ceiling_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_001431_before.pdf b/tests/domain/modelling/fixtures/solar_pv_001431_before.pdf new file mode 100644 index 00000000..190f4aad Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_1.pdf b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_1.pdf new file mode 100644 index 00000000..f38fbe1c Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_1.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_2.pdf b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_2.pdf new file mode 100644 index 00000000..0cc5da41 Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_2.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_3.pdf b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_3.pdf new file mode 100644 index 00000000..bf3f1e36 Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_3.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_with_battery_001431_after.pdf b/tests/domain/modelling/fixtures/solar_pv_with_battery_001431_after.pdf new file mode 100644 index 00000000..5a76ea7e Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_with_battery_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/solid_brick_ewi_001431_after.pdf b/tests/domain/modelling/fixtures/solid_brick_ewi_001431_after.pdf new file mode 100644 index 00000000..566c1e79 Binary files /dev/null and b/tests/domain/modelling/fixtures/solid_brick_ewi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/solid_brick_ewi_001431_before.pdf b/tests/domain/modelling/fixtures/solid_brick_ewi_001431_before.pdf new file mode 100644 index 00000000..3d0c7396 Binary files /dev/null and b/tests/domain/modelling/fixtures/solid_brick_ewi_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/solid_brick_iwi_001431_after.pdf b/tests/domain/modelling/fixtures/solid_brick_iwi_001431_after.pdf new file mode 100644 index 00000000..9f135a34 Binary files /dev/null and b/tests/domain/modelling/fixtures/solid_brick_iwi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/solid_brick_iwi_001431_before.pdf b/tests/domain/modelling/fixtures/solid_brick_iwi_001431_before.pdf new file mode 100644 index 00000000..3d0c7396 Binary files /dev/null and b/tests/domain/modelling/fixtures/solid_brick_iwi_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/solid_floor_001431_after.pdf b/tests/domain/modelling/fixtures/solid_floor_001431_after.pdf new file mode 100644 index 00000000..ea3ffbfa Binary files /dev/null and b/tests/domain/modelling/fixtures/solid_floor_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/solid_floor_001431_before.pdf b/tests/domain/modelling/fixtures/solid_floor_001431_before.pdf new file mode 100644 index 00000000..187140c0 Binary files /dev/null and b/tests/domain/modelling/fixtures/solid_floor_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/suspended_floor_001431_after.pdf b/tests/domain/modelling/fixtures/suspended_floor_001431_after.pdf new file mode 100644 index 00000000..3cc53118 Binary files /dev/null and b/tests/domain/modelling/fixtures/suspended_floor_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/suspended_floor_001431_before.pdf b/tests/domain/modelling/fixtures/suspended_floor_001431_before.pdf new file mode 100644 index 00000000..49a82bc0 Binary files /dev/null and b/tests/domain/modelling/fixtures/suspended_floor_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_ewi_001431_after.pdf b/tests/domain/modelling/fixtures/system_built_ewi_001431_after.pdf new file mode 100644 index 00000000..1b76e032 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_ewi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_ewi_001431_before.pdf b/tests/domain/modelling/fixtures/system_built_ewi_001431_before.pdf new file mode 100644 index 00000000..0e4830b1 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_ewi_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_iwi_001431_after.pdf b/tests/domain/modelling/fixtures/system_built_iwi_001431_after.pdf new file mode 100644 index 00000000..c943c608 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_iwi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/system_built_iwi_001431_before.pdf b/tests/domain/modelling/fixtures/system_built_iwi_001431_before.pdf new file mode 100644 index 00000000..0e4830b1 Binary files /dev/null and b/tests/domain/modelling/fixtures/system_built_iwi_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/timber_frame_iwi_001431_after.pdf b/tests/domain/modelling/fixtures/timber_frame_iwi_001431_after.pdf new file mode 100644 index 00000000..c24ae877 Binary files /dev/null and b/tests/domain/modelling/fixtures/timber_frame_iwi_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/timber_frame_iwi_001431_before.pdf b/tests/domain/modelling/fixtures/timber_frame_iwi_001431_before.pdf new file mode 100644 index 00000000..fec38abb Binary files /dev/null and b/tests/domain/modelling/fixtures/timber_frame_iwi_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf b/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf new file mode 100644 index 00000000..2d209593 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf b/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf new file mode 100644 index 00000000..7a5b4c13 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf b/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf new file mode 100644 index 00000000..e6c37477 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf b/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf new file mode 100644 index 00000000..ebc431d8 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf differ diff --git a/tests/domain/modelling/test_ashp_cost_inputs.py b/tests/domain/modelling/test_ashp_cost_inputs.py new file mode 100644 index 00000000..c598f9ed --- /dev/null +++ b/tests/domain/modelling/test_ashp_cost_inputs.py @@ -0,0 +1,84 @@ +"""The dwelling interpretation that feeds `Products.ashp_bundle_cost` — reading +an `EpcPropertyData` into a typed `AshpCostInputs` (ADR-0025). This is the +modelling-layer half of the split: it derives the existing system, property +size band, design heat loss (floor-area proxy), radiator count, and whether a +wet system can be reused — the catalogue math (Products) stays EPC-free. +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.heating_recommendation import ashp_cost_inputs +from domain.modelling.products import AshpCostInputs, AshpExistingSystem +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +def test_mains_gas_dwelling_maps_to_a_reusable_wet_gas_system() -> None: + # Arrange — a mains-gas regular boiler with radiators (90 m2, 7 habitable + # rooms): an existing wet system the ASHP can reuse. + epc = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_before.pdf" + ) + + # Act + inputs: AshpCostInputs = ashp_cost_inputs(epc) + + # Assert — gas, large (90 > 75 m2), 4.5 kW (90 x 0.05), 10 radiators + # (7 habitable + 3), reusable wet system. + assert inputs.existing_system is AshpExistingSystem.GAS + assert inputs.is_small_property is False + assert abs(inputs.design_heat_loss_kw - 4.5) <= 1e-9 + assert inputs.radiator_count == 10 + assert inputs.has_reusable_wet_system is True + + +def test_electric_dwelling_has_no_reusable_wet_system() -> None: + # Arrange — an electric storage-heater dwelling (no wet system). + epc = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + + # Act + inputs: AshpCostInputs = ashp_cost_inputs(epc) + + # Assert — electric, so a full new wet distribution is needed. + assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE + assert inputs.has_reusable_wet_system is False + + +def test_classification_keys_on_fuel_not_the_mains_gas_flag() -> None: + # Arrange — the 001431 electric fixtures all lodge mains_gas=True (gas is + # available at the property) while heating electrically (fuel 30). The + # classifier must key on the fuel, not the flag, or it would misread these + # as gas and wrongly reuse a non-existent wet system. + epc = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + + # Act / Assert + assert epc.sap_energy_source.mains_gas is True + inputs: AshpCostInputs = ashp_cost_inputs(epc) + assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE + assert inputs.has_reusable_wet_system is False + + +def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None: + # Arrange — no oil/LPG fixture exists, so mutate an off-gas dwelling's main + # fuel to oil (28) and LPG (27); both are wet boiler systems. + base: EpcPropertyData = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + + def _with_fuel(code: int) -> EpcPropertyData: + clone: EpcPropertyData = copy.deepcopy(base) + clone.sap_energy_source.mains_gas = False + clone.sap_heating.main_heating_details[0].main_fuel_type = code + clone.sap_heating.main_heating_details[0].sap_main_heating_code = 199 + return clone + + # Act / Assert + assert ashp_cost_inputs(_with_fuel(28)).existing_system is AshpExistingSystem.OIL + assert ashp_cost_inputs(_with_fuel(27)).existing_system is AshpExistingSystem.LPG + assert ashp_cost_inputs(_with_fuel(28)).has_reusable_wet_system is True diff --git a/tests/domain/modelling/test_considered_measures.py b/tests/domain/modelling/test_considered_measures.py new file mode 100644 index 00000000..b5b5b4e7 --- /dev/null +++ b/tests/domain/modelling/test_considered_measures.py @@ -0,0 +1,83 @@ +"""Slice 3 — `restrict_to_considered_measures`, the pure allowlist that limits a +run to a chosen set of measure types (mirroring the legacy engine's +`inclusions`). + +It filters at the Option level, so a multi-option Recommendation (e.g. Heating & +Hot Water offering both HHRSH and an ASHP bundle) is kept with only its allowed +Options; a Recommendation left with no allowed Option is dropped entirely. A +None allowlist means "consider everything" (today's unrestricted behaviour). +""" + +from domain.modelling.considered_measures import restrict_to_considered_measures +from domain.modelling.measure_type import MeasureType +from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation + + +def _option(measure_type: MeasureType) -> MeasureOption: + return MeasureOption( + measure_type=measure_type, description=str(measure_type), overlay=EpcSimulation() + ) + + +def _heating_rec() -> Recommendation: + # Heating & Hot Water competes HHRSH against an ASHP bundle in one rec. + return Recommendation( + surface="Heating & Hot Water", + options=( + _option(MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS), + _option(MeasureType.AIR_SOURCE_HEAT_PUMP), + ), + ) + + +def _solar_rec() -> Recommendation: + return Recommendation(surface="Solar PV", options=(_option(MeasureType.SOLAR_PV),)) + + +def _wall_rec() -> Recommendation: + return Recommendation( + surface="Wall", options=(_option(MeasureType.CAVITY_WALL_INSULATION),) + ) + + +def test_none_allowlist_keeps_everything() -> None: + # Arrange + recommendations = [_heating_rec(), _solar_rec(), _wall_rec()] + + # Act + kept = restrict_to_considered_measures(recommendations, None) + + # Assert + assert kept == recommendations + + +def test_drops_recommendations_with_no_allowed_option() -> None: + # Arrange + considered = frozenset( + {MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, MeasureType.SOLAR_PV} + ) + + # Act + kept = restrict_to_considered_measures( + [_heating_rec(), _solar_rec(), _wall_rec()], considered + ) + + # Assert — the wall rec is gone; heating + solar survive. + surfaces = {rec.surface for rec in kept} + assert surfaces == {"Heating & Hot Water", "Solar PV"} + + +def test_filters_options_within_a_kept_recommendation() -> None: + # Arrange — HHRSH is allowed but the competing ASHP bundle is not. + considered = frozenset( + {MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, MeasureType.SOLAR_PV} + ) + + # Act + kept = restrict_to_considered_measures([_heating_rec()], considered) + + # Assert — the heating rec keeps ONLY its HHRSH option, ASHP is dropped. + assert len(kept) == 1 + kept_types = [option.measure_type for option in kept[0].options] + assert kept_types == [MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS] diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py new file mode 100644 index 00000000..0e6832b7 --- /dev/null +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -0,0 +1,1194 @@ +"""Elmhurst before/after cascade pins for the Recommendation Generators. + +Each measure has an Elmhurst `before` Summary (baseline cert) and an `after` +Summary (the same cert re-lodged with the measure applied). The pin drives the +matching generator on the parsed `before`, scores its Option's overlay through +the `PackageScorer`, and asserts the result equals the calculator's score on +the parsed `after` at `abs(diff) <= 1e-4` for SAP / CO2 / primary energy. + +This is the real cert→generator→overlay→calculator cascade, not a per-section +isolation test (see `[[feedback-cascade-pin-methodology]]`): a non-zero delta +is a named generator/overlay/calculator gap to fix, never a tolerance to widen +(`[[feedback-zero-error-strict]]`). +""" + +from __future__ import annotations + +import copy +from dataclasses import replace +from typing import Final + +import pytest + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + PhotovoltaicArray, + PvBatteries, + PvBattery, +) +from domain.modelling.scoring.package_scorer import PackageScorer, Score +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.generators.floor_recommendation import recommend_floor_insulation +from domain.modelling.generators.roof_recommendation import ( + recommend_roof_insulation, +) +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + SolarOverlay, +) +from domain.modelling.generators.wall_recommendation import recommend_cavity_wall +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.generators.solid_wall_recommendation import ( + recommend_solid_wall, +) +from domain.modelling.generators.glazing_recommendation import recommend_glazing +from domain.modelling.generators.lighting_recommendation import recommend_lighting +from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from domain.modelling.recommendation import MeasureOption +from domain.sap10_calculator.calculator import Sap10Calculator, SapResult +from repositories.product.product_repository import ProductRepository +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_001431 import ( + build_epc as build_001431_epc, +) + +# RdSAP §A.2.2 forces a secondary system for electric-storage mains; SAP code +# 402 (slimline storage) is in that set. Code 104 (a gas combi boiler) is not. +_ELECTRIC_STORAGE_MAIN_CODE: Final[int] = 402 +_STANDARD_ELECTRICITY_FUEL: Final[int] = 30 +# SAP 10.2 Table 4a code 691 — electric panel/convector/radiant heaters, the +# fixed secondary the user's example cert lodges. +_SECONDARY_ELECTRIC_PANEL_CODE: Final[int] = 691 + +# Pin tolerance: the Summary PDFs are deterministic test vectors, so the +# overlay must reproduce the re-lodged cert exactly. Matches the worksheet +# e2e tolerance. +_PIN_ABS: Final[float] = 1e-4 + +# RdSAP wall_insulation_type codes for solid-wall insulation (Elmhurst +# Summary "E External" / "I Internal"); cf. domain/sap10_ml/rdsap_uvalues.py. +_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 + + +class _AnyProduct(ProductRepository): + """In-memory ProductRepository returning a fixed Product for any Measure + Type. The pins assert the SAP cascade, not Cost, so the unit cost is + immaterial — only the generator's overlay is exercised.""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=1.0, contingency_rate=0.0 + ) + + +def _assert_overlay_reproduces_after( + before: EpcPropertyData, after: EpcPropertyData, overlay: EpcSimulation +) -> None: + """Score ``overlay`` on ``before`` and assert it matches the calculator's + score on the re-lodged ``after`` across all three metrics.""" + calculator = Sap10Calculator() + relodged: SapResult = calculator.calculate(after) + scored: Score = PackageScorer(calculator).score(before, [overlay]) + + assert abs(scored.sap_continuous - relodged.sap_score_continuous) <= _PIN_ABS + assert abs(scored.co2_kg_per_yr - relodged.co2_kg_per_yr) <= _PIN_ABS + assert ( + abs(scored.primary_energy_kwh_per_yr - relodged.primary_energy_kwh_per_yr) + <= _PIN_ABS + ) + + +def _assert_overlay_scores( + before: EpcPropertyData, + overlay: EpcSimulation, + *, + sap: float, + co2: float, + pe: float, +) -> None: + """Score ``overlay`` on ``before`` and assert it matches the given snapshot + of SAP / CO2 / primary energy. Used where the relodged after-cert predates + the Vaillant product swap (it lodges the old heat-pump index): the snapshot + is taken as correct because the same overlay reproduces the corrected + Vaillant cert at delta 0 in the boiler-3 pin (ADR-0025).""" + scored: Score = PackageScorer(Sap10Calculator()).score(before, [overlay]) + assert abs(scored.sap_continuous - sap) <= _PIN_ABS + assert abs(scored.co2_kg_per_yr - co2) <= _PIN_ABS + assert abs(scored.primary_energy_kwh_per_yr - pe) <= _PIN_ABS + + +def test_cavity_wall_overlay_reproduces_the_relodged_after() -> None: + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "cavity_wall_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "cavity_wall_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_cavity_wall( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_solid_brick_ewi_overlay_reproduces_the_relodged_after() -> None: + # Arrange — 100 mm external wall insulation on a solid-brick main wall. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_after.pdf" + ) + overlay = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=_WALL_INSULATION_EXTERNAL, + wall_insulation_thickness=_SOLID_WALL_INSULATION_MM, + ) + } + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, overlay) + + +def test_solid_brick_iwi_overlay_reproduces_the_relodged_after() -> None: + # Arrange — 100 mm internal wall insulation on a solid-brick main wall + # (also lowers the thermal-mass parameter, unlike EWI). + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_iwi_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "solid_brick_iwi_001431_after.pdf" + ) + overlay = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=_WALL_INSULATION_INTERNAL, + wall_insulation_thickness=_SOLID_WALL_INSULATION_MM, + ) + } + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, overlay) + + +def test_solid_brick_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None: + # Arrange — one uninsulated solid-brick before, two re-lodged afters. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + ewi_after: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_after.pdf" + ) + iwi_after: EpcPropertyData = parse_recommendation_summary( + "solid_brick_iwi_001431_after.pdf" + ) + + # Act — solid brick is suitable for both, unrestricted. + recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct()) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — both Options offered, and each Option's overlay reproduces its + # own re-lodged after at the pin tolerance. + assert set(options) == {"external_wall_insulation", "internal_wall_insulation"} + _assert_overlay_reproduces_after( + before, ewi_after, options["external_wall_insulation"].overlay + ) + _assert_overlay_reproduces_after( + before, iwi_after, options["internal_wall_insulation"].overlay + ) + + +def test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None: + # Arrange — system-built (precast concrete) takes both Options like solid + # brick (ADR-0019): one uninsulated before, two re-lodged afters. + before: EpcPropertyData = parse_recommendation_summary( + "system_built_ewi_001431_before.pdf" + ) + ewi_after: EpcPropertyData = parse_recommendation_summary( + "system_built_ewi_001431_after.pdf" + ) + iwi_after: EpcPropertyData = parse_recommendation_summary( + "system_built_iwi_001431_after.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct()) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — both Options offered, each reproducing its own re-lodged after. + assert set(options) == {"external_wall_insulation", "internal_wall_insulation"} + _assert_overlay_reproduces_after( + before, ewi_after, options["external_wall_insulation"].overlay + ) + _assert_overlay_reproduces_after( + before, iwi_after, options["internal_wall_insulation"].overlay + ) + + +def test_timber_frame_generator_offers_iwi_only_pinning_its_after() -> None: + # Arrange — timber frame takes IWI but EWI is not constructable (ADR-0019). + before: EpcPropertyData = parse_recommendation_summary( + "timber_frame_iwi_001431_before.pdf" + ) + iwi_after: EpcPropertyData = parse_recommendation_summary( + "timber_frame_iwi_001431_after.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct()) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — IWI only, and it reproduces the re-lodged after. + assert set(options) == {"internal_wall_insulation"} + _assert_overlay_reproduces_after( + before, iwi_after, options["internal_wall_insulation"].overlay + ) + + +def test_conservation_area_drops_ewi_but_keeps_iwi() -> None: + # Arrange — a conservation area blocks the external-appearance change only. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_solid_wall( + before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True) + ) + + # Assert — IWI survives, EWI is gone. + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "internal_wall_insulation" + } + + +def test_listed_building_blocks_all_solid_wall_insulation() -> None: + # Arrange — listed/heritage protect the fabric, so both EWI and IWI go. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act / Assert + assert ( + recommend_solid_wall( + before, _AnyProduct(), PlanningRestrictions(is_listed=True) + ) + is None + ) + + +def test_flat_drops_ewi_but_keeps_iwi() -> None: + # Arrange — a flat can take IWI to its own unit, but EWI needs whole-block + # coordination (ADR-0019). property_type "Flat" is the Elmhurst name form. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + flat: EpcPropertyData = replace(before, property_type="Flat") + + # Act + recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct()) + + # Assert + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "internal_wall_insulation" + } + + +def test_flat_detected_from_api_property_type_code() -> None: + # Arrange — the API path lodges property_type as a stringified code + # ("2" = Flat per PROPERTY_TYPE_LOOKUP), not the name. + before: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + flat: EpcPropertyData = replace(before, property_type="2") + + # Act + recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct()) + + # Assert — same gate fires regardless of representation. + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "internal_wall_insulation" + } + + +def test_cavity_wall_gets_no_solid_wall_recommendation() -> None: + # Arrange — a cavity wall is handled by recommend_cavity_wall, never here. + before: EpcPropertyData = parse_recommendation_summary( + "cavity_wall_001431_before.pdf" + ) + + # Act / Assert + assert recommend_solid_wall(before, _AnyProduct()) is None + + +def test_loft_overlay_reproduces_the_relodged_after() -> None: + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "loft_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "loft_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_roof_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None: + # Arrange — a pitched roof with an uninsulated sloping ceiling; the re-lodged + # after raises its insulation from As Built to 100 mm (ADR-0021). + before: EpcPropertyData = parse_recommendation_summary( + "sloping_ceiling_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "sloping_ceiling_001431_after.pdf" + ) + + # Act — the dispatcher detects "sloping ceiling" and offers the sloping + # measure (not loft). + recommendation: Recommendation | None = recommend_roof_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — one sloping-ceiling Option whose overlay reproduces the after. + assert set(options) == {"sloping_ceiling_insulation"} + _assert_overlay_reproduces_after( + before, after, options["sloping_ceiling_insulation"].overlay + ) + + +def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() -> None: + # Arrange — a thatched pitched roof. Thatch is NOT excluded: the covering + # doesn't block insulating the loft floor, so it takes loft (joist) + # insulation, re-lodged None → 300 mm (ADR-0021). + before: EpcPropertyData = parse_recommendation_summary( + "loft_thatched_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "loft_thatched_001431_after.pdf" + ) + + # Act — the dispatcher routes a thatched roof to the loft branch. + recommendation: Recommendation | None = recommend_roof_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — one loft Option whose overlay reproduces the after. + assert set(options) == {"loft_insulation"} + _assert_overlay_reproduces_after( + before, after, options["loft_insulation"].overlay + ) + + +def test_roof_generator_insulates_a_flat_roof_pinning_its_after() -> None: + # Arrange — a flat roof, uninsulated (As Built → None on the Elmhurst path); + # the re-lodged after raises it to 200 mm (ADR-0021). + before: EpcPropertyData = parse_recommendation_summary( + "flat_roof_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "flat_roof_001431_after.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — one flat-roof Option whose overlay reproduces the after. + assert set(options) == {"flat_roof_insulation"} + _assert_overlay_reproduces_after( + before, after, options["flat_roof_insulation"].overlay + ) + + +def test_solid_floor_overlay_reproduces_the_relodged_after() -> None: + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "solid_floor_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "solid_floor_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_floor_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None: + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "suspended_floor_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "suspended_floor_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_floor_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None: + # The full-SAP pin below is xfail (draught-proofing coupling), but the + # overlay's actual job — turning every single-glazed window into the + # relodged spec — is deterministic and must hold exactly: it proves the + # generator detects BOTH single-glazing codes (1 and 15) on the real cert. + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct()) + assert recommendation is not None + + # Act — apply the overlay to the parsed before. + applied: EpcPropertyData = apply_simulations( + before, [recommendation.options[0].overlay] + ) + + # Assert — every window's glazing_type + lodged U/g matches the after. + def _window_spec(epc: EpcPropertyData) -> list[tuple[object, object, object]]: + specs: list[tuple[object, object, object]] = [] + for window in epc.sap_windows: + details = window.window_transmission_details + specs.append( + ( + window.glazing_type, + details.u_value if details is not None else None, + details.solar_transmittance if details is not None else None, + ) + ) + return specs + + assert _window_spec(applied) == _window_spec(after) + + +_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = ( + "Blocked on the glazing measure's draught-proofing coupling. The window " + "U/g overlay reproduces the after's 14 windows EXACTLY (all four single-" + "glazed panes — codes 1 and 15 — become the relodged double/secondary " + "spec). The residual ~0.7 SAP is a secondary effect the overlay does not " + "model: replacing the single-glazed (lodged draught_proofed=No) windows " + "with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and " + "lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet " + "isolated. Flips green once the glazing overlay propagates draught-proofing " + "(and the residual fabric coupling is modelled)." +) + + +@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON) +def test_double_glazing_overlay_reproduces_the_relodged_after() -> None: + # Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15, + # "single glazing, known data"); the after re-lodges every one as double + # (gt=5, U=1.40, g=0.72). + before: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct()) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON) +def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a planning protection forces secondary glazing; the after + # re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85). + before: EpcPropertyData = parse_recommendation_summary( + "secondary_glazing_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "secondary_glazing_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_glazing( + before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True) + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_lighting_overlay_reproduces_the_relodged_after_zero_existing_leds() -> None: + # Arrange — a dwelling with no LEDs (20 incandescent); the after re-lodges + # all 20 as LED. Lighting only changes bulb counts → Appendix L (232), with + # no fabric coupling, so the full-SAP pin closes cleanly. + before: EpcPropertyData = parse_recommendation_summary( + "low_energy_lighting_zero_leds_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "low_energy_lighting_zero_leds_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_lighting(before, _AnyProduct()) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_lighting_overlay_reproduces_the_relodged_after_some_existing_leds() -> None: + # Arrange — a dwelling with some LEDs already (5 LED + 15 incandescent); the + # after re-lodges all 20 as LED. Exercises the partial-upgrade path: the + # overlay tops led up to the total rather than starting from zero. + before: EpcPropertyData = parse_recommendation_summary( + "low_energy_lighting_some_leds_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "low_energy_lighting_some_leds_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_lighting(before, _AnyProduct()) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_hhr_storage_overlay_reproduces_the_relodged_after_from_electric_storage() -> None: + # Arrange — an existing electric storage system re-lodged as high-heat- + # retention storage (Table 4a 402 -> 409, control 2401 -> 2404), gaining an + # off-peak immersion cylinder and a dual meter (ADR-0024). + before: EpcPropertyData = parse_recommendation_summary( + "hhr_storage_from_electric_storage_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "hhr_storage_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o + for o in recommendation.options + if o.measure_type == "high_heat_retention_storage_heaters" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_hhr_storage_overlay_reproduces_the_relodged_after_from_no_system() -> None: + # Arrange — a "no system present" electric dwelling re-lodged as HHR storage; + # the same absolute-target overlay must reproduce the common after. + before: EpcPropertyData = parse_recommendation_summary( + "hhr_storage_from_no_system_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "hhr_storage_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o + for o in recommendation.options + if o.measure_type == "high_heat_retention_storage_heaters" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None: + # Arrange — a typical mains-gas combi house re-cast as an air-source heat + # pump (fuel 26 -> 30, SAP code 104 -> Vaillant aroTHERM plus 5 kW index + # 110257 + category 4, control 2106 -> 2210), off mains gas, gaining a heat- + # pump cylinder (ADR-0024). The boiler-1 after-cert predates the Vaillant + # swap (it lodges the old index 101413), so this snapshots the Vaillant + # overlay's own output rather than re-pinning a stale relodged PDF — taken as + # correct because the same overlay reproduces the corrected Vaillant cert at + # delta 0 in the system-boiler pin below. + before: EpcPropertyData = parse_recommendation_summary( + "ashp_from_gas_boiler_001431_before.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "air_source_heat_pump" + ) + + # Act / Assert — re-pinned after merging main's fabric fixes (roof "Unknown" + # U → Table 18 default, Room-in-Roof U leak), which shift this 001431 + # dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot + # of the Vaillant overlay's own output, validated transitively by the + # system-boiler pin below (which reproduces a real Vaillant cert at delta 0). + _assert_overlay_scores( + before, + option.overlay, + sap=51.99820176096402, + co2=1268.4645083243888, + pe=13080.20756425629, + ) + + +def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw() -> None: + # Arrange — a gas boiler whose hot water is electric/instantaneous (water- + # heating SAP code 909, no cylinder) re-cast as an ASHP. Exercises the + # overlay's water_heating_code reset (909 -> 901, "from the heat pump") that + # boiler-1 didn't (its HW was already 901). Snapshots the Vaillant overlay's + # output (the after-cert predates the Vaillant swap), validated transitively + # by the system-boiler pin below. + before: EpcPropertyData = parse_recommendation_summary( + "ashp_from_gas_boiler_instant_hw_001431_before.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "air_source_heat_pump" + ) + + # Act / Assert — re-pinned after merging main's fabric fixes (see the + # boiler-1 pin above); the same merge also resolved this cert's main-fuel + # mapper gap (§14.2 mains-gas derivation), so its raw before now baselines — + # see `test_gas_boiler_instant_hw_before_baselines`. + _assert_overlay_scores( + before, + option.overlay, + sap=39.00740809309464, + co2=2248.6089062232704, + pe=23094.10189037302, + ) + + +def test_ashp_overlay_reproduces_the_relodged_after_from_a_system_boiler_with_cylinder() -> None: + # Arrange — a mains-gas *regular/system* boiler (SAP code 101, not a combi) + # that already heats its own hot-water cylinder (size 2 / insulation type 2 / + # 80 mm) re-lodged as an ASHP. This exercises the cylinder OVERWRITE path that + # boiler-1/boiler-2 didn't: those added a cylinder where none existed, whereas + # here the overlay must overwrite the existing cylinder to the fixed heat-pump + # cylinder (size 4 / insulation type 1 / 50 mm). The dwelling also goes off + # mains gas (fuel 26 -> 30, code 101 -> Vaillant aroTHERM plus 5 kW index + # 110257 + category 4, control 2113 -> 2210). After-cert re-lodged with the + # Vaillant: ASHP raises this dwelling's SAP 63.85 -> 72.30. + before: EpcPropertyData = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "air_source_heat_pump" + ) + + # Act / Assert — the absolute overlay overwrites the existing cylinder and + # reproduces the after exactly. + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_gas_boiler_instant_hw_before_baselines() -> None: + # The Modelling pipeline baselines the dwelling before modelling it, so the + # before must be scorable on its own. This was previously blocked: the gas + # boiler lodged with EES 'BGB' / SAP code 102 derived no main_fuel_type, so + # Sap10Calculator raised MissingMainFuelType. Merging main's mapper fix + # (resolve gas-boiler main fuel from the §14.2 mains-gas meter) closed that + # gap, so the raw before now baselines. + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "ashp_from_gas_boiler_instant_hw_001431_before.pdf" + ) + + # Act + result: SapResult = Sap10Calculator().calculate(before) + + # Assert — it baselines without raising, on mains gas. + assert result.sap_score_continuous > 0.0 + + +def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a mains-gas wet boiler (SAP code 114) heating an uninsulated + # hot-water cylinder (no insulation, no thermostat) re-lodged as a new gas + # condensing boiler with a cylinder (SAP code 102, fanned flue), the cylinder + # jacketed (insulation type 2 / 80 mm) and given a thermostat. The boiler + # upgrade leaves the (already adequate) controls + cylinder size + meter + # unchanged. Validates the boiler-with-cylinder option end-state at delta 0. + # + # NB the absolute SAP on this dwelling is subject to a separate Summary-path + # mapper roof-fidelity gap (our calculator reads the roof better-insulated + # than Elmhurst, so it scores ~75 where Elmhurst prints 56); the gap is + # identical on before + after (the boiler measure never touches the roof), so + # it cancels and this pin still proves the overlay applies Elmhurst's exact + # heating field-delta. Tracked on the calculator branch, not here. + before: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + # The cert lodges code 114 (already condensing), which the efficiency gate + # excludes from a like-for-like swap; recast to a pre-1998 non-condensing + # boiler (110) so the upgrade is offered. The overlay overwrites the code to + # 102 regardless, so this changes only eligibility, not the validated result. + before.sap_heating.main_heating_details[0].sap_main_heating_code = 110 + after: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_boiler_combi_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a mains-gas combi (SAP code 112, no cylinder) with inadequate + # controls (2111 "TRVs and bypass" — no room thermostat, so no boiler + # interlock) re-lodged as a new gas condensing combi (code 104, fanned flue) + # with full programmer + room thermostat + TRV controls (2106). No cylinder, + # so no cylinder components. Validates the combi end-state + the controls- + # when-inadequate upgrade at delta 0. (Same Summary-path roof gap as the + # with-cylinder pin — it cancels across before/after.) + before: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_gas_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_gas_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_oil_combi_overlay_reproduces_the_relodged_after() -> None: + # Arrange — an OIL combi (fuel 28, SAP code 130, no cylinder) on a mains-gas + # street re-lodged as a gas condensing combi (fuel 28->26, code 104, fanned + # flue). Validates the non-gas -> gas conversion: the upgrade targets gas + # because a mains-gas connection is present (ADR-0024 revised). Controls are + # already adequate (2106), so they are unchanged. + before: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_oil_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_oil_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_boiler_with_already_insulated_cylinder_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a gas boiler heating an ALREADY-jacketed cylinder (insulation + # type 2 / 80 mm) with no thermostat, re-lodged as a new gas condensing + # boiler (code 102) with a cylinder thermostat added. Validates the cylinder + # path's skip-jacket branch (the 80 mm jacket is not re-applied) while the + # thermostat is still added. (Sourced from an LPG re-lodgement; the Summary + # mapper reads its fuel as mains gas — fuel 26 — so this exercises the gas + # cylinder path, not a true LPG conversion. The LPG fuel-mapping gap is a + # separate mapper-front concern.) + before: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_lpg_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_lpg_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_coal_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a SOLID-FUEL (coal) boiler (fuel 11, SAP code 153) heating a + # cylinder, on a mains-gas street, re-lodged as a gas condensing boiler + # (fuel 11->26, code 102, fanned flue + boiler flue type 2). Exercises the + # non-gas -> gas conversion for a solid-fuel boiler AND the new + # `boiler_flue_type` end-state (coal's before lodged none; every other cert + # already lodged flue type 2). The cylinder is already 80 mm insulated so the + # jacket is skipped; only the thermostat is added. + # + # The relodged after predates the user-locked "always add a cylinder + # thermostat when absent" rule, so it stale-lodged thermostat 'N'; the test + # corrects it to the rule's end-state 'Y' (the same correction the gas + # with-cylinder after received by re-lodging). Controls are already adequate + # (2106), so they are unchanged. + before: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_coal_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_coal_001431_after.pdf" + ) + after.sap_heating.cylinder_thermostat = "Y" + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +@pytest.mark.parametrize( + "before_fixture, after_fixture, measure_type", + [ + # The system tune-up keeps the existing boiler and forces the heating + # controls to a fixed end-state (standard 2106 / zone 2110) ABSOLUTELY — + # proven by reproducing each common after from two different starting + # controls (2101 "no control" and 2113 "room thermostat and TRVs") — plus + # the conditional cylinder jacket + thermostat (both befores are + # uninsulated / un-thermostatted, so both fire). + ( + "tune_up_from_2101_001431_before.pdf", + "tune_up_standard_001431_after.pdf", + "system_tune_up", + ), + ( + "tune_up_from_2113_001431_before.pdf", + "tune_up_standard_001431_after.pdf", + "system_tune_up", + ), + ( + "tune_up_from_2101_001431_before.pdf", + "tune_up_zoned_001431_after.pdf", + "system_tune_up_zoned", + ), + ( + "tune_up_from_2113_001431_before.pdf", + "tune_up_zoned_001431_after.pdf", + "system_tune_up_zoned", + ), + ], +) +def test_system_tune_up_overlay_reproduces_the_relodged_after( + before_fixture: str, after_fixture: str, measure_type: str +) -> None: + # Arrange + before: EpcPropertyData = parse_recommendation_summary(before_fixture) + after: EpcPropertyData = parse_recommendation_summary(after_fixture) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == measure_type + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +# --- Solar PV cascade pins (ADR-0026) ------------------------------------- +# +# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp, +# varied orientation/pitch/overshading) — deterministic test vectors chosen to +# exercise the overlay -> calculator PV path across the config space, NOT the +# Google-derived production arrays. So these pins hand-build the SolarOverlay +# matching each after-cert's lodged arrays (the generator's own overlay is +# Google-sourced and validated separately in test_solar_recommendation / +# test_solar_overshading); the cascade proves `_fold_solar` + the calculator +# reproduce Elmhurst's PV re-lodgement exactly. +# +# All five certs share one main-heating system lodged with EES code 'WGK' / +# Main Heating SAP code 502, which the Elmhurst mapper does not yet derive a +# main_fuel_type for (it maps to '' -> MissingMainFuelType). The solar overlay +# never touches heating, so the pins patch the shared fuel to mains gas (26) on +# both before and after identically — the heating contribution is then equal on +# both sides and the delta isolates the PV change. The unresolved raw baseline +# is a separate mapper-front gap, tripwired by `test_solar_before_baselines`. + +_SOLAR_MAINS_GAS_FUEL: Final[int] = 26 + + +def _parse_solar(fixture_name: str) -> EpcPropertyData: + """Parse a solar before/after Summary, patching the shared main-heating + fuel to mains gas (the EES 'WGK' / SAP code 502 mapper-front gap — see the + section note). Applied identically on before + after, so it cancels in the + PV delta.""" + epc: EpcPropertyData = parse_recommendation_summary(fixture_name) + main = epc.sap_heating.main_heating_details[0] + if not main.main_fuel_type: + main.main_fuel_type = _SOLAR_MAINS_GAS_FUEL + return epc + + +def test_solar_overlay_reproduces_relodged_after_se_sw_shaded() -> None: + # Arrange — two shaded planes: SE (octant 4) at 30° pitch under significant + # shading (code 3), and SW (octant 6) at 45° pitch under modest shading + # (code 2); each a 1.00 kWp array. Exercises the overshading + orientation + # spread. + before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf") + after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_1.pdf") + overlay = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray(peak_power=1.0, pitch=2, orientation=4, overshading=3), + PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=6, overshading=2), + ], + pv_diverter_present=True, + is_dwelling_export_capable=True, + ) + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, overlay) + + +def test_solar_overlay_reproduces_relodged_after_e_w_unshaded() -> None: + # Arrange — E (octant 3) at 60° pitch and W (octant 7) at 45° pitch, both + # unshaded (code 1). Exercises the steeper pitches with no shading. + before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf") + after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_2.pdf") + overlay = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=3, overshading=1), + PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=7, overshading=1), + ], + pv_diverter_present=True, + is_dwelling_export_capable=True, + ) + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, overlay) + + +def test_solar_overlay_reproduces_relodged_after_nw_n_unshaded() -> None: + # Arrange — NW (octant 8) at 60° pitch and N (octant 1) at 45° pitch, both + # unshaded. The least-productive orientations (the N plane in particular) + # exercise the low-yield end of the SAP Appendix M output. + before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf") + after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_3.pdf") + overlay = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1), + PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1), + ], + pv_diverter_present=True, + is_dwelling_export_capable=True, + ) + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, overlay) + + +def test_battery_cert_currently_reproduced_by_the_no_battery_overlay() -> None: + # Tripwire (user-requested): the "with battery" cert lodges a §19 5 kWh + # battery, but the current Elmhurst extractor does NOT parse it (the parsed + # EpcPropertyData has pv_batteries=None). So the cert currently scores + # identically to its no-battery twin, and the *no-battery* overlay (same NW/N + # arrays) reproduces it exactly. When the extractor learns to parse the §19 + # Batteries block, the after-cert will gain ~+1.1 SAP from the 5 kWh battery + # and THIS PIN WILL FAIL — the fix is then to switch to the with-battery + # overlay below (which the calculator already models, see the next test). + before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf") + after: EpcPropertyData = _parse_solar("solar_pv_with_battery_001431_after.pdf") + overlay = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1), + PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1), + ], + pv_diverter_present=True, + is_dwelling_export_capable=True, + ) + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, overlay) + + +def test_battery_overlay_raises_sap_above_its_no_battery_twin() -> None: + # The calculator DOES model a PV battery (App M monthly self-consumption), so + # the recommendation's battery variant is a meaningful, higher-SAP Option — + # even though the example cert's battery is not yet parsed. This pins the fix + # target for the tripwire above: once the extractor parses the §19 battery, + # the with-battery overlay should reproduce the (then battery-bearing) cert. + before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf") + arrays = [ + PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1), + PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1), + ] + no_battery = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=arrays, + pv_diverter_present=True, + is_dwelling_export_capable=True, + ) + ) + with_battery = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=arrays, + pv_diverter_present=True, + is_dwelling_export_capable=True, + pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)), + ) + ) + + # Act + scorer = PackageScorer(Sap10Calculator()) + sap_no_battery: float = scorer.score(before, [no_battery]).sap_continuous + sap_with_battery: float = scorer.score(before, [with_battery]).sap_continuous + + # Assert — the 5 kWh battery raises SAP by a meaningful margin. + assert sap_with_battery > sap_no_battery + 1e-3 + + +_SOLAR_FUEL_GAP_REASON: Final[str] = ( + "Blocked on the Elmhurst mapper deriving main_fuel_type for the main heating " + "lodged with EES code 'WGK' / Main Heating SAP code 502: it currently maps to " + "'' (empty), so Sap10Calculator raises MissingMainFuelType when baselining the " + "raw solar before. The solar overlay never touches heating, so the cascade " + "pins above patch the shared fuel to mains gas (26) on before + after to " + "isolate the PV delta — only baselining the unmodified before is blocked. " + "Flips green once the mapper derives mains gas from the WGK/502 lodgement. " + "Owner: mapper/extractor front." +) + + +@pytest.mark.xfail(strict=True, reason=_SOLAR_FUEL_GAP_REASON) +def test_solar_before_baselines() -> None: + # The Modelling pipeline baselines the dwelling before modelling it, so the + # before must be scorable on its own. This solar cert is not yet: its main + # fuel is unresolved (see reason). A failing tripwire for the mapper fix. + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "solar_pv_001431_before.pdf" + ) + + # Act / Assert — currently raises MissingMainFuelType. + Sap10Calculator().calculate(before) + + +# --- Secondary Heating Removal (ADR-0028) ---------------------------------- +# The user's Elmhurst before/after Summary for this measure (cert 001431, +# electric-storage main + secondary 691) cannot be parsed — that PDF export +# trips the documented 001431 Summary window-extraction bug. So these pins use +# the worksheet-pinned `build_epc()` (a validated real-001431 representation, +# the repo's sanctioned 001431 baseline) with the secondary configuration set on +# it, exercising the real generator → overlay → calculator cascade. + + +def test_secondary_removal_on_an_electric_storage_main_is_a_no_op() -> None: + # Arrange — 001431 recast to an electric-storage main (SAP code 402, fuel 30) + # with a lodged secondary (691). RdSAP §A.2.2 forces a default secondary back + # on storage mains, so removal reproduces the after at delta 0 — exactly why + # the user's before/after Summaries both print SAP F35. + before: EpcPropertyData = build_001431_epc() + main = before.sap_heating.main_heating_details[0] + main.sap_main_heating_code = _ELECTRIC_STORAGE_MAIN_CODE + main.main_fuel_type = _STANDARD_ELECTRICITY_FUEL + main.main_heating_index_number = None + before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE + after: EpcPropertyData = copy.deepcopy(before) + after.sap_heating.secondary_heating_type = None + after.sap_heating.secondary_fuel_type = None + recommendation: Recommendation | None = recommend_secondary_heating_removal( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert — the overlay reproduces the secondary-removed cert at delta 0. + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +def test_secondary_removal_on_a_non_forced_main_raises_sap() -> None: + # Arrange — 001431's lodged gas combi (SAP code 104, NOT a forced-secondary + # main) with an added electric secondary (691). Removing it reallocates the + # Table 11 secondary fraction to the cheaper gas main, so cost-based SAP rises + # (the value path the forced-secondary example can't exercise). + before: EpcPropertyData = build_001431_epc() + before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE + recommendation: Recommendation | None = recommend_secondary_heating_removal( + before, _AnyProduct() + ) + assert recommendation is not None + scorer = PackageScorer(Sap10Calculator()) + + # Act + with_secondary: Score = scorer.score(before, []) + removed: Score = scorer.score(before, [recommendation.options[0].overlay]) + + # Assert — removal strictly raises SAP (delta well above the pin tolerance). + assert removed.sap_continuous - with_secondary.sap_continuous > _PIN_ABS diff --git a/tests/domain/modelling/test_floor_recommendation.py b/tests/domain/modelling/test_floor_recommendation.py new file mode 100644 index 00000000..1ce5bd7f --- /dev/null +++ b/tests/domain/modelling/test_floor_recommendation.py @@ -0,0 +1,97 @@ +"""Behaviour of the floor Recommendation Generator: detecting an uninsulated +ground floor and its construction (suspended vs solid), emitting the matching +single insulation Option with overlay + priced Cost. A floor is one +construction, so this is a single-Option Recommendation (like cavity walls). +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, +) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.generators.floor_recommendation import recommend_floor_insulation +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=25.0, contingency_rate=0.20 + ) + + +def _main(epc: EpcPropertyData) -> SapBuildingPart: + return next( + p for p in epc.sap_building_parts if p.identifier is BuildingPartIdentifier.MAIN + ) + + +def test_uninsulated_suspended_floor_yields_suspended_insulation() -> None: + # Arrange — 000490 MAIN: "Suspended timber", "As built" (uninsulated) + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Ground floor" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "suspended_floor_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert _main(simulated).floor_insulation_thickness == 100 + + +def test_uninsulated_solid_floor_yields_solid_insulation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _main(baseline).floor_construction_type = "Solid" + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.options[0].measure_type == "solid_floor_insulation" + + +def test_already_insulated_floor_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _main(baseline).floor_insulation_thickness = "100" + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_floor_option_carries_cost_from_ground_floor_area_and_product() -> None: + # Arrange — 000490 MAIN ground floor area 14.85 m^2 + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 14.85 * 25.0) <= 0.01 + assert abs(cost.contingency_rate - 0.20) <= 1e-9 diff --git a/tests/domain/modelling/test_glazing_recommendation.py b/tests/domain/modelling/test_glazing_recommendation.py new file mode 100644 index 00000000..993052f0 --- /dev/null +++ b/tests/domain/modelling/test_glazing_recommendation.py @@ -0,0 +1,126 @@ +"""Behaviour of the glazing Recommendation Generator: detecting single-glazed +windows and emitting one planning-picked "Windows" Recommendation (ADR-0022). + +Unrestricted dwellings get `double_glazing`; a planning-protected dwelling gets +`secondary_glazing` instead — the measure is hard-picked by the Property's +`PlanningRestrictions`, not offered as competing Options. All single-glazed +windows are upgraded together in one overlay; the overlay writes each window's +lodged U-value and g-value (not just `glazing_type`) because the calculator +consumes those per-window values directly. Detection + pricing only, no scores +(ADR-0016). The before/after cascade pins live in `test_elmhurst_cascade_pins`. +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.generators.glazing_recommendation import recommend_glazing +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import WindowOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +# SAP10.2 Table U2 code 1 = single glazing (the generator's trigger). +_SINGLE_GLAZED = 1 + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed per-window glazing price. + + `unit_cost_per_m2` carries the catalogue row's fully-loaded total, reused as + a flat average price per window (ADR-0022).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=600.0, + contingency_rate=0.2, + id=42, + ) + + +def _with_single_glazed(epc: EpcPropertyData, *indices: int) -> EpcPropertyData: + """Return a copy of `epc` with the windows at `indices` set single-glazed.""" + clone: EpcPropertyData = copy.deepcopy(epc) + for index in indices: + clone.sap_windows[index].glazing_type = _SINGLE_GLAZED + return clone + + +def test_single_glazed_dwelling_yields_a_double_glazing_recommendation() -> None: + # Arrange — 000490 is all double-glazed; make windows 0 and 2 single-glazed. + baseline: EpcPropertyData = _with_single_glazed(build_epc(), 0, 2) + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts() + ) + + # Assert — one double-glazing Option whose overlay rewrites each single- + # glazed window to the pinned double target (gt=5, U=1.40, g=0.72). + assert recommendation is not None + assert recommendation.surface == "Windows" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "double_glazing" + assert dict(option.overlay.windows) == { + 0: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), + 2: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), + } + + +def test_fully_glazed_dwelling_yields_no_recommendation() -> None: + # Arrange — 000490 is entirely double-glazed (gt=2); nothing to upgrade. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_average_per_single_glazed_window() -> None: + # Arrange — three single-glazed windows at £600 each (a flat per-window + # average reused from `unit_cost_per_m2`, ADR-0022). + baseline: EpcPropertyData = _with_single_glazed(build_epc(), 0, 2, 4) + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 3 * 600.0 + assert cost.contingency_rate == 0.2 + assert recommendation.options[0].material_id == 42 + + +def test_planning_protection_picks_secondary_glazing_over_double() -> None: + # Arrange — a conservation area blocks external work, so the external units + # can't be replaced; an internal secondary pane is the picked Measure. + baseline: EpcPropertyData = _with_single_glazed(build_epc(), 0, 2) + restrictions = PlanningRestrictions(in_conservation_area=True) + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts(), restrictions + ) + + # Assert — one secondary-glazing Option; each single window upgraded to the + # pinned secondary target (gt=11, U=2.90, g unchanged at 0.85). + assert recommendation is not None + option = recommendation.options[0] + assert option.measure_type == "secondary_glazing" + assert dict(option.overlay.windows) == { + 0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), + 2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), + } diff --git a/tests/domain/modelling/test_heating_cost_inputs.py b/tests/domain/modelling/test_heating_cost_inputs.py new file mode 100644 index 00000000..470be139 --- /dev/null +++ b/tests/domain/modelling/test_heating_cost_inputs.py @@ -0,0 +1,97 @@ +"""The dwelling interpretation that feeds `Products.boiler_bundle_cost` / +`tune_up_cost` — reading an `EpcPropertyData` into typed cost inputs (ADR-0027). +The modelling-layer half of the split: it derives the radiator count, which +standard-control parts are already fitted (from the SAP Table 4e control code), +whether the boiler upgrade fires a controls change, and which cylinder fixes +apply — the catalogue math (Products) stays EPC-free. +""" + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.heating_recommendation import ( + boiler_cost_inputs, + tune_up_cost_inputs, +) +from domain.modelling.products import BoilerCostInputs, TuneUpCostInputs +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +def test_tune_up_inputs_from_no_controls_charge_every_part() -> None: + # Arrange — control 2101 ("no control"): no programmer, room thermostat or + # TRVs fitted; an uninsulated, un-thermostatted cylinder; 10 radiators. + epc: EpcPropertyData = parse_recommendation_summary( + "tune_up_from_2101_001431_before.pdf" + ) + + # Act + inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=False) + + # Assert + assert inputs == TuneUpCostInputs( + is_zoned=False, + radiator_count=10, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + +def test_tune_up_inputs_read_existing_control_parts() -> None: + # Arrange — control 2113 ("room thermostat and TRVs"): already has a room + # thermostat + TRVs, only the programmer is missing. The is_zoned flag is + # passed through. + epc: EpcPropertyData = parse_recommendation_summary( + "tune_up_from_2113_001431_before.pdf" + ) + + # Act + inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=True) + + # Assert — so the standard cost would charge only the programmer. + assert inputs.is_zoned is True + assert inputs.has_programmer is False + assert inputs.has_room_thermostat is True + assert inputs.has_trvs is True + + +def test_boiler_inputs_flag_a_controls_upgrade_for_inadequate_controls() -> None: + # Arrange — a combi (no cylinder) with inadequate controls (2111 "TRVs and + # bypass", no room thermostat): the boiler upgrade also fires the standard + # controls, which already has TRVs but no programmer/room thermostat. + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_gas_001431_before.pdf" + ) + + # Act + inputs: BoilerCostInputs = boiler_cost_inputs(epc) + + # Assert + assert inputs == BoilerCostInputs( + upgrades_controls=True, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=True, + needs_cylinder_jacket=False, + needs_cylinder_thermostat=False, + ) + + +def test_boiler_inputs_no_controls_upgrade_when_already_adequate() -> None: + # Arrange — a gas boiler with a cylinder and already-adequate controls + # (2106): the boiler doesn't fire a controls change, but both cylinder fixes + # apply (uninsulated, un-thermostatted). + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + + # Act + inputs: BoilerCostInputs = boiler_cost_inputs(epc) + + # Assert + assert inputs.upgrades_controls is False + assert inputs.needs_cylinder_jacket is True + assert inputs.needs_cylinder_thermostat is True diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py new file mode 100644 index 00000000..5e8e1576 --- /dev/null +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -0,0 +1,563 @@ +"""Behaviour of the heating Recommendation Generator: detecting a dwelling +whose heating system should be replaced and emitting one "Heating & Hot Water" +Recommendation of competing whole-system bundles (ADR-0024). This slice covers +the high-heat-retention storage (HHRSH) bundle; ASHP and boiler bundles land in +later slices. Detection + pricing only; impact is produced by scoring (ADR-0016). +""" + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import HeatingOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +# Electricity main-fuel code (Elmhurst → SAP10) and the Table 4a SAP code an +# existing (non-HHR) electric storage system lodges. +_ELECTRICITY = 30 +_OLD_STORAGE_SAP_CODE = 402 + + +def _electric_storage_baseline() -> EpcPropertyData: + """A 000490 dwelling re-cast as an existing (non-HHR) electric storage + system: electric main fuel, Table 4a code 402.""" + epc: EpcPropertyData = build_epc() + main = epc.sap_heating.main_heating_details[0] + main.main_fuel_type = _ELECTRICITY + main.sap_main_heating_code = _OLD_STORAGE_SAP_CODE + return epc + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed HHRSH product cost.""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=3500.0, contingency_rate=0.26 + ) + + +def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: + # Arrange — an existing electric storage system (not HHR). + baseline: EpcPropertyData = _electric_storage_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — one "Heating & Hot Water" Recommendation carrying the HHRSH + # bundle, whose overlay is the absolute HHR end-state. + assert recommendation is not None + assert recommendation.surface == "Heating & Hot Water" + options = {o.measure_type.value: o for o in recommendation.options} + assert "high_heat_retention_storage_heaters" in options + assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay( + main_fuel_type=30, + sap_main_heating_code=409, + main_heating_control=2404, + water_heating_code=903, + water_heating_fuel=30, + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=120, + cylinder_thermostat="Y", + has_hot_water_cylinder=True, + meter_type="Dual", + ) + + +def test_on_gas_boiler_dwelling_yields_no_hhr_storage_bundle() -> None: + # Arrange — 000490 is a mains-gas combi (fuel 26, mains_gas True). HHR + # storage suits off-gas / electric dwellings, not an on-gas gas boiler. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no HHRSH bundle (no other bundle is built in this slice → None). + if recommendation is not None: + assert "high_heat_retention_storage_heaters" not in { + o.measure_type for o in recommendation.options + } + + +def test_already_hhr_storage_dwelling_yields_no_hhr_bundle() -> None: + # Arrange — an electric dwelling already on HHR storage (Table 4a code 409) + # must not be told to install HHR storage again (ASHP may still be offered as + # a better end-state — it is a house here). + baseline: EpcPropertyData = _electric_storage_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 409 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no HHRSH bundle. + if recommendation is not None: + assert "high_heat_retention_storage_heaters" not in { + o.measure_type for o in recommendation.options + } + + +def test_existing_heat_pump_dwelling_yields_no_hhr_storage_bundle() -> None: + # Arrange — an electric dwelling already on a heat pump (category 4) is never + # downgraded to storage heaters. + baseline: EpcPropertyData = _electric_storage_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_category = 4 + + # Act / Assert + assert recommend_heating(baseline, _StubProducts()) is None + + +def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None: + # Arrange + baseline: EpcPropertyData = _electric_storage_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the bundle's fully-loaded cost + contingency come from the product. + assert recommendation is not None + option = next( + o + for o in recommendation.options + if o.measure_type == "high_heat_retention_storage_heaters" + ) + assert option.cost is not None + assert abs(option.cost.total - 3500.0) <= 1e-9 + assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 + + +def _gas_boiler_house() -> EpcPropertyData: + """A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible.""" + epc: EpcPropertyData = build_epc() + epc.property_type = "House" + return epc + + +def test_gas_boiler_house_yields_an_ashp_bundle() -> None: + # Arrange — a mains-gas house; ASHP is offered for any non-flat house/ + # bungalow regardless of current system or efficiency (ADR-0024). + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the ASHP bundle carries the absolute heat-pump end-state. + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "air_source_heat_pump" in options + assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay( + main_fuel_type=30, + main_heating_control=2210, + main_heating_index_number=110257, + main_heating_category=4, + water_heating_code=901, + water_heating_fuel=30, + 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, + ) + + +def test_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None: + # Arrange — a mains-gas regular boiler with a cylinder (90 m2, 7 habitable + # rooms): the ASHP reuses the existing wet system (ADR-0025). + epc: EpcPropertyData = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_before.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_heating(epc, _StubProducts()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "air_source_heat_pump" + ) + + # Assert — composite: decommission (gas) 720 + pump (4.5 kW band) 9720 + + # cylinder 2382.60 + reuse distribution (flush 168 + 0.5 x dist(10) 5220 = + # 2778) = 15600.60, with the separate 25% ASHP contingency. + assert option.cost is not None + assert abs(option.cost.total - 15600.60) <= 1e-9 + assert abs(option.cost.contingency_rate - 0.25) <= 1e-9 + + +def test_listed_building_yields_no_ashp_bundle() -> None: + # Arrange — a listed building protects the fabric; an external ASHP unit is + # not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out. + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), PlanningRestrictions(is_listed=True) + ) + + # Assert + assert recommendation is None + + +def test_conservation_area_still_yields_an_ashp_bundle() -> None: + # Arrange — unlike glazing, a conservation area does NOT exclude ASHP; it is + # offered with a planning caveat (ADR-0024, research-grounded). + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), PlanningRestrictions(in_conservation_area=True) + ) + + # Assert + assert recommendation is not None + assert "air_source_heat_pump" in {o.measure_type for o in recommendation.options} + + +def test_flat_yields_no_ashp_bundle() -> None: + # Arrange — flats are not auto-offered an individual ASHP (siting/lease/ + # MCS-020 need a survey — ADR-0024). + baseline: EpcPropertyData = _gas_boiler_house() + baseline.property_type = "Flat" + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no ASHP (and the gas flat is not HHRSH-eligible either → None). + if recommendation is not None: + assert "air_source_heat_pump" not in { + o.measure_type for o in recommendation.options + } + + +def test_existing_heat_pump_yields_no_ashp_bundle() -> None: + # Arrange — a dwelling already on a heat pump (category 4) is not re-offered + # an ASHP. + baseline: EpcPropertyData = _gas_boiler_house() + baseline.sap_heating.main_heating_details[0].main_heating_category = 4 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + assert "air_source_heat_pump" not in { + o.measure_type for o in recommendation.options + } + + +# --- Gas boiler upgrade (Heating/HW expansion) ---------------------------- + + +def _gas_boiler_with_cylinder_baseline() -> EpcPropertyData: + """A mains-gas wet boiler heating an uninsulated, un-thermostatted hot-water + cylinder — the boiler-with-cylinder dwelling. The cert lodges code 114 + (already condensing), which the efficiency gate excludes from a like-for-like + swap; recast to a pre-1998 non-condensing boiler (code 110) so the boiler + upgrade is a genuine candidate (the overlay overwrites the code to 102 + regardless of the before).""" + epc: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + epc.sap_heating.main_heating_details[0].sap_main_heating_code = 110 + return epc + + +def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> None: + # Arrange — a mains-gas wet boiler with an uninsulated, un-thermostatted + # cylinder: the upgrade fires both conditional cylinder fixes. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the absolute boiler end-state (code 102, fanned flue) with the + # cylinder jacketed (type 2 / 80 mm) and thermostatted; controls, cylinder + # size, and meter are left unchanged. + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "gas_boiler_upgrade" in options + assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay( + main_fuel_type=26, + heat_emitter_type=1, + sap_main_heating_code=102, + fan_flue_present=True, + boiler_flue_type=2, + water_heating_code=901, + water_heating_fuel=26, + cylinder_insulation_type=2, + cylinder_insulation_thickness_mm=80, + cylinder_thermostat="Y", + has_hot_water_cylinder=True, + ) + + +def test_boiler_upgrade_skips_jacket_when_cylinder_already_insulated() -> None: + # Arrange — the same dwelling but with an already well-insulated cylinder + # (100 mm > the 80 mm jacket end-state): the jacket must not be re-applied + # (and must never downgrade it). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.cylinder_insulation_thickness_mm = 100 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no jacket fields, but the thermostat still added (absent before). + assert recommendation is not None + overlay = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ).overlay.heating + assert overlay is not None + assert overlay.cylinder_insulation_type is None + assert overlay.cylinder_insulation_thickness_mm is None + assert overlay.cylinder_thermostat == "Y" + + +def test_boiler_upgrade_skips_thermostat_when_already_present() -> None: + # Arrange — the same dwelling but the cylinder already has a thermostat. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.cylinder_thermostat = "Y" + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no thermostat field, but the jacket still added (uninsulated). + assert recommendation is not None + overlay = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ).overlay.heating + assert overlay is not None + assert overlay.cylinder_thermostat is None + assert overlay.cylinder_insulation_type == 2 + + +def test_already_condensing_gas_boiler_yields_no_boiler_upgrade() -> None: + # Arrange — the real cert: a mains-gas boiler already condensing (Table 4b + # code 114, 84% winter — the same as the new code 102). A like-for-like swap + # gains nothing, so the boiler upgrade is not offered; the dwelling still + # gets a tune-up for its cylinder + controls. + baseline: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + measure_types = {o.measure_type for o in recommendation.options} + assert "gas_boiler_upgrade" not in measure_types + assert "system_tune_up_zoned" in measure_types + + +def test_non_gas_boiler_is_not_gated_on_efficiency() -> None: + # Arrange — an oil condensing boiler (Table 4b code 127, 84% winter — meets + # the new gas boiler's efficiency) on a mains-gas street. Unlike a gas + # boiler, the oil→gas fuel switch has value beyond efficiency, so it is NOT + # suppressed by the efficiency gate. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 127 + baseline.sap_heating.main_heating_details[0].main_fuel_type = 28 # oil + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the boiler upgrade is still offered (the fuel switch). + assert recommendation is not None + assert "gas_boiler_upgrade" in {o.measure_type for o in recommendation.options} + + +def test_electric_boiler_dwelling_yields_no_gas_boiler_upgrade() -> None: + # Arrange — an electric boiler (Table 4a code 191) is left alone: + # electrification, not a gas swap, is its upgrade path. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 191 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + assert "gas_boiler_upgrade" not in { + o.measure_type for o in recommendation.options + } + + +def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None: + # Arrange — an oil boiler (Table 4b code 130) with no mains-gas connection: + # a gas boiler cannot be installed, so no upgrade is offered (the gas end- + # state is gated on a mains-gas connection). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130 + baseline.sap_energy_source.mains_gas = False + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + assert "gas_boiler_upgrade" not in { + o.measure_type for o in recommendation.options + } + + +def _gas_combi_baseline() -> EpcPropertyData: + """A mains-gas combi (Table 4b code 112, no cylinder) with inadequate + controls (2111 "TRVs and bypass" — no room thermostat).""" + return parse_recommendation_summary("boiler_combi_gas_001431_before.pdf") + + +def test_gas_combi_dwelling_yields_a_combi_boiler_upgrade_bundle() -> None: + # Arrange — a mains-gas combi with no cylinder and inadequate controls: + # the upgrade replaces it with a condensing combi (code 104) and upgrades + # the controls to 2106, touching no cylinder fields. + baseline: EpcPropertyData = _gas_combi_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "gas_boiler_upgrade" in options + assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay( + main_fuel_type=26, + heat_emitter_type=1, + sap_main_heating_code=104, + fan_flue_present=True, + boiler_flue_type=2, + main_heating_control=2106, + water_heating_code=901, + water_heating_fuel=26, + ) + + +def _tune_up_baseline() -> EpcPropertyData: + """A mains-gas wet boiler (kept) with "no control" (2101) and an uninsulated, + un-thermostatted cylinder — the system tune-up dwelling.""" + return parse_recommendation_summary("tune_up_from_2101_001431_before.pdf") + + +def test_wet_boiler_dwelling_yields_both_tune_up_options() -> None: + # Arrange — a wet boiler whose controls can be improved: both the standard + # (2106) and zone (2110) control tune-ups are offered as competing options, + # each keeping the boiler and fixing the cylinder. + baseline: EpcPropertyData = _tune_up_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — both options carry the control end-state + the cylinder fixes, and + # leave the boiler untouched (no fuel / sap code / flue fields). + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert options["system_tune_up"].overlay.heating == HeatingOverlay( + main_heating_control=2106, + cylinder_insulation_type=2, + cylinder_insulation_thickness_mm=80, + cylinder_thermostat="Y", + ) + assert options["system_tune_up_zoned"].overlay.heating == HeatingOverlay( + main_heating_control=2110, + cylinder_insulation_type=2, + cylinder_insulation_thickness_mm=80, + cylinder_thermostat="Y", + ) + + +def test_tune_up_standard_not_offered_when_controls_already_standard() -> None: + # Arrange — controls are already standard (2106): the standard tune-up would + # be a control no-op, so only the zone tune-up is offered. + baseline: EpcPropertyData = _tune_up_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_control = 2106 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + measure_types = {o.measure_type for o in recommendation.options} + assert "system_tune_up" not in measure_types + assert "system_tune_up_zoned" in measure_types + + +def test_tune_up_neither_offered_when_controls_already_zoned() -> None: + # Arrange — controls are already zone control (2110): neither tune-up would + # improve them, so neither is offered. + baseline: EpcPropertyData = _tune_up_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_control = 2110 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + measure_types = {o.measure_type for o in recommendation.options} + assert "system_tune_up" not in measure_types + assert "system_tune_up_zoned" not in measure_types + + +def test_tune_up_carries_the_composite_per_dwelling_cost() -> None: + # Arrange — a wet boiler with "no control" (2101) and an uninsulated, un- + # thermostatted cylinder, 10 radiators: the standard tune-up fits the full + # control set + both cylinder fixes (ADR-0027). + baseline: EpcPropertyData = _tune_up_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "system_tune_up" + ) + + # Assert — programmer 120 + room stat 150 + TRVs 10x35=350 = 620 controls + + # jacket 50 + cylinder stat 150 = 820, with the 0.10 tune-up contingency + # (composed, not the stub catalogue scalar). + assert option.cost is not None + assert abs(option.cost.total - 820.0) <= 1e-9 + assert abs(option.cost.contingency_rate - 0.10) <= 1e-9 + + +def test_boiler_upgrade_carries_the_composite_per_dwelling_cost() -> None: + # Arrange — a gas boiler with a cylinder and adequate controls (2106): the + # boiler is a like-for-like swap (no controls upgrade, no system-change + # extras), with both cylinder fixes (ADR-0027). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Assert — boiler 3200 + jacket 50 + cylinder stat 150 = 3400, 0.26 boiler + # contingency. + assert option.cost is not None + assert abs(option.cost.total - 3400.0) <= 1e-9 + assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 + + +def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None: + # Arrange — the same combi but with already-adequate controls (2113, room + # thermostat and TRVs): the upgrade must not move the controls (and must + # never downgrade a better control). + baseline: EpcPropertyData = _gas_combi_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_control = 2113 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + overlay = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ).overlay.heating + assert overlay is not None + assert overlay.main_heating_control is None diff --git a/tests/domain/modelling/test_lighting_recommendation.py b/tests/domain/modelling/test_lighting_recommendation.py new file mode 100644 index 00000000..4386c6ee --- /dev/null +++ b/tests/domain/modelling/test_lighting_recommendation.py @@ -0,0 +1,121 @@ +"""Behaviour of the lighting Recommendation Generator: detecting non-LED bulbs +and emitting one "Lighting" Recommendation whose single Option upgrades every +bulb to LED (ADR-0023). A free Optimiser candidate (it improves SAP), unlike +ventilation's forced dependency. Detection + pricing only, no scores (ADR-0016). +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.lighting_recommendation import recommend_lighting +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import LightingOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed per-bulb lighting price. + + `unit_cost_per_m2` carries the catalogue row's fully-loaded total, reused as + a flat average price per bulb (ADR-0023).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=5.0, + contingency_rate=0.26, + id=7, + ) + + +def _with_bulbs( + epc: EpcPropertyData, *, led: int, cfl: int, incandescent: int, low_energy: int +) -> EpcPropertyData: + """Return a copy of `epc` with the four fixed-lighting bulb counts set.""" + clone: EpcPropertyData = copy.deepcopy(epc) + clone.led_fixed_lighting_bulbs_count = led + clone.cfl_fixed_lighting_bulbs_count = cfl + clone.incandescent_fixed_lighting_bulbs_count = incandescent + clone.low_energy_fixed_lighting_bulbs_count = low_energy + return clone + + +def test_dwelling_with_non_led_bulbs_yields_a_lighting_recommendation() -> None: + # Arrange — a mixed inventory: 2 LED + 3 CFL + 4 incandescent + 1 LEL. + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=2, cfl=3, incandescent=4, low_energy=1 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert — one all-LED Option: led = total (10), every other count zeroed. + assert recommendation is not None + assert recommendation.surface == "Lighting" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "low_energy_lighting" + assert option.overlay.lighting == LightingOverlay( + led_fixed_lighting_bulbs_count=10, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + low_energy_fixed_lighting_bulbs_count=0, + ) + + +def test_already_all_led_dwelling_yields_no_recommendation() -> None: + # Arrange — every bulb is already LED; nothing to upgrade. + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=9, cfl=0, incandescent=0, low_energy=0 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_dwelling_with_no_lodged_bulbs_yields_no_recommendation() -> None: + # Arrange — no bulb counts lodged (the calculator's L5b fallback case): with + # no inventory to size against, no Recommendation is offered (ADR-0023). + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=0, cfl=0, incandescent=0, low_energy=0 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_average_per_non_led_bulb() -> None: + # Arrange — 8 non-LED bulbs (3 CFL + 4 incandescent + 1 LEL) at £5 each; the + # 2 existing LEDs are not priced (not replaced). + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=2, cfl=3, incandescent=4, low_energy=1 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 8 * 5.0 + assert cost.contingency_rate == 0.26 + assert recommendation.options[0].material_id == 7 diff --git a/tests/domain/modelling/test_measure_dependency.py b/tests/domain/modelling/test_measure_dependency.py new file mode 100644 index 00000000..d4914deb --- /dev/null +++ b/tests/domain/modelling/test_measure_dependency.py @@ -0,0 +1,79 @@ +"""Behaviour of the ventilation Measure Dependency: the data-declared "fabric +insulation requires adequate ventilation" edge. Production (detection + pricing) +is the ventilation Recommendation Generator's job and is tested in +test_ventilation_recommendation; here we test the forced-edge wrapping and the +trigger set. See CONTEXT.md (Measure Dependency) / ADR-0016. +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.optimisation.measure_dependency import ( + MEASURES_NEEDING_VENTILATION, + ventilation_dependency, +) +from domain.modelling.optimisation.optimiser import MeasureDependency +from domain.modelling.product import Product +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed per-unit ventilation cost.""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=450.0, contingency_rate=0.26 + ) + + +def test_triggers_are_the_fabric_wall_measures() -> None: + # Arrange / Act / Assert — the data-held trigger set (cf. legacy + # assumptions.measures_needing_ventilation). + assert MEASURES_NEEDING_VENTILATION == frozenset( + { + "cavity_wall_insulation", + "internal_wall_insulation", + "external_wall_insulation", + } + ) + + +def test_wraps_the_priced_recommendation_into_a_forced_edge() -> None: + # Arrange — 000490 needs ventilation, so the generator produces a priced MEV + # Recommendation that the dependency wraps. + baseline: EpcPropertyData = build_epc() + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert — a forced edge triggered by the fabric measures; the required + # Option carries the generator's price and no role-1 signal (it is never + # freely scored). + assert dependency is not None + assert dependency.triggers == MEASURES_NEEDING_VENTILATION + assert dependency.required.option.measure_type == "mechanical_ventilation" + assert dependency.required.sap_gain == 0.0 + cost = dependency.required.option.cost + assert cost is not None + assert abs(cost.total - 900.0) <= 1e-9 + + +def test_no_dependency_when_the_dwelling_needs_no_ventilation() -> None: + # Arrange — already mechanically ventilated, so the generator returns None + # and there is no edge to force. + baseline: EpcPropertyData = build_epc() + assert baseline.sap_ventilation is not None + baseline.sap_ventilation.mechanical_ventilation_kind = "EXTRACT_OR_PIV_OUTSIDE" + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert + assert dependency is None diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py new file mode 100644 index 00000000..93431c21 --- /dev/null +++ b/tests/domain/modelling/test_measure_type.py @@ -0,0 +1,56 @@ +"""Slice 1 — `MeasureType`, the canonical enum of the measures the Modelling +stage actually models. + +A `StrEnum` so each member *is* its persisted/optimiser string value (e.g. +`MeasureType.SOLAR_PV == "solar_pv"`), letting it flow through the `recommendation` +varchar column and the optimiser's group-by-type key unchanged. Replaces the +per-generator string constants as the single source of truth, and is the +vocabulary the `considered_measures` allowlist speaks. +""" + +from domain.modelling.measure_type import MeasureType + +# The full set of measures the generators emit today (one per Generator option). +_EXPECTED_VALUES = { + "cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "sloping_ceiling_insulation", + "flat_roof_insulation", + "suspended_floor_insulation", + "solid_floor_insulation", + "double_glazing", + "secondary_glazing", + "low_energy_lighting", + "mechanical_ventilation", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "gas_boiler_upgrade", + "system_tune_up", + "system_tune_up_zoned", + "solar_pv", + "secondary_heating_removal", +} + + +def test_members_cover_exactly_the_modelled_measures() -> None: + # Arrange / Act + values = {member.value for member in MeasureType} + + # Assert + assert values == _EXPECTED_VALUES + + +def test_member_is_its_string_value() -> None: + # Arrange / Act / Assert — a StrEnum member compares and persists as its str. + assert MeasureType.SOLAR_PV == "solar_pv" + assert MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS == ( + "high_heat_retention_storage_heaters" + ) + assert isinstance(MeasureType.SOLAR_PV, str) + + +def test_round_trips_through_its_value() -> None: + # Arrange / Act / Assert — a raw catalogue/DB string maps back to the member. + assert MeasureType("cavity_wall_insulation") is MeasureType.CAVITY_WALL_INSULATION diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py new file mode 100644 index 00000000..80f39d7e --- /dev/null +++ b/tests/domain/modelling/test_optimiser.py @@ -0,0 +1,685 @@ +"""Behaviour of the Optimiser core: a grouped-knapsack MILP over per-Option +role-1 scores (ADR-0016). Picks at most one Option per Recommendation (disjoint +groups, no cross-group constraints) to maximise total SAP gain subject to the +Scenario budget. This is the warm-start *signal* — the truthful figure comes +from the whole-package re-score + repair (a later slice); here we test the +selection with synthetic scores and no calculator. +""" + +from __future__ import annotations + +from typing import Sequence + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.optimisation.optimiser import ( + MeasureDependency, + OptimisedPackage, + ScoredOption, + optimise, + optimise_min_cost, + optimise_package, +) +from domain.modelling.measure_type import MeasureType +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + VentilationOverlay, +) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: + return ScoredOption( + option=MeasureOption( + measure_type=MeasureType(measure_type), + description=measure_type, + overlay=EpcSimulation(), + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=gain, + ) + + +# Distinguishable overlays so the stub scorer can attribute a true gain per +# measure (wall / roof / floor) regardless of the role-1 signal. +_WALL_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) +_ROOF_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(roof_insulation_thickness=300) + } +) +_FLOOR_OVERLAY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(floor_insulation_thickness=100) + } +) + + +def _scored_overlay( + measure_type: str, *, gain: float, cost: float, overlay: EpcSimulation +) -> ScoredOption: + return ScoredOption( + option=MeasureOption( + measure_type=MeasureType(measure_type), + description=measure_type, + overlay=overlay, + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=gain, + ) + + +class _StubScorer: + """A deterministic stand-in for PackageScorer: the package SAP is a base + plus a fixed *true* gain per measure present (by overlay field), decoupled + from the role-1 signal — so the repair loop is exercised without the + calculator (ADR-0016).""" + + def __init__(self, *, base: float, wall: float, roof: float, floor: float) -> None: + self._base = base + self._wall = wall + self._roof = roof + self._floor = floor + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + sap = self._base + for sim in simulations: + part = sim.building_parts[BuildingPartIdentifier.MAIN] + if part.wall_insulation_type is not None: + sap += self._wall + if part.roof_insulation_thickness is not None: + sap += self._roof + if part.floor_insulation_thickness is not None: + sap += self._floor + return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) + + +def _selected_types(selection: list[ScoredOption]) -> set[str]: + return {scored.option.measure_type for scored in selection} + + +def test_grouped_knapsack_maximises_gain_within_budget() -> None: + # Arrange — wall group has two mutually-exclusive options; roof + floor one + # each. EWI has the best gain but is unaffordable alongside the rest. + groups: list[list[ScoredOption]] = [ + [ + _scored("external_wall_insulation", gain=10.0, cost=8000.0), + _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + ], + [_scored("loft_insulation", gain=4.0, cost=1500.0)], + [_scored("suspended_floor_insulation", gain=3.0, cost=2000.0)], + ] + + # Act + selection: list[ScoredOption] = optimise(groups, budget=5000.0) + + # Assert — cavity + loft + floor (cost 4500, gain 13) beats any package + # containing the 8000 EWI option within the 5000 budget. + assert _selected_types(selection) == { + "cavity_wall_insulation", + "loft_insulation", + "suspended_floor_insulation", + } + + +def test_picks_at_most_one_option_per_group() -> None: + # Arrange — both wall options are individually affordable. + groups: list[list[ScoredOption]] = [ + [ + _scored("external_wall_insulation", gain=10.0, cost=2000.0), + _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + ], + ] + + # Act + selection: list[ScoredOption] = optimise(groups, budget=10000.0) + + # Assert — never both treatments of the same wall; the higher-gain one wins. + assert len(selection) == 1 + assert _selected_types(selection) == {"external_wall_insulation"} + + +def test_no_budget_picks_the_best_option_in_every_group() -> None: + # Arrange + groups: list[list[ScoredOption]] = [ + [ + _scored("external_wall_insulation", gain=10.0, cost=8000.0), + _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + ], + [_scored("loft_insulation", gain=4.0, cost=1500.0)], + ] + + # Act — None budget = unconstrained. + selection: list[ScoredOption] = optimise(groups, budget=None) + + # Assert + assert _selected_types(selection) == { + "external_wall_insulation", + "loft_insulation", + } + + +def test_budget_too_small_for_any_option_selects_nothing() -> None: + # Arrange + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [_scored("loft_insulation", gain=4.0, cost=1500.0)], + ] + + # Act + selection: list[ScoredOption] = optimise(groups, budget=500.0) + + # Assert — nothing affordable; selecting none is the optimum. + assert selection == [] + + +def test_no_groups_selects_nothing() -> None: + # Act / Assert + assert optimise([], budget=10000.0) == [] + + +def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> None: + # Arrange — only one of the two fits the budget; pick the affordable best. + groups: list[list[ScoredOption]] = [ + [_scored("external_wall_insulation", gain=10.0, cost=8000.0)], + [_scored("loft_insulation", gain=4.0, cost=1500.0)], + ] + + # Act + selection: list[ScoredOption] = optimise(groups, budget=2000.0) + + # Assert — EWI is unaffordable; loft alone is the best within £2000. + assert _selected_types(selection) == {"loft_insulation"} + + +# --- optimise_min_cost: least-cost-to-target selection (ADR-0016 amendment) --- + + +def test_min_cost_picks_the_cheapest_package_that_reaches_the_target() -> None: + # Arrange — two packages both clear the target gain; one is cheaper. + groups: list[list[ScoredOption]] = [ + [ + _scored("loft_insulation", gain=10.0, cost=2000.0), + _scored("external_wall_insulation", gain=15.0, cost=3000.0), + ], + ] + + # Act + selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0) + + # Assert — least-cost-to-target takes the +10 @ £2000, NOT the higher-gain + # +15 @ £3000 (no overshoot, surplus budget unspent). + assert selection is not None + assert _selected_types(selection) == {"loft_insulation"} + + +def test_min_cost_combines_groups_to_reach_the_target_at_least_cost() -> None: + # Arrange — no single option reaches +10; the cheapest combo that does is + # cavity (+6, £1000) + loft (+4, £1500) = +10 @ £2500, beating EWI (+10, + # £8000). + groups: list[list[ScoredOption]] = [ + [ + _scored("cavity_wall_insulation", gain=6.0, cost=1000.0), + _scored("external_wall_insulation", gain=10.0, cost=8000.0), + ], + [_scored("loft_insulation", gain=4.0, cost=1500.0)], + ] + + # Act + selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0) + + # Assert + assert selection is not None + assert _selected_types(selection) == { + "cavity_wall_insulation", + "loft_insulation", + } + + +def test_min_cost_breaks_cost_ties_toward_the_higher_gain() -> None: + # Arrange — two equally-priced packages both reach the target; prefer the + # one with more headroom ("recommend more" on a tie). + groups: list[list[ScoredOption]] = [ + [ + _scored("cavity_wall_insulation", gain=10.0, cost=2000.0), + _scored("external_wall_insulation", gain=14.0, cost=2000.0), + ], + ] + + # Act + selection = optimise_min_cost(groups, budget=10000.0, target_gain=10.0) + + # Assert + assert selection is not None + assert _selected_types(selection) == {"external_wall_insulation"} + + +def test_min_cost_returns_none_when_target_unreachable_within_budget() -> None: + # Arrange — the only target-reaching package costs more than the budget. + groups: list[list[ScoredOption]] = [ + [_scored("external_wall_insulation", gain=10.0, cost=8000.0)], + ] + + # Act + selection = optimise_min_cost(groups, budget=5000.0, target_gain=10.0) + + # Assert — infeasible (caller falls back to max-gain). + assert selection is None + + +def test_min_cost_returns_none_when_no_package_reaches_the_target() -> None: + # Arrange — even everything together falls short of the target gain. + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], + [_scored("loft_insulation", gain=3.0, cost=1500.0)], + ] + + # Act + selection = optimise_min_cost(groups, budget=None, target_gain=10.0) + + # Assert + assert selection is None + + +def test_min_cost_unbudgeted_picks_cheapest_reaching_target_not_everything() -> None: + # Arrange — no budget cap, but min-cost still means cheapest-to-target, not + # "install everything". + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=10.0, cost=1000.0)], + [_scored("loft_insulation", gain=4.0, cost=1500.0)], + ] + + # Act — cavity alone (+10 @ £1000) already reaches the target. + selection = optimise_min_cost(groups, budget=None, target_gain=10.0) + + # Assert — loft is left off; it would only add cost past the target. + assert selection is not None + assert _selected_types(selection) == {"cavity_wall_insulation"} + + +def test_min_cost_non_positive_target_selects_nothing() -> None: + # Arrange — a target already met (gain 0 needed) is reached by the empty + # package at zero cost. + groups: list[list[ScoredOption]] = [ + [_scored("cavity_wall_insulation", gain=6.0, cost=1000.0)], + ] + + # Act + selection = optimise_min_cost(groups, budget=5000.0, target_gain=0.0) + + # Assert — the cheapest target-reaching package is the empty one. + assert selection == [] + + +def test_repair_adds_an_untreated_group_option_to_close_the_undershoot() -> None: + # Arrange — role-1 under-counts roof (signal 0 → warm-start skips it), but + # its true re-scored gain (+4) is what closes the target. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [_scored_overlay("suspended_floor_insulation", gain=8.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + ] + scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + + # Act + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=5000.0, + target_sap=50.0, + ) + + # Assert — warm-start took wall+floor (re-score 48 < 50); repair added the + # roof (true +4) to reach 52, the truthful package total. + types = {scored.option.measure_type for scored in package.selected} + assert "loft_insulation" in types + assert types == { + "cavity_wall_insulation", + "suspended_floor_insulation", + "loft_insulation", + } + assert abs(package.score.sap_continuous - 52.0) <= 1e-9 + + +def test_no_target_returns_the_warm_start_package_without_repair() -> None: + # Arrange + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + ] + scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + + # Act + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=None, + target_sap=None, + ) + + # Assert — no target → no repair; warm-start package re-scored as the truth. + assert {s.option.measure_type for s in package.selected} == { + "cavity_wall_insulation" + } + assert abs(package.score.sap_continuous - 45.0) <= 1e-9 + + +def test_repair_stops_when_no_affordable_improving_option_remains() -> None: + # Arrange — the only untreated-group option costs more than the budget left. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=0.0, cost=5000.0, overlay=_ROOF_OVERLAY)], + ] + scorer = _StubScorer(base=40.0, wall=5.0, roof=4.0, floor=3.0) + + # Act + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=1000.0, + target_sap=50.0, + ) + + # Assert — wall only (re-score 45 < 50); roof unaffordable, so repair stops + # at the best achievable package rather than overspending. + assert {s.option.measure_type for s in package.selected} == { + "cavity_wall_insulation" + } + assert abs(package.score.sap_continuous - 45.0) <= 1e-9 + + +# --- optimise_package: least-cost-to-target objective (ADR-0016 amendment) --- + + +def test_package_stops_at_the_target_and_does_not_overshoot() -> None: + # Arrange — wall alone already clears the target; max-gain would add roof + + # floor too. Least-cost-to-target must stop at the wall. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + ] + scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) + + # Act — target 69 (gain 9); wall (+10 → 70) reaches it for £1000. + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=10000.0, + target_sap=69.0, + ) + + # Assert — just the wall; roof + floor (which would reach 80) are left off, + # surplus budget unspent. + assert _selected_types(package.selected) == {"cavity_wall_insulation"} + assert abs(package.score.sap_continuous - 70.0) <= 1e-9 + + +def test_package_falls_back_to_max_gain_when_target_unreachable() -> None: + # Arrange — even all three measures (+20 → 80) cannot reach the target. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=5.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + [_scored_overlay("suspended_floor_insulation", gain=5.0, cost=1000.0, overlay=_FLOOR_OVERLAY)], + ] + scorer = _StubScorer(base=60.0, wall=10.0, roof=5.0, floor=5.0) + + # Act — target 90 is out of reach; best effort is the most SAP budget buys. + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=10000.0, + target_sap=90.0, + ) + + # Assert — max-gain: all three, SAP 80 (below target, best effort). + assert _selected_types(package.selected) == { + "cavity_wall_insulation", + "loft_insulation", + "suspended_floor_insulation", + } + assert abs(package.score.sap_continuous - 80.0) <= 1e-9 + + +def test_package_repairs_when_the_signal_overshoots_the_true_score() -> None: + # Arrange — the wall's role-1 signal (+10) clears the target gain, so the + # min-cost warm-start picks it alone; but its true gain is only +5, so the + # package undershoots and repair must top it up. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + ] + scorer = _StubScorer(base=60.0, wall=5.0, roof=4.0, floor=0.0) + + # Act — target 69 (gain 9). Warm-start {wall} (signal 10) → true 65 < 69 → + # repair adds the roof (+4) → 69. + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=10000.0, + target_sap=69.0, + ) + + # Assert + assert _selected_types(package.selected) == { + "cavity_wall_insulation", + "loft_insulation", + } + assert abs(package.score.sap_continuous - 69.0) <= 1e-9 + + +# --- Measure Dependency injection (ADR-0016) ------------------------------- + +_VENT_OVERLAY = EpcSimulation( + ventilation=VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) +) + + +class _VentStubScorer: + """A stub that adds a fixed gain per wall overlay present and a fixed + (negative) `vent` contribution when a ventilation overlay is present — + so the Measure Dependency's effect on the truthful package total and the + repair decision is exercised without the calculator.""" + + def __init__(self, *, base: float, wall: float, roof: float, vent: float) -> None: + self._base = base + self._wall = wall + self._roof = roof + self._vent = vent + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + sap = self._base + for sim in simulations: + if sim.ventilation is not None: + sap += self._vent + for part in sim.building_parts.values(): + if part.wall_insulation_type is not None: + sap += self._wall + if part.roof_insulation_thickness is not None: + sap += self._roof + return Score(sap_continuous=sap, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0) + + +def _ventilation_dependency(*, cost: float) -> MeasureDependency: + """A forced 'fabric requires ventilation' edge for the tests.""" + return MeasureDependency( + triggers=frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION} + ), + required=ScoredOption( + option=MeasureOption( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=0.0, + ), + ) + + +def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -> None: + # Arrange — cavity is dirt cheap (£100) and its role-1 signal (+6) alone + # reaches the target gain, so a ventilation-BLIND min-cost would pick it. + # But the wall forces in ventilation at a true/­signal −5, which sinks the + # package below target. A ventilation-AWARE warm-start prices that −5 into + # the candidate and instead takes the wall-free loft path. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=6.0, cost=100.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=8.0, cost=1500.0, overlay=_ROOF_OVERLAY)], + ] + scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) + dependency = MeasureDependency( + triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}), + required=ScoredOption( + option=MeasureOption( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=300.0, contingency_rate=0.0), + ), + sap_gain=0.0, # placeholder; optimise_package scores the real signal + ), + ) + + # Act — target 66 (gain 6 over the 60 baseline). + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=10000.0, + target_sap=66.0, + dependencies=[dependency], + ) + + # Assert — the loft path (true 68, £1500), NOT cavity + forced ventilation: + # cavity's signal (+6) is cancelled by ventilation (−5) to +1 < target. + assert _selected_types(package.selected) == {"loft_insulation"} + assert abs(package.score.sap_continuous - 68.0) <= 1e-9 + + +def test_dependency_injected_when_a_trigger_measure_is_selected() -> None: + # Arrange — the wall is selected, so its ventilation dependency must be + # injected before the re-score; ventilation never competes in the pool. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + ] + scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + + # Act + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=None, + target_sap=None, + dependencies=[_ventilation_dependency(cost=900.0)], + ) + + # Assert — ventilation is in the package and its negative contribution lands + # in the truthful total: 40 base + 5 wall − 2 ventilation = 43. + assert _selected_types(package.selected) == { + "cavity_wall_insulation", + "mechanical_ventilation", + } + assert abs(package.score.sap_continuous - 43.0) <= 1e-9 + + +def test_dependency_not_injected_without_a_trigger_measure() -> None: + # Arrange — only loft is selected; the wall-triggered ventilation dependency + # must not fire. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("loft_insulation", gain=4.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + ] + scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + + # Act + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=None, + target_sap=None, + dependencies=[_ventilation_dependency(cost=900.0)], + ) + + # Assert — no trigger, no ventilation; 40 base + 4 roof = 44. + assert _selected_types(package.selected) == {"loft_insulation"} + assert abs(package.score.sap_continuous - 44.0) <= 1e-9 + + +def test_wall_dropped_when_it_cannot_be_ventilated_within_budget() -> None: + # Arrange — cavity (£1000) fits the £1000 budget on its own, but its + # mandatory ventilation (£900) would bust it. We never blow the budget: a + # wall we can't afford to ventilate is a wall we can't afford, so it is + # dropped (the budget is a hard envelope, ventilation is not forced over it). + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + ] + scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + + # Act — tight budget; ventilation-aware selection prices the £900 in. + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=1000.0, + target_sap=None, + dependencies=[_ventilation_dependency(cost=900.0)], + ) + + # Assert — nothing recommended; the budget is respected and the wall is + # never left un-ventilated. + assert package.selected == [] + + +def test_injected_ventilation_penalty_drives_extra_repair() -> None: + # Arrange — wall (+5) injects ventilation (−2): re-score 43 < target 46. + # Repair adds the roof (true +4) to reach 47, paying for the ventilation + # penalty out of the budget the dependency's cost has already eaten into. + groups: list[list[ScoredOption]] = [ + [_scored_overlay("cavity_wall_insulation", gain=10.0, cost=1000.0, overlay=_WALL_OVERLAY)], + [_scored_overlay("loft_insulation", gain=0.0, cost=1000.0, overlay=_ROOF_OVERLAY)], + ] + scorer = _VentStubScorer(base=40.0, wall=5.0, roof=4.0, vent=-2.0) + + # Act + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=build_epc(), + budget=5000.0, + target_sap=46.0, + dependencies=[_ventilation_dependency(cost=900.0)], + ) + + # Assert — repair pulled the roof in to clear the target net of ventilation: + # 40 + 5 wall − 2 vent + 4 roof = 47. + assert _selected_types(package.selected) == { + "cavity_wall_insulation", + "loft_insulation", + "mechanical_ventilation", + } + assert abs(package.score.sap_continuous - 47.0) <= 1e-9 diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py new file mode 100644 index 00000000..1dce4067 --- /dev/null +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -0,0 +1,516 @@ +"""Behaviour of the Overlay Applicator: folding Simulation Overlays +(EpcSimulation) onto a baseline EpcPropertyData to produce a new one for +the calculator. See ADR-0016 and the Modelling glossary in CONTEXT.md. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + PhotovoltaicArray, + PvBatteries, + PvBattery, + SapBuildingPart, + SapVentilation, +) +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + HeatingOverlay, + LightingOverlay, + SecondaryHeatingOverlay, + SolarOverlay, + VentilationOverlay, + WindowOverlay, +) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart: + return next(p for p in epc.sap_building_parts if p.identifier is identifier) + + +def test_apply_writes_targeted_building_part_and_leaves_others_untouched() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + extension_before: int | str = _part( + baseline, BuildingPartIdentifier.EXTENSION_1 + ).wall_insulation_type + simulation = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1) + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert + assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 1 + assert ( + _part(result, BuildingPartIdentifier.EXTENSION_1).wall_insulation_type + == extension_before + ) + + +def test_empty_simulation_is_a_no_op() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + + # Act + result: EpcPropertyData = apply_simulations(baseline, [EpcSimulation()]) + + # Assert + assert result == baseline + + +def test_later_simulation_wins_on_a_shared_field() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + first = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1) + } + ) + second = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [first, second]) + + # Assert + assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 2 + + +def test_apply_writes_dwelling_ventilation_onto_sap_ventilation() -> None: + # Arrange — a Measure Dependency overlay targets the whole-dwelling + # ventilation system (no building part), e.g. retrofit MEV. + baseline: EpcPropertyData = build_epc() + simulation = EpcSimulation( + ventilation=VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert + assert result.sap_ventilation is not None + assert ( + result.sap_ventilation.mechanical_ventilation_kind + == "EXTRACT_OR_PIV_OUTSIDE" + ) + + +def test_ventilation_overlay_creates_sap_ventilation_when_baseline_has_none() -> None: + # Arrange — a naturally-ventilated baseline that lodged no SapVentilation. + baseline: EpcPropertyData = build_epc() + baseline.sap_ventilation = None + simulation = EpcSimulation( + ventilation=VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert + assert isinstance(result.sap_ventilation, SapVentilation) + assert ( + result.sap_ventilation.mechanical_ventilation_kind + == "EXTRACT_OR_PIV_OUTSIDE" + ) + + +def test_ventilation_overlay_leaves_building_parts_and_baseline_untouched() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + main_before: int | str = _part( + baseline, BuildingPartIdentifier.MAIN + ).wall_insulation_type + simulation = EpcSimulation( + ventilation=VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — ventilation overlay touches only sap_ventilation; the baseline + # is never mutated. + assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before + assert baseline.sap_ventilation is not None + assert baseline.sap_ventilation.mechanical_ventilation_kind is None + + +def test_baseline_is_not_mutated() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + original: int | str = _part( + baseline, BuildingPartIdentifier.MAIN + ).wall_insulation_type + + # Act + _: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=1 + ) + } + ) + ], + ) + + # Assert + assert ( + _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original + ) + + +def test_apply_folds_a_window_overlay_by_index_into_transmission_details() -> None: + # Arrange — window 0 starts double (glazing_type 2, U 2.8, g 0.76); the + # overlay upgrades it to a modern double spec, writing the U-value and + # solar-g into the nested WindowTransmissionDetails (ADR-0022). + baseline: EpcPropertyData = build_epc() + + # Act — target window 0 by its sap_windows index. + result: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + windows={ + 0: WindowOverlay( + glazing_type=5, u_value=1.40, solar_transmittance=0.72 + ) + } + ) + ], + ) + + # Assert — glazing_type set on the window; U/g routed into the transmission + # details (where the cascade reads them); other windows untouched. + upgraded = result.sap_windows[0] + assert upgraded.glazing_type == 5 + assert upgraded.window_transmission_details is not None + assert abs(upgraded.window_transmission_details.u_value - 1.40) <= 1e-9 + assert abs(upgraded.window_transmission_details.solar_transmittance - 0.72) <= 1e-9 + assert result.sap_windows[1].window_transmission_details is not None + assert abs(result.sap_windows[1].window_transmission_details.u_value - 2.8) <= 1e-9 + + +def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + assert baseline.sap_windows[0].window_transmission_details is not None + original_u: float = baseline.sap_windows[0].window_transmission_details.u_value + + # Act + _: EpcPropertyData = apply_simulations( + baseline, + [EpcSimulation(windows={0: WindowOverlay(u_value=1.40)})], + ) + + # Assert + assert baseline.sap_windows[0].window_transmission_details is not None + assert ( + baseline.sap_windows[0].window_transmission_details.u_value == original_u + ) + + +def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None: + # Arrange — a whole-dwelling lighting change (no building part), e.g. an + # all-LED upgrade folded onto the top-level bulb counts (ADR-0023). + baseline: EpcPropertyData = build_epc() + simulation = EpcSimulation( + lighting=LightingOverlay( + led_fixed_lighting_bulbs_count=8, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + low_energy_fixed_lighting_bulbs_count=0, + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert + assert result.led_fixed_lighting_bulbs_count == 8 + assert result.cfl_fixed_lighting_bulbs_count == 0 + assert result.incandescent_fixed_lighting_bulbs_count == 0 + assert result.low_energy_fixed_lighting_bulbs_count == 0 + + +def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: + # Arrange — a whole-system HHR storage bundle replacing 000490's gas combi + # (fuel 26, control 2106, no cylinder, mains_gas True). The heating overlay + # is the deepest surface: it writes across main_heating_details[0], + # sap_heating, the top-level EpcPropertyData, and sap_energy_source at once + # (ADR-0024). + baseline: EpcPropertyData = build_epc() + simulation = EpcSimulation( + heating=HeatingOverlay( + main_fuel_type=30, + sap_main_heating_code=409, + main_heating_control=2404, + water_heating_code=903, + water_heating_fuel=30, + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=120, + has_hot_water_cylinder=True, + meter_type="18 Hour", + mains_gas=False, + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — every targeted field routed to its home object. + main = result.sap_heating.main_heating_details[0] + assert main.main_fuel_type == 30 + assert main.sap_main_heating_code == 409 + assert main.main_heating_control == 2404 + assert result.sap_heating.water_heating_code == 903 + assert result.sap_heating.water_heating_fuel == 30 + assert result.sap_heating.cylinder_size == 2 + assert result.sap_heating.cylinder_insulation_type == 1 + assert result.sap_heating.cylinder_insulation_thickness_mm == 120 + assert result.has_hot_water_cylinder is True + assert result.sap_energy_source is not None + assert result.sap_energy_source.meter_type == "18 Hour" + assert result.sap_energy_source.mains_gas is False + + +def test_secondary_heating_overlay_clears_the_lodged_secondary() -> None: + # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ + # convector/radiant heaters). Pin a fuel on it too so we prove the fold + # clears BOTH the type and the fuel (ADR-0028). + baseline: EpcPropertyData = build_epc() + baseline.sap_heating.secondary_fuel_type = 30 + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act — fold a removal overlay. + result: EpcPropertyData = apply_simulations( + baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] + ) + + # Assert — the secondary is gone from the dwelling handed to the calculator. + assert result.sap_heating.secondary_heating_type is None + assert result.sap_heating.secondary_fuel_type is None + + +def test_secondary_heating_removal_does_not_mutate_the_baseline() -> None: + # Arrange — 000490 lodges secondary SAP code 691. + baseline: EpcPropertyData = build_epc() + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act — fold a removal overlay. + _: EpcPropertyData = apply_simulations( + baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] + ) + + # Assert — the baseline's secondary is untouched (the fold copies first). + assert baseline.sap_heating.secondary_heating_type == 691 + + +def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: + # Arrange — 000490 lodges a mains-gas combi (fuel 26, control 2106, no + # cylinder, mains_gas True). + baseline: EpcPropertyData = build_epc() + original_fuel = baseline.sap_heating.main_heating_details[0].main_fuel_type + original_control = baseline.sap_heating.main_heating_details[0].main_heating_control + original_wh_code: int | None = baseline.sap_heating.water_heating_code + original_cylinder = baseline.has_hot_water_cylinder + assert baseline.sap_energy_source is not None + original_mains_gas = baseline.sap_energy_source.mains_gas + + # Act — fold an HHR storage bundle. + _: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + heating=HeatingOverlay( + main_fuel_type=30, + sap_main_heating_code=409, + main_heating_control=2404, + water_heating_code=903, + has_hot_water_cylinder=True, + mains_gas=False, + ) + ) + ], + ) + + # Assert — the baseline's heating is untouched. + assert baseline.sap_heating.main_heating_details[0].main_fuel_type == original_fuel + assert ( + baseline.sap_heating.main_heating_details[0].main_heating_control + == original_control + ) + assert baseline.sap_heating.water_heating_code == original_wh_code + assert baseline.has_hot_water_cylinder == original_cylinder + assert baseline.sap_energy_source.mains_gas == original_mains_gas + + +def test_heating_index_overlay_clears_a_stale_sap_main_heating_code() -> None: + # Arrange — 000490's gas combi lodges a Table 4a code; an ASHP bundle sets a + # PCDB index instead. The two are mutually-exclusive efficiency anchors, so + # the stale code must be cleared or it wins the calculator's dispatch. + baseline: EpcPropertyData = build_epc() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 104 + + # Act + result: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + heating=HeatingOverlay( + main_heating_index_number=101413, main_heating_category=4 + ) + ) + ], + ) + + # Assert — the index is set and the old SAP code is gone. + main = result.sap_heating.main_heating_details[0] + assert main.main_heating_index_number == 101413 + assert main.sap_main_heating_code is None + + +def test_heating_sap_code_overlay_clears_a_stale_index() -> None: + # Arrange — a dwelling with a PCDB-indexed system; an HHR storage bundle sets + # a Table 4a code instead, so the stale index must be cleared. + baseline: EpcPropertyData = build_epc() + baseline.sap_heating.main_heating_details[0].main_heating_index_number = 8262 + + # Act + result: EpcPropertyData = apply_simulations( + baseline, + [EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=409))], + ) + + # Assert + main = result.sap_heating.main_heating_details[0] + assert main.sap_main_heating_code == 409 + assert main.main_heating_index_number is None + + +def test_apply_folds_a_solar_overlay_onto_the_energy_source() -> None: + # Arrange — 000490 lodges no PV, not export-capable, no diverter. A Solar PV + # Option installs a two-segment array, ensures export, and adds a battery + # (ADR-0026). The solar overlay is the sixth surface; it writes onto + # sap_energy_source. + baseline: EpcPropertyData = build_epc() + arrays = [ + PhotovoltaicArray(peak_power=4.8, pitch=2, orientation=5, overshading=1), + PhotovoltaicArray(peak_power=1.2, pitch=2, orientation=6, overshading=2), + ] + simulation = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=arrays, + pv_diverter_present=True, + pv_connection=1, + is_dwelling_export_capable=True, + pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)), + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — every field routed onto sap_energy_source. + source = result.sap_energy_source + assert source.photovoltaic_arrays is not None + assert [a.peak_power for a in source.photovoltaic_arrays] == [4.8, 1.2] + assert [a.orientation for a in source.photovoltaic_arrays] == [5, 6] + assert [a.overshading for a in source.photovoltaic_arrays] == [1, 2] + assert source.pv_diverter_present is True + assert source.pv_connection == 1 + assert source.is_dwelling_export_capable is True + assert source.pv_batteries is not None + assert abs(source.pv_batteries.pv_battery.battery_capacity - 5.0) <= 1e-9 + + +def test_solar_overlay_leaves_diverter_unset_when_omitted() -> None: + # Arrange — a combi dwelling gets PV without a diverter (nothing to divert + # to); the omitted field leaves the baseline False unchanged. + baseline: EpcPropertyData = build_epc() + simulation = EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray(peak_power=3.2, pitch=2, orientation=5, overshading=1) + ], + is_dwelling_export_capable=True, + ) + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — diverter untouched (still False), export flipped True. + assert result.sap_energy_source.pv_diverter_present is False + assert result.sap_energy_source.is_dwelling_export_capable is True + + +def test_baseline_energy_source_is_not_mutated_by_a_solar_overlay() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + original_export = baseline.sap_energy_source.is_dwelling_export_capable + original_arrays = baseline.sap_energy_source.photovoltaic_arrays + + # Act + _: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + solar=SolarOverlay( + photovoltaic_arrays=[ + PhotovoltaicArray( + peak_power=3.2, pitch=2, orientation=5, overshading=1 + ) + ], + is_dwelling_export_capable=True, + ) + ) + ], + ) + + # Assert — the baseline's energy source is untouched. + assert baseline.sap_energy_source.is_dwelling_export_capable == original_export + assert baseline.sap_energy_source.photovoltaic_arrays == original_arrays + + +def test_baseline_lighting_is_not_mutated_by_a_lighting_overlay() -> None: + # Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED. + baseline: EpcPropertyData = build_epc() + original_led: int = baseline.led_fixed_lighting_bulbs_count + original_lel: int | None = baseline.low_energy_fixed_lighting_bulbs_count + + # Act — fold an all-LED overlay (led = the 8 total). + _: EpcPropertyData = apply_simulations( + baseline, + [ + EpcSimulation( + lighting=LightingOverlay( + led_fixed_lighting_bulbs_count=8, + low_energy_fixed_lighting_bulbs_count=0, + ) + ) + ], + ) + + # Assert — the baseline's counts are untouched. + assert baseline.led_fixed_lighting_bulbs_count == original_led + assert baseline.low_energy_fixed_lighting_bulbs_count == original_lel diff --git a/tests/domain/modelling/test_package_scorer.py b/tests/domain/modelling/test_package_scorer.py new file mode 100644 index 00000000..e0575ea6 --- /dev/null +++ b/tests/domain/modelling/test_package_scorer.py @@ -0,0 +1,71 @@ +"""Behaviour of the Package Scorer: composing Simulation Overlays onto a +baseline EpcPropertyData and scoring the result on the deterministic SAP10 +calculator. The reusable compute primitive (ADR-0016). Elmhurst before/after +cascade pins land with #1154 once the cert parses; here we exercise the real +calculator on a hand-built EPD. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.scoring.package_scorer import PackageScorer, Score +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from domain.sap10_calculator.calculator import Sap10Calculator, SapResult +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +_CAVITY_FILL = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) + + +def test_filling_the_main_cavity_improves_sap() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN: uninsulated cavity + scorer = PackageScorer(Sap10Calculator()) + + # Act + base: Score = scorer.score(baseline, []) + filled: Score = scorer.score(baseline, [_CAVITY_FILL]) + + # Assert + assert filled.sap_continuous > base.sap_continuous + + +def test_empty_package_scores_the_unmodified_baseline() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + calculator = Sap10Calculator() + direct: SapResult = calculator.calculate(baseline) + + # Act + score: Score = PackageScorer(calculator).score(baseline, []) + + # Assert + assert abs(score.sap_continuous - direct.sap_score_continuous) <= 1e-9 + assert abs(score.co2_kg_per_yr - direct.co2_kg_per_yr) <= 1e-9 + assert ( + abs(score.primary_energy_kwh_per_yr - direct.primary_energy_kwh_per_yr) + <= 1e-9 + ) + + +def test_score_carries_the_scored_sap_result_for_billing() -> None: + # Arrange — the post-package SapResult must ride on the Score so Bill + # Derivation can price the simulated end-state without a second calculate + # (ADR-0014 amendment). + baseline: EpcPropertyData = build_epc() + scorer = PackageScorer(Sap10Calculator()) + + # Act + filled: Score = scorer.score(baseline, [_CAVITY_FILL]) + + # Assert — the SapResult is the one the Score's headline figures came from. + assert filled.sap_result is not None + assert ( + abs(filled.sap_result.sap_score_continuous - filled.sap_continuous) <= 1e-9 + ) diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py new file mode 100644 index 00000000..1b191202 --- /dev/null +++ b/tests/domain/modelling/test_plan.py @@ -0,0 +1,97 @@ +"""Behaviour of the Plan / PlanMeasure domain types: the per-Property output +of one Scenario's modelling run. A Plan carries its selected Plan Measures +(the Optimised Package) plus the baseline/post-retrofit Scores, and derives +the persisted headline figures (cost aggregates, CO₂ saving, post-retrofit +band). Single-phase, flat post-retrofit figures (ADR-0005 / ADR-0017). +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc import Epc +from domain.billing.bill import Bill, BillSection, BillSectionCost +from domain.modelling.measure_type import MeasureType +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring.scoring import MeasureImpact + + +def _bill(*, heating_kwh: float, total_gbp: float) -> Bill: + return Bill( + sections={ + BillSection.HEATING: BillSectionCost(kwh=heating_kwh, cost_gbp=total_gbp) + }, + standing_charges_gbp=0.0, + seg_credit_gbp=0.0, + total_gbp=total_gbp, + ) + + +def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure: + return PlanMeasure( + measure_type=MeasureType(measure_type), + description=measure_type.replace("_", " "), + cost=Cost(total=total, contingency_rate=rate), + impact=MeasureImpact( + sap_points=1.0, co2_savings_kg_per_yr=1.0, energy_savings_kwh_per_yr=1.0 + ), + ) + + +def test_plan_aggregates_cost_and_savings_and_bands_the_post_sap() -> None: + # Arrange + measures: tuple[PlanMeasure, ...] = ( + _measure("cavity_wall_insulation", total=1000.0, rate=0.10), + _measure("loft_insulation", total=500.0, rate=0.20), + ) + baseline = Score( + sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0 + ) + post = Score( + sap_continuous=70.4, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0 + ) + plan = Plan(measures=measures, baseline=baseline, post_retrofit=post) + + # Act / Assert + assert abs(plan.cost_of_works - 1500.0) <= 1e-9 + assert abs(plan.contingency_cost - 200.0) <= 1e-9 # 1000*0.10 + 500*0.20 + assert abs(plan.co2_savings_kg_per_yr - 400.0) <= 1e-9 # baseline - post + assert abs(plan.post_sap_continuous - 70.4) <= 1e-9 + assert plan.post_epc_rating is Epc.C # round(70.4) = 70 → band C (69–80) + + +def test_plan_derives_post_bill_and_savings_from_the_baseline_and_post_bills() -> None: + # Arrange — a Plan whose baseline and post-package Bills have been derived. + baseline = Score( + sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0 + ) + post = Score( + sap_continuous=70.0, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0 + ) + plan = Plan( + measures=(), + baseline=baseline, + post_retrofit=post, + baseline_bill=_bill(heating_kwh=10000.0, total_gbp=2000.0), + post_bill=_bill(heating_kwh=6000.0, total_gbp=1400.0), + ) + + # Act / Assert — plan-level energy/bill figures (ADR-0014 amendment). + assert plan.post_energy_bill == 1400.0 + assert plan.energy_bill_savings == 600.0 # 2000 − 1400 + assert plan.post_energy_consumption == 6000.0 # Σ post section kWh + assert plan.energy_consumption_savings == 4000.0 # 10000 − 6000 + + +def test_plan_energy_figures_are_none_without_bills() -> None: + # Arrange — a Plan with no bills derived (the figures persist as NULL). + score = Score( + sap_continuous=55.0, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=15000.0 + ) + plan = Plan(measures=(), baseline=score, post_retrofit=score) + + # Act / Assert + assert plan.post_energy_bill is None + assert plan.energy_bill_savings is None + assert plan.post_energy_consumption is None + assert plan.energy_consumption_savings is None diff --git a/tests/domain/modelling/test_plan_valuation.py b/tests/domain/modelling/test_plan_valuation.py new file mode 100644 index 00000000..61d78a78 --- /dev/null +++ b/tests/domain/modelling/test_plan_valuation.py @@ -0,0 +1,74 @@ +"""A Plan derives its Valuation Uplift from its band jump (ADR-0018). + +The uplift is plan-conditional — it needs the Plan's baseline -> post band jump +and its cost — so the Plan derives it, given one external input: the Property's +current market value (mostly absent, so the £ forms are usually None).""" + +from __future__ import annotations + +from domain.modelling.plan import Plan +from domain.modelling.scoring.package_scorer import Score +from infrastructure.postgres.modelling import PlanModel + + +def _plan(*, current_market_value: float | None) -> Plan: + # Baseline SAP 57.4 rounds to band D; post 70.0 rounds to band C. + baseline = Score( + sap_continuous=57.4, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=300.0 + ) + post = Score( + sap_continuous=70.0, co2_kg_per_yr=2100.0, primary_energy_kwh_per_yr=240.0 + ) + return Plan( + measures=(), + baseline=baseline, + post_retrofit=post, + current_market_value=current_market_value, + ) + + +def test_plan_derives_pound_uplift_from_a_current_market_value() -> None: + # Arrange — a £200k property modelled D -> C. + plan: Plan = _plan(current_market_value=200_000.0) + + # Act + uplift = plan.valuation + + # Assert — D->C average 2.5% of £200k = £5,000 uplift, £205,000 post-retrofit. + assert uplift.average_value is not None + assert abs(uplift.average_value - 5_000.0) <= 1e-6 + assert uplift.post_retrofit_value is not None + assert abs(uplift.post_retrofit_value - 205_000.0) <= 1e-6 + + +def test_plan_model_persists_the_valuation_pound_forms() -> None: + # Arrange + plan: Plan = _plan(current_market_value=200_000.0) + + # Act + model: PlanModel = PlanModel.from_domain( + plan, property_id=1, scenario_id=7, portfolio_id=1, is_default=True + ) + + # Assert + assert model.valuation_increase_lower_bound is not None + assert abs(model.valuation_increase_lower_bound - 4_000.0) <= 1e-6 + assert model.valuation_increase_average is not None + assert abs(model.valuation_increase_average - 5_000.0) <= 1e-6 + assert model.valuation_post_retrofit is not None + assert abs(model.valuation_post_retrofit - 205_000.0) <= 1e-6 + + +def test_plan_model_leaves_valuation_null_without_a_market_value() -> None: + # Arrange — no current market value (the common case at scale). + plan: Plan = _plan(current_market_value=None) + + # Act + model: PlanModel = PlanModel.from_domain( + plan, property_id=1, scenario_id=7, portfolio_id=1, is_default=True + ) + + # Assert — the percentage is still derivable, but the £ columns stay NULL. + assert model.valuation_increase_average is None + assert model.valuation_post_retrofit is None + assert abs(plan.valuation.average_pct - 0.025) <= 1e-9 diff --git a/tests/domain/modelling/test_products.py b/tests/domain/modelling/test_products.py new file mode 100644 index 00000000..0fa81b58 --- /dev/null +++ b/tests/domain/modelling/test_products.py @@ -0,0 +1,313 @@ +"""Behaviour of `Products.ashp_bundle_cost` — the composite, per-dwelling ASHP +bundle cost (ADR-0025). Pure catalogue math: given a typed `AshpCostInputs` it +selects and sums the applicable Southern Housing rate lines (decommission + +heat pump + cylinder + distribution) into a `Cost`, carrying the separate ASHP +contingency. No EpcPropertyData / calculator — the dwelling interpretation that +produces the inputs lives in the modelling layer. + +Costs are pinned against the real Southern Housing Group rate sheet, so the +totals are exact (delta <= 1e-9), mirroring the cascade-pin philosophy. +""" + +from dataclasses import replace + +from domain.modelling.products import ( + AshpCostInputs, + AshpExistingSystem, + AshpRates, + BoilerCostInputs, + Products, + TuneUpCostInputs, +) +from domain.modelling.recommendation import Cost + +_PIN: float = 1e-9 + + +def test_ashp_bundle_cost_composes_an_electric_storage_full_distribution_dwelling() -> None: + # Arrange — a small electric-storage dwelling: no reusable wet system, so a + # full new wet distribution is priced. 4 kW design heat loss (smallest pump + # band), 7 radiators. + products = Products() + inputs = AshpCostInputs( + existing_system=AshpExistingSystem.ELECTRIC_STORAGE, + is_small_property=True, + design_heat_loss_kw=4.0, + radiator_count=7, + has_reusable_wet_system=False, + ) + + # Act + cost: Cost = products.ashp_bundle_cost(inputs) + + # Assert — decommission 570 + pump 9720 + cylinder 2382.60 + distribution + # (7 rads) 3618 = 16290.60, with the separate 25% ASHP contingency. + assert abs(cost.total - 16290.60) <= 1e-9 + assert abs(cost.contingency_rate - 0.25) <= 1e-9 + + +def test_ashp_bundle_cost_uses_injected_rates() -> None: + # Arrange — the rate table is data (ADR-0025): a Products built with a tweaked + # cylinder rate prices that cylinder, not the committed default. + rates: AshpRates = replace(AshpRates.default(), cylinder=1000.0) + products = Products(rates=rates) + inputs = AshpCostInputs( + existing_system=AshpExistingSystem.ELECTRIC_STORAGE, + is_small_property=True, + design_heat_loss_kw=4.0, + radiator_count=7, + has_reusable_wet_system=False, + ) + + # Act + cost: Cost = products.ashp_bundle_cost(inputs) + + # Assert — decommission 570 + pump 9720 + injected cylinder 1000 + + # distribution 3618 = 14908.0. + assert abs(cost.total - 14908.0) <= 1e-9 + + +def _large_no_reuse(system: AshpExistingSystem) -> AshpCostInputs: + """A large dwelling, 8 kW band, 8 radiators, no reusable wet system — so the + only thing varying with ``system`` is the decommission line.""" + return AshpCostInputs( + existing_system=system, + is_small_property=False, + design_heat_loss_kw=8.0, + radiator_count=8, + has_reusable_wet_system=False, + ) + + +def test_decommission_cost_varies_by_existing_system() -> None: + # Arrange — common: pump (8 kW) 9840 + cylinder 2382.60 + distribution (8 + # rads) 4152 = 16374.60; only decommission differs by system. + products = Products() + common = 16374.60 + + # Act / Assert — gas and oil are flat 720; LPG 960; electric-storage large + # 840 (small 570 is pinned by the tracer above). + assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.GAS)).total - (common + 720.0)) <= 1e-9 + assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.OIL)).total - (common + 720.0)) <= 1e-9 + assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.LPG)).total - (common + 960.0)) <= 1e-9 + assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.ELECTRIC_STORAGE)).total - (common + 840.0)) <= 1e-9 + + +def test_reusable_wet_system_prices_a_flush_plus_half_the_distribution() -> None: + # Arrange — a gas dwelling whose wet system is reusable: instead of a full + # new distribution, the ASHP pays a power-flush plus half the radiator band + # (a documented estimate for partial radiator upsizing — ADR-0025). + products = Products() + inputs = AshpCostInputs( + existing_system=AshpExistingSystem.GAS, + is_small_property=False, + design_heat_loss_kw=8.0, + radiator_count=8, + has_reusable_wet_system=True, + ) + + # Act + cost: Cost = products.ashp_bundle_cost(inputs) + + # Assert — decommission 720 + pump 9840 + cylinder 2382.60 + distribution + # (flush 168 + 0.5 x 4152 = 2244) = 15186.60. + assert abs(cost.total - 15186.60) <= 1e-9 + + +def _small_no_reuse(system: AshpExistingSystem) -> AshpCostInputs: + """A small dwelling, 4 kW band, 7 radiators, no reusable wet system — pump + 9720 + cylinder 2382.60 + distribution (7) 3618 = 15720.60 common base.""" + return AshpCostInputs( + existing_system=system, + is_small_property=True, + design_heat_loss_kw=4.0, + radiator_count=7, + has_reusable_wet_system=False, + ) + + +def test_decommission_falls_back_for_systems_not_on_the_rate_sheet() -> None: + # Arrange — the rate sheet covers gas/oil/LPG/electric-storage, but ASHP is + # offered to any house regardless of fuel (ADR-0025): no system costs nothing + # to remove; electric room/panel heaters use the electric-storage line; any + # other system defaults to the gas line — never a raise (that would wrongly + # block ASHP eligibility). + products = Products() + base = 15720.60 + + # Act / Assert + assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.NONE)).total - (base + 0.0)) <= 1e-9 + assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.ELECTRIC_OTHER)).total - (base + 570.0)) <= 1e-9 + assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.OTHER)).total - (base + 720.0)) <= 1e-9 + + +def _pump_price(products: Products, design_heat_loss_kw: float) -> float: + """Isolate the heat-pump line: no-system (decommission 0) + cylinder + 2382.60 + distribution (7 rads) 3618 = 6000.60 base, so total - base is the + pump band price for ``design_heat_loss_kw``.""" + inputs = AshpCostInputs( + existing_system=AshpExistingSystem.NONE, + is_small_property=True, + design_heat_loss_kw=design_heat_loss_kw, + radiator_count=7, + has_reusable_wet_system=False, + ) + return products.ashp_bundle_cost(inputs).total - 6000.60 + + +def test_heat_pump_rounds_design_heat_loss_up_to_the_next_band() -> None: + # Arrange + products = Products() + + # Act / Assert — bands {5,8,11,15,16+} kW -> {9720,9840,10200,10680,11400}; + # a load is rounded UP to the smallest band that covers it. + assert abs(_pump_price(products, 5.0) - 9720.0) <= 1e-9 # at the 5 kW edge + assert abs(_pump_price(products, 5.01) - 9840.0) <= 1e-9 # just over -> 8 kW + assert abs(_pump_price(products, 8.0) - 9840.0) <= 1e-9 + assert abs(_pump_price(products, 8.01) - 10200.0) <= 1e-9 + assert abs(_pump_price(products, 11.0) - 10200.0) <= 1e-9 + assert abs(_pump_price(products, 15.0) - 10680.0) <= 1e-9 + assert abs(_pump_price(products, 15.01) - 11400.0) <= 1e-9 # above largest + assert abs(_pump_price(products, 25.0) - 11400.0) <= 1e-9 + + +def _full_distribution(products: Products, radiator_count: int) -> float: + """Isolate the full distribution line: no-system (decommission 0) + pump + (4 kW) 9720 + cylinder 2382.60 = 12102.60 base.""" + inputs = AshpCostInputs( + existing_system=AshpExistingSystem.NONE, + is_small_property=True, + design_heat_loss_kw=4.0, + radiator_count=radiator_count, + has_reusable_wet_system=False, + ) + return products.ashp_bundle_cost(inputs).total - 12102.60 + + +def test_radiator_count_is_clamped_to_the_distribution_table_bounds() -> None: + # Arrange — the distribution table only spans 4-12 radiators, so a proxy + # count outside that range is clamped to the nearest band (ADR-0025). + products = Products() + + # Act / Assert — below 4 prices as 4 (2220); above 12 prices as 12 (6288); + # in-range is exact. + assert abs(_full_distribution(products, 2) - 2220.0) <= 1e-9 + assert abs(_full_distribution(products, 4) - 2220.0) <= 1e-9 + assert abs(_full_distribution(products, 9) - 4680.0) <= 1e-9 + assert abs(_full_distribution(products, 12) - 6288.0) <= 1e-9 + assert abs(_full_distribution(products, 15) - 6288.0) <= 1e-9 + + +# --- Boiler / tune-up composite costs (ADR-0027) -------------------------- + + +def test_tune_up_standard_from_no_controls_with_cylinder_fixes() -> None: + # Arrange — a 7-radiator dwelling with no existing controls, an uninsulated + # un-thermostatted cylinder: the standard tune-up fits the full control set + # plus both cylinder fixes. + products = Products() + inputs = TuneUpCostInputs( + is_zoned=False, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + # Act + cost: Cost = products.tune_up_cost(inputs) + + # Assert — programmer 120 + room stat 150 + TRVs 7x35=245 = 515 controls, + # + jacket 50 + cylinder stat 150 = 715, with the 0.10 tune-up contingency. + assert abs(cost.total - 715.0) <= _PIN + assert abs(cost.contingency_rate - 0.10) <= _PIN + + +def test_tune_up_standard_charges_only_the_missing_control_parts() -> None: + # Arrange — the dwelling already has a room thermostat + TRVs (only the + # programmer is missing), and the cylinder is already sorted. + products = Products() + inputs = TuneUpCostInputs( + is_zoned=False, + radiator_count=7, + has_programmer=False, + has_room_thermostat=True, + has_trvs=True, + needs_cylinder_jacket=False, + needs_cylinder_thermostat=False, + ) + + # Act + cost: Cost = products.tune_up_cost(inputs) + + # Assert — only the programmer is charged (incremental, no double-charge). + assert abs(cost.total - 120.0) <= _PIN + + +def test_tune_up_zoned_prices_a_full_smart_kit_no_per_room_sensor() -> None: + # Arrange — a 7-radiator dwelling, zone tune-up, both cylinder fixes. + products = Products() + inputs = TuneUpCostInputs( + is_zoned=True, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + # Act + cost: Cost = products.tune_up_cost(inputs) + + # Assert — hub 205 + smart TRVs 7x50=350 = 555 (no separate sensor line), + # + cylinder 200 = 755, 0.10 contingency. Zone is a full kit regardless of + # the existing parts. + assert abs(cost.total - 755.0) <= _PIN + assert abs(cost.contingency_rate - 0.10) <= _PIN + + +def test_boiler_bundle_cost_controls_already_adequate() -> None: + # Arrange — a like-for-like gas boiler swap whose controls are already + # adequate (no controls upgrade), with both cylinder fixes. + products = Products() + inputs = BoilerCostInputs( + upgrades_controls=False, + radiator_count=7, + has_programmer=True, + has_room_thermostat=True, + has_trvs=True, + needs_cylinder_jacket=True, + needs_cylinder_thermostat=True, + ) + + # Act + cost: Cost = products.boiler_bundle_cost(inputs) + + # Assert — boiler 3200 + cylinder 200 = 3400, with the 0.26 boiler + # contingency. No controls, no system-change extras. + assert abs(cost.total - 3400.0) <= _PIN + assert abs(cost.contingency_rate - 0.26) <= _PIN + + +def test_boiler_bundle_cost_adds_standard_controls_when_upgraded() -> None: + # Arrange — a gas boiler swap that also fixes inadequate controls (from + # nothing) on a 7-radiator dwelling, no cylinder. + products = Products() + inputs = BoilerCostInputs( + upgrades_controls=True, + radiator_count=7, + has_programmer=False, + has_room_thermostat=False, + has_trvs=False, + needs_cylinder_jacket=False, + needs_cylinder_thermostat=False, + ) + + # Act + cost: Cost = products.boiler_bundle_cost(inputs) + + # Assert — boiler 3200 + standard controls 515 = 3715. + assert abs(cost.total - 3715.0) <= _PIN diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py new file mode 100644 index 00000000..63f28ce6 --- /dev/null +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -0,0 +1,101 @@ +"""Behaviour of the roof Recommendation Generator: detecting an uninsulated +loft and emitting a Recommendation whose Measure Option carries the loft- +insulation Simulation Overlay and a priced Cost. Mirrors the wall generator. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, + SapRoomInRoof, +) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.generators.roof_recommendation import recommend_roof_insulation +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=30.0, contingency_rate=0.10 + ) + + +def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart: + return next(p for p in epc.sap_building_parts if p.identifier is identifier) + + +def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Roof" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "loft_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 300 + + +def test_already_insulated_loft_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN roof already 300 mm + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300 + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_room_in_roof_yields_no_recommendation_pending_a_dedicated_branch() -> None: + # Arrange — an uninsulated loft the fallback would otherwise top up, but the + # part is a room-in-roof. The simple loft/sloping overlay can't model RR + # insulation (its sloping/stud/gable surfaces carry their own U-values via + # Table 17/18), so the generator must defer rather than mis-fire loft. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_insulation_thickness = 0 + main.sap_room_in_roof = SapRoomInRoof(floor_area=9.0, construction_age_band="D") + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_loft_option_carries_cost_from_roof_area_and_product() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2 + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 14.85 * 30.0) <= 0.01 + assert abs(cost.contingency_rate - 0.10) <= 1e-9 diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py new file mode 100644 index 00000000..61cfb454 --- /dev/null +++ b/tests/domain/modelling/test_scoring.py @@ -0,0 +1,185 @@ +"""Behaviour of per-measure scoring: the telescoping marginal cascade that +serves both the per-Option optimiser signal (role 1) and the final-package +display attribution (role 3) — ADR-0016. Exercises the real calculator on a +hand-built EPD; no PDF/parser involved. +""" + +from typing import Sequence + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.measure_type import MeasureType +from domain.modelling.scoring.package_scorer import PackageScorer, Score +from domain.modelling.recommendation import MeasureOption +from domain.modelling.scoring.scoring import ( + MeasureImpact, + cascade_scores, + independent_option_impacts, + marginal_impacts, + marginals_from_scores, +) +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from domain.sap10_calculator.calculator import Sap10Calculator +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +_MAIN_CAVITY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) +_EXT1_CAVITY = EpcSimulation( + building_parts={ + BuildingPartIdentifier.EXTENSION_1: BuildingPartOverlay(wall_insulation_type=2) + } +) + + +class _CountingScorer(PackageScorer): + """A PackageScorer stand-in that counts score() calls; the score is a + deterministic function of the overlays so distinct overlays differ.""" + + def __init__(self) -> None: + self.calls = 0 + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + self.calls += 1 + total = 0.0 + for sim in simulations: + for overlay in sim.building_parts.values(): + total += overlay.wall_insulation_type or 0 + return Score( + sap_continuous=total, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0 + ) + + +def _option(overlay: EpcSimulation) -> MeasureOption: + return MeasureOption( + measure_type=MeasureType.CAVITY_WALL_INSULATION, description="opt", overlay=overlay + ) + + +def test_independent_option_impacts_score_each_distinct_overlay_once() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = _CountingScorer() + overlay_a = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } + ) + overlay_a_dup = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } + ) + overlay_b = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3) + } + ) + options = [_option(overlay_a), _option(overlay_a_dup), _option(overlay_b)] + + # Act + impacts: list[MeasureImpact] = independent_option_impacts( + scorer, baseline, options + ) + + # Assert + # baseline scored once + one score per DISTINCT overlay (a, b) = 3, not 4 + assert scorer.calls == 3 + assert impacts[0].sap_points == impacts[1].sap_points == 2.0 + assert impacts[2].sap_points == 3.0 + + +def test_single_overlay_marginal_is_its_improvement_over_baseline() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = PackageScorer(Sap10Calculator()) + base: Score = scorer.score(baseline, []) + filled: Score = scorer.score(baseline, [_MAIN_CAVITY]) + + # Act + impacts: list[MeasureImpact] = marginal_impacts(scorer, baseline, [_MAIN_CAVITY]) + + # Assert + assert len(impacts) == 1 + assert impacts[0].sap_points > 0 # cavity fill improves SAP + assert ( + abs(impacts[0].sap_points - (filled.sap_continuous - base.sap_continuous)) + <= 1e-9 + ) + + +def test_cascade_scores_returns_the_baseline_plus_one_score_per_prefix() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = _CountingScorer() + overlays = [_MAIN_CAVITY, _EXT1_CAVITY] + + # Act + scores: list[Score] = cascade_scores(scorer, baseline, overlays) + + # Assert + # baseline (empty prefix) + one score per cumulative prefix + assert len(scores) == 3 + assert scorer.calls == 3 + assert scores[0].sap_continuous == 0.0 # empty prefix + assert scores[1].sap_continuous == 2.0 # MAIN cavity (type 2) + assert scores[2].sap_continuous == 4.0 # + EXTENSION_1 cavity (type 2) + + +def test_marginals_from_scores_are_the_consecutive_prefix_deltas() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = PackageScorer(Sap10Calculator()) + overlays = [_MAIN_CAVITY, _EXT1_CAVITY] + scores: list[Score] = cascade_scores(scorer, baseline, overlays) + + # Act + impacts: list[MeasureImpact] = marginals_from_scores(scores) + + # Assert — each marginal is the delta over the previous prefix score + assert len(impacts) == 2 + assert ( + abs(impacts[0].sap_points - (scores[1].sap_continuous - scores[0].sap_continuous)) + <= 1e-9 + ) + assert ( + abs(impacts[1].sap_points - (scores[2].sap_continuous - scores[1].sap_continuous)) + <= 1e-9 + ) + + +def test_marginals_telescope_to_the_whole_package_total() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = PackageScorer(Sap10Calculator()) + overlays = [_MAIN_CAVITY, _EXT1_CAVITY] + base: Score = scorer.score(baseline, []) + full: Score = scorer.score(baseline, overlays) + + # Act + impacts: list[MeasureImpact] = marginal_impacts(scorer, baseline, overlays) + + # Assert + assert len(impacts) == 2 + assert ( + abs( + sum(i.sap_points for i in impacts) + - (full.sap_continuous - base.sap_continuous) + ) + <= 1e-9 + ) + assert ( + abs( + sum(i.energy_savings_kwh_per_yr for i in impacts) + - (base.primary_energy_kwh_per_yr - full.primary_energy_kwh_per_yr) + ) + <= 1e-6 + ) diff --git a/tests/domain/modelling/test_secondary_heating_recommendation.py b/tests/domain/modelling/test_secondary_heating_recommendation.py new file mode 100644 index 00000000..13ae6f69 --- /dev/null +++ b/tests/domain/modelling/test_secondary_heating_recommendation.py @@ -0,0 +1,94 @@ +"""Behaviour of the Secondary Heating Removal Recommendation Generator: offering +to strip a dwelling's lodged secondary heating system so the main serves 100% of +space heating (ADR-0028). A standalone, co-selectable Recommendation; eligibility +is purely physical (offer iff a secondary is lodged) — the Optimiser de-selects +the cases where removal cannot move SAP. Detection + pricing only (ADR-0016). +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.secondary_heating_recommendation import ( + recommend_secondary_heating_removal, +) +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import SecondaryHeatingOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed flat per-dwelling + decommission price (ADR-0028).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=250.0, + contingency_rate=0.25, + id=11, + ) + + +def _without_secondary(epc: EpcPropertyData) -> EpcPropertyData: + """Return a copy of `epc` with no secondary heating system lodged.""" + clone: EpcPropertyData = copy.deepcopy(epc) + clone.sap_heating.secondary_heating_type = None + clone.sap_heating.secondary_fuel_type = None + return clone + + +def test_dwelling_with_a_lodged_secondary_yields_a_removal_recommendation() -> None: + # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ + # convector/radiant heaters). + baseline: EpcPropertyData = build_epc() + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert — one Option whose overlay clears the secondary. + assert recommendation is not None + assert recommendation.surface == "Secondary Heating" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "secondary_heating_removal" + assert option.overlay.secondary_heating == SecondaryHeatingOverlay() + + +def test_dwelling_without_a_secondary_yields_no_recommendation() -> None: + # Arrange — nothing lodged to remove (RdSAP only records a secondary when a + # fixed emitter is present; a portable would not be lodged at all). + baseline: EpcPropertyData = _without_secondary(build_epc()) + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_per_dwelling_decommission() -> None: + # Arrange — a lodged secondary; the cost is a flat per-dwelling decommission + # figure (one electrician visit + localised making-good), not room-scaled. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_secondary_heating_removal( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 250.0 + assert cost.contingency_rate == 0.25 + assert recommendation.options[0].material_id == 11 diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py new file mode 100644 index 00000000..d48a7aec --- /dev/null +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -0,0 +1,131 @@ +"""Slice 4 — conservative PV config selection (ADR-0026). + +From Google's full `solarPanelConfigs` ladder, drop north-facing segments +(within 30° of due north), cap usable panels at ~70% of maxArrayPanelsCount +(imagery misses obstructions; MCS wants an edge setback), then sample up to +five configs spanning min→max by energy so the Optimiser gets a genuine +size/cost choice. +""" + +import json +from pathlib import Path +from typing import Any + +from domain.modelling.generators.solar_recommendation import ( + select_conservative_configs, +) +from domain.modelling.solar_potential import ( + SolarPanelConfiguration, + SolarPotential, + SolarRoofSegment, +) + +_FIXTURE: Path = ( + Path(__file__).resolve().parent + / "fixtures" + / "google_building_insights_001431.json" +) + + +def _insights() -> dict[str, Any]: + with _FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +def _segment(panels: int, azimuth: float, energy: float) -> SolarRoofSegment: + return SolarRoofSegment( + segment_index=0, + panels_count=panels, + azimuth_degrees=azimuth, + pitch_degrees=30.0, + yearly_energy_dc_kwh=energy, + ) + + +def test_real_example_samples_five_spanning_configs() -> None: + # Arrange + potential = SolarPotential.from_building_insights(_insights()) + + # Act + configs = select_conservative_configs(potential) + + # Assert — five rungs spanning the conservative range, ascending by size, + # all ≤ 70% of maxArrayPanelsCount (49 → 34.3) + assert [c.panels_count for c in configs] == [4, 12, 19, 26, 34] + assert all(c.panels_count <= 0.70 * potential.max_array_panels_count for c in configs) + + +def test_north_facing_segments_are_dropped() -> None: + # Arrange — a single config with a due-north plane and a south plane + south = _segment(panels=6, azimuth=180.0, energy=2000.0) + north = _segment(panels=4, azimuth=5.0, energy=900.0) + near_north = _segment(panels=2, azimuth=345.0, energy=400.0) # within 30° of N + potential = SolarPotential( + panel_capacity_watts=400.0, + max_array_panels_count=20, + configurations=( + SolarPanelConfiguration( + panels_count=12, + yearly_energy_dc_kwh=3300.0, + segments=(south, north, near_north), + ), + ), + ) + + # Act + configs = select_conservative_configs(potential) + + # Assert — only the south plane survives; counts/energy recomputed to it + assert len(configs) == 1 + only = configs[0] + assert only.panels_count == 6 + assert abs(only.yearly_energy_dc_kwh - 2000.0) <= 1e-4 + assert [s.azimuth_degrees for s in only.segments] == [180.0] + + +def test_cap_excludes_configs_above_seventy_percent() -> None: + # Arrange — max 10 panels → cap 7; a 6-panel and an 8-panel rung + potential = SolarPotential( + panel_capacity_watts=400.0, + max_array_panels_count=10, + configurations=( + SolarPanelConfiguration( + panels_count=6, + yearly_energy_dc_kwh=2000.0, + segments=(_segment(6, 180.0, 2000.0),), + ), + SolarPanelConfiguration( + panels_count=8, + yearly_energy_dc_kwh=2600.0, + segments=(_segment(8, 180.0, 2600.0),), + ), + ), + ) + + # Act + configs = select_conservative_configs(potential) + + # Assert — only the 6-panel rung (≤7) survives + assert [c.panels_count for c in configs] == [6] + + +def test_all_north_or_empty_yields_no_configs() -> None: + # Arrange — every plane faces north + potential = SolarPotential( + panel_capacity_watts=400.0, + max_array_panels_count=20, + configurations=( + SolarPanelConfiguration( + panels_count=4, + yearly_energy_dc_kwh=800.0, + segments=(_segment(4, 10.0, 800.0),), + ), + ), + ) + + # Act + configs = select_conservative_configs(potential) + + # Assert + assert configs == () diff --git a/tests/domain/modelling/test_solar_overshading.py b/tests/domain/modelling/test_solar_overshading.py new file mode 100644 index 00000000..9c97ce15 --- /dev/null +++ b/tests/domain/modelling/test_solar_overshading.py @@ -0,0 +1,89 @@ +"""Slice 3 — generation-calibrated PV overshading (ADR-0026). + +Google's `yearlyEnergyDcKwh` per segment already encodes real orientation, +tilt and shading from imagery. Dividing its AC equivalent by SAP's own +unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and isolates +the effective overshading factor ZPV, which snaps to the RdSAP bucket +{1:1.0, 2:0.8, 3:0.5, 4:0.35}. +""" + +import json +from pathlib import Path +from typing import Any + +from domain.modelling.generators.solar_recommendation import ( + overshading_code_from_zpv, + segment_overshading_code, +) +from domain.modelling.solar_potential import SolarPotential, SolarRoofSegment +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + pv_annual_solar_radiation_kwh_per_m2, +) + +_FIXTURE: Path = ( + Path(__file__).resolve().parent + / "fixtures" + / "google_building_insights_001431.json" +) +_DC_TO_AC_RATE = 0.955 +_SAP_PV_PERFORMANCE_FACTOR = 0.8 + + +def _insights() -> dict[str, Any]: + with _FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +def test_overshading_cutpoints_snap_to_rdsap_buckets() -> None: + # Arrange / Act / Assert — ADR-0026 midpoints: ≥0.90→1, 0.65–0.90→2, + # 0.425–0.65→3, <0.425→4, and ZPV>1 clamps to 1. + assert overshading_code_from_zpv(1.20) == 1 + assert overshading_code_from_zpv(0.90) == 1 + assert overshading_code_from_zpv(0.89) == 2 + assert overshading_code_from_zpv(0.65) == 2 + assert overshading_code_from_zpv(0.64) == 3 + assert overshading_code_from_zpv(0.425) == 3 + assert overshading_code_from_zpv(0.42) == 4 + assert overshading_code_from_zpv(0.10) == 4 + + +def _segment_with_zpv(target_zpv: float) -> SolarRoofSegment: + """A south-facing 30°-tilt 2 kWp segment whose Google generation is set so + its back-solved overshading factor is ``target_zpv``.""" + orientation, pitch_code, panels, capacity = 5, 2, 5, 400.0 # 5 × 400 W = 2 kWp + kwp = panels * capacity / 1000 + s = pv_annual_solar_radiation_kwh_per_m2(orientation, pitch_code) + g_ac = _SAP_PV_PERFORMANCE_FACTOR * kwp * s * target_zpv + yearly_dc = g_ac / _DC_TO_AC_RATE + return SolarRoofSegment( + segment_index=0, + panels_count=panels, + azimuth_degrees=180.0, # S → octant 5 + pitch_degrees=30.0, # → code 2 + yearly_energy_dc_kwh=yearly_dc, + ) + + +def test_segment_overshading_recovers_each_bucket() -> None: + # Arrange / Act / Assert — a segment dialled to each bucket midpoint + capacity = 400.0 + assert segment_overshading_code(_segment_with_zpv(1.0), capacity) == 1 + assert segment_overshading_code(_segment_with_zpv(0.8), capacity) == 2 + assert segment_overshading_code(_segment_with_zpv(0.5), capacity) == 3 + assert segment_overshading_code(_segment_with_zpv(0.35), capacity) == 4 + + +def test_real_example_segments_are_unshaded() -> None: + # Arrange + potential = SolarPotential.from_building_insights(_insights()) + largest = potential.configurations[-1] + + # Act + codes = { + segment_overshading_code(seg, potential.panel_capacity_watts) + for seg in largest.segments + } + + # Assert — a clear London roof: every plane back-solves to ZPV > 1 → code 1 + assert codes == {1} diff --git a/tests/domain/modelling/test_solar_potential.py b/tests/domain/modelling/test_solar_potential.py new file mode 100644 index 00000000..1561242f --- /dev/null +++ b/tests/domain/modelling/test_solar_potential.py @@ -0,0 +1,102 @@ +"""Slice 2 — the typed `SolarPotential` projection over a Google Solar +`buildingInsights` response (ADR-0026). + +Pins the orientation/pitch mappings and the projection against the real +London `buildingInsights` example (mirrored into fixtures from the +user-provided RTF). +""" + +import json +from pathlib import Path +from typing import Any + +from domain.modelling.solar_potential import ( + SolarPotential, + azimuth_to_sap_octant, + pitch_to_sap_code, +) + +_FIXTURE: Path = ( + Path(__file__).resolve().parent + / "fixtures" + / "google_building_insights_001431.json" +) + + +def _insights() -> dict[str, Any]: + with _FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +def test_azimuth_to_sap_octant_cardinals_and_diagonals() -> None: + # Arrange / Act / Assert — Google azimuth 0=N clockwise → SAP octant code + assert azimuth_to_sap_octant(0.0) == 1 # N + assert azimuth_to_sap_octant(45.0) == 2 # NE + assert azimuth_to_sap_octant(90.0) == 3 # E + assert azimuth_to_sap_octant(135.0) == 4 # SE + assert azimuth_to_sap_octant(180.0) == 5 # S + assert azimuth_to_sap_octant(225.0) == 6 # SW + assert azimuth_to_sap_octant(270.0) == 7 # W + assert azimuth_to_sap_octant(315.0) == 8 # NW + assert azimuth_to_sap_octant(360.0) == 1 # wraps to N + + +def test_pitch_to_sap_code_snaps_to_rdsap_enum() -> None: + # Arrange / Act / Assert — RdSAP 10 §11.1 fixed tilts + assert pitch_to_sap_code(0.0) == 1 + assert pitch_to_sap_code(30.0) == 2 + assert pitch_to_sap_code(45.0) == 3 + assert pitch_to_sap_code(60.0) == 4 + assert pitch_to_sap_code(90.0) == 5 + # Real Google pitches (~32-34°) snap to the 30° code + assert pitch_to_sap_code(33.65681) == 2 + assert pitch_to_sap_code(31.896425) == 2 + + +def test_projection_reads_potential_level_fields() -> None: + # Arrange + insights = _insights() + + # Act + potential = SolarPotential.from_building_insights(insights) + + # Assert + assert abs(potential.panel_capacity_watts - 400.0) <= 1e-4 + assert potential.max_array_panels_count == 49 + assert len(potential.configurations) == 46 + + +def test_projection_first_config_single_segment() -> None: + # Arrange + insights = _insights() + + # Act + potential = SolarPotential.from_building_insights(insights) + first = potential.configurations[0] + + # Assert — the smallest rung: 4 panels on one SE roof plane + assert first.panels_count == 4 + assert len(first.segments) == 1 + segment = first.segments[0] + assert segment.segment_index == 1 + assert segment.panels_count == 4 + assert abs(segment.azimuth_degrees - 136.27895) <= 1e-4 + assert abs(segment.yearly_energy_dc_kwh - 1617.0192) <= 1e-4 + assert segment.sap_orientation == 4 # SE + assert segment.sap_pitch_code == 2 # ~32° → 30° + + +def test_projection_largest_config_spans_all_segments() -> None: + # Arrange + insights = _insights() + + # Act + potential = SolarPotential.from_building_insights(insights) + largest = potential.configurations[-1] + + # Assert — the 49-panel rung spans all four roof planes + assert largest.panels_count == 49 + assert sum(s.panels_count for s in largest.segments) == 49 + octants = {s.sap_orientation for s in largest.segments} + assert octants == {8, 4, 2, 6} # NW, SE, NE, SW diff --git a/tests/domain/modelling/test_solar_products.py b/tests/domain/modelling/test_solar_products.py new file mode 100644 index 00000000..08fe808a --- /dev/null +++ b/tests/domain/modelling/test_solar_products.py @@ -0,0 +1,164 @@ +"""Behaviour of `Products.solar_bundle_cost` — the composite, per-dwelling +Solar PV bundle cost (ADR-0026). Pure catalogue math over the Southern Housing +"SOLAR PV & BATTERY" EA-rate column: pv_system(kWp band) + scaffolding + +enabling base + [diverter if cylinder] + [battery if the with-battery variant], +carrying the separate 15% solar contingency. No EpcPropertyData / calculator. + +Pinned against the real rate sheet (delta <= 1e-9), mirroring the cascade-pin +philosophy. The £2,000 battery is a flagged estimate — it is not on the rate +sheet (ADR-0026), confirmed with the user to stand in until a DB rate lands. +""" + +from dataclasses import replace + +from domain.modelling.products import Products, SolarCostInputs, SolarRates +from domain.modelling.recommendation import Cost + +# pv_system EA bands (slate roof, ECOPV06-13) + the fixed adders. +_ENABLING = 150.0 + 50.0 + 330.0 # EICR + DNO + 2-way consumer unit = 530 +_SCAFFOLD_TWO = 900.0 + 450.0 # first elevation + one additional = 1350 + + +def test_solar_bundle_cost_composes_a_small_array_with_cylinder() -> None: + # Arrange — a 1.6 kWp array (4 × 400 W) on a dwelling with a cylinder (so a + # diverter is added), two elevations of scaffolding, no battery. + products = Products() + inputs = SolarCostInputs( + peak_power_kwp=1.6, has_cylinder=True, has_battery=False, elevations=2 + ) + + # Act + cost: Cost = products.solar_bundle_cost(inputs) + + # Assert — pv(1.5 band 2635) + scaffold 1350 + enabling 530 + diverter 980 + # = 5495, with the separate 15% solar contingency. + assert abs(cost.total - (2635.0 + _SCAFFOLD_TWO + _ENABLING + 980.0)) <= 1e-9 + assert abs(cost.total - 5495.0) <= 1e-9 + assert abs(cost.contingency_rate - 0.15) <= 1e-9 + + +def test_solar_bundle_cost_composes_a_large_array_with_battery_no_cylinder() -> None: + # Arrange — a 4.8 kWp array (12 × 400 W) caps at the 4.5 kWp band; no + # cylinder (no diverter), the with-battery variant. + products = Products() + inputs = SolarCostInputs( + peak_power_kwp=4.8, has_cylinder=False, has_battery=True, elevations=2 + ) + + # Act + cost: Cost = products.solar_bundle_cost(inputs) + + # Assert — pv(4.5 band 3690) + scaffold 1350 + enabling 530 + battery 2000 + # = 7570 (no diverter). + assert abs(cost.total - (3690.0 + _SCAFFOLD_TWO + _ENABLING + 2000.0)) <= 1e-9 + assert abs(cost.total - 7570.0) <= 1e-9 + + +def _pv_line(products: Products, peak_power_kwp: float) -> float: + """Isolate the pv_system line: no cylinder, no battery, one elevation + (scaffold 900) + enabling 530 = 1430 base, so total - base is the band + price for ``peak_power_kwp``.""" + inputs = SolarCostInputs( + peak_power_kwp=peak_power_kwp, + has_cylinder=False, + has_battery=False, + elevations=1, + ) + return products.solar_bundle_cost(inputs).total - (900.0 + _ENABLING) + + +def test_pv_system_snaps_to_the_nearest_kwp_band() -> None: + # Arrange + products = Products() + + # Act / Assert — EA bands {1.0:2410, 1.5:2635, 2.0:2890, 2.5:2965, 3.0:3115, + # 3.5:3380, 4.0:3490, 4.5:3690}. Sub-1.0 floors to 1.0; above 4.5 caps to + # 4.5; otherwise the nearest band. + assert abs(_pv_line(products, 0.4) - 2410.0) <= 1e-9 # 1 panel → floor 1.0 + assert abs(_pv_line(products, 1.0) - 2410.0) <= 1e-9 + assert abs(_pv_line(products, 1.6) - 2635.0) <= 1e-9 # 4 panels → 1.5 + assert abs(_pv_line(products, 2.0) - 2890.0) <= 1e-9 + assert abs(_pv_line(products, 4.8) - 3690.0) <= 1e-9 # 12 panels → cap 4.5 + assert abs(_pv_line(products, 7.6) - 3690.0) <= 1e-9 # 19 panels → cap 4.5 + + +def test_scaffolding_scales_with_elevations() -> None: + # Arrange — isolate scaffolding: 1.0 kWp (2410) + enabling 530, no cylinder, + # no battery; total - 2940 is the scaffolding line. + products = Products() + base = 2410.0 + _ENABLING + + def scaffold(elevations: int) -> float: + return ( + products.solar_bundle_cost( + SolarCostInputs( + peak_power_kwp=1.0, + has_cylinder=False, + has_battery=False, + elevations=elevations, + ) + ).total + - base + ) + + # Act / Assert — £900 first elevation + £450 each additional + assert abs(scaffold(1) - 900.0) <= 1e-9 + assert abs(scaffold(2) - 1350.0) <= 1e-9 + assert abs(scaffold(3) - 1800.0) <= 1e-9 + + +def test_diverter_is_priced_only_with_a_cylinder() -> None: + # Arrange — identical arrays differing only in cylinder presence. + products = Products() + with_cylinder = SolarCostInputs( + peak_power_kwp=2.0, has_cylinder=True, has_battery=False, elevations=2 + ) + without_cylinder = replace(with_cylinder, has_cylinder=False) + + # Act / Assert — the cylinder dwelling pays the £980 Myenergi Eddi diverter. + delta = ( + products.solar_bundle_cost(with_cylinder).total + - products.solar_bundle_cost(without_cylinder).total + ) + assert abs(delta - 980.0) <= 1e-9 + + +def test_battery_is_priced_only_for_the_with_battery_variant() -> None: + # Arrange + products = Products() + no_battery = SolarCostInputs( + peak_power_kwp=2.0, has_cylinder=False, has_battery=False, elevations=2 + ) + with_battery = replace(no_battery, has_battery=True) + + # Act / Assert — the £2,000 flagged-estimate battery line. + delta = ( + products.solar_bundle_cost(with_battery).total + - products.solar_bundle_cost(no_battery).total + ) + assert abs(delta - 2000.0) <= 1e-9 + + +def test_solar_bundle_cost_uses_injected_rates() -> None: + # Arrange — rates are data: a tweaked battery rate prices that battery. + rates: SolarRates = replace(SolarRates.default(), battery=1500.0) + products = Products(solar_rates=rates) + inputs = SolarCostInputs( + peak_power_kwp=2.0, has_cylinder=False, has_battery=True, elevations=2 + ) + + # Act + cost: Cost = products.solar_bundle_cost(inputs) + + # Assert — pv(2.0 band 2890) + scaffold 1350 + enabling 530 + battery 1500 = + # 6270. + assert abs(cost.total - 6270.0) <= 1e-9 + + +def test_battery_rate_is_flagged_as_an_estimate() -> None: + # Arrange / Act — the £2,000 battery is not on the rate sheet (ADR-0026). + rates = SolarRates.default() + + # Assert — flagged so it can be swapped from the DB later. + assert rates.battery_estimate is True + assert abs(rates.battery - 2000.0) <= 1e-9 diff --git a/tests/domain/modelling/test_solar_recommendation.py b/tests/domain/modelling/test_solar_recommendation.py new file mode 100644 index 00000000..9d51b0d8 --- /dev/null +++ b/tests/domain/modelling/test_solar_recommendation.py @@ -0,0 +1,238 @@ +"""Behaviour of the Solar PV Recommendation Generator (ADR-0026): one "Solar +PV" Recommendation of competing whole-array Options — up to five +conservatively-sized configs × {no battery, battery} — built from a typed +`SolarPotential`. Detection + pricing only; impact is produced by scoring. +""" + +import json +from dataclasses import replace +from pathlib import Path +from typing import Any, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData, PhotovoltaicArray +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.generators.solar_recommendation import recommend_solar +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.solar_potential import SolarPotential +from repositories.product.product_repository import ProductRepository +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + +_FIXTURE: Path = ( + Path(__file__).resolve().parent + / "fixtures" + / "google_building_insights_001431.json" +) +_SOLAR_MEASURE_TYPE = "solar_pv" +_BATTERY_CAPACITY_KWH = 5.0 + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed solar_pv catalogue row.""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=0.0, + contingency_rate=0.15, + id=909, + ) + + +def _solar_potential() -> SolarPotential: + with _FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return SolarPotential.from_building_insights(data) + + +def _eligible_house() -> EpcPropertyData: + """The solar 001431 before cert: a House with a hot-water cylinder, no + existing PV — solar-eligible.""" + return parse_recommendation_summary("solar_pv_001431_before.pdf") + + +def test_eligible_house_yields_a_solar_pv_recommendation_of_competing_options() -> None: + # Arrange — a house with feasible Google solar potential (5 conservative + # configs) and a cylinder. + baseline = _eligible_house() + + # Act + recommendation: Optional[Recommendation] = recommend_solar( + baseline, _StubProducts(), _solar_potential() + ) + + # Assert — one "Solar PV" Recommendation, 5 configs × {no battery, battery} + # = 10 competing Options, all measure_type solar_pv. + assert recommendation is not None + assert recommendation.surface == "Solar PV" + assert len(recommendation.options) == 10 + assert {o.measure_type for o in recommendation.options} == {_SOLAR_MEASURE_TYPE} + assert all(o.material_id == 909 for o in recommendation.options) + + +def test_each_option_overlay_installs_per_segment_arrays_and_ensures_export() -> None: + # Arrange + baseline = _eligible_house() + + # Act + recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) + + # Assert — every option folds a SolarOverlay: one PhotovoltaicArray per + # config segment, export ensured, diverter set (the dwelling has a cylinder). + assert recommendation is not None + for option in recommendation.options: + overlay = option.overlay.solar + assert overlay is not None + assert overlay.is_dwelling_export_capable is True + assert overlay.pv_diverter_present is True + arrays = overlay.photovoltaic_arrays + assert arrays is not None and len(arrays) >= 1 + assert all(isinstance(a, PhotovoltaicArray) for a in arrays) + assert all(1 <= a.orientation <= 8 for a in arrays) + assert all(1 <= a.pitch <= 5 for a in arrays) + assert all(1 <= a.overshading <= 4 for a in arrays) + + +def test_smallest_config_array_peak_power_matches_panels_times_capacity() -> None: + # Arrange — the smallest conservative config is 4 panels × 400 W = 1.6 kWp + # on one SE plane (≈32° → pitch code 2), back-solved to a heavy-ish bucket. + baseline = _eligible_house() + + # Act + recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) + + # Assert — find the no-battery option whose single array totals 1.6 kWp. + assert recommendation is not None + no_battery_arrays: list[list[PhotovoltaicArray]] = [] + for option in recommendation.options: + overlay = option.overlay.solar + assert overlay is not None + if overlay.pv_batteries is None and overlay.photovoltaic_arrays is not None: + no_battery_arrays.append(overlay.photovoltaic_arrays) + smallest = min( + no_battery_arrays, key=lambda arrays: sum(a.peak_power for a in arrays) + ) + assert len(smallest) == 1 + assert abs(smallest[0].peak_power - 1.6) <= 1e-9 + assert smallest[0].orientation == 4 # SE + assert smallest[0].pitch == 2 # ~32° → 30° + + +def test_battery_variant_adds_a_five_kwh_battery_and_costs_more() -> None: + # Arrange + baseline = _eligible_house() + + # Act + recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) + + # Assert — for the same array size, the battery variant carries a 5 kWh + # battery and a higher cost than its no-battery twin. + assert recommendation is not None + by_size: dict[float, dict[bool, float]] = {} + for option in recommendation.options: + overlay = option.overlay.solar + assert overlay is not None and option.cost is not None + size = round(sum(a.peak_power for a in (overlay.photovoltaic_arrays or [])), 6) + has_battery = overlay.pv_batteries is not None + by_size.setdefault(size, {})[has_battery] = option.cost.total + if has_battery: + assert overlay.pv_batteries is not None + assert ( + abs( + overlay.pv_batteries.pv_battery.battery_capacity + - _BATTERY_CAPACITY_KWH + ) + <= 1e-9 + ) + for size, costs in by_size.items(): + assert costs[True] > costs[False], size + + +def test_combi_dwelling_gets_no_diverter() -> None: + # Arrange — the same house without a cylinder (a combi has nothing to divert + # surplus PV to), so the diverter field is left unset. + baseline = _eligible_house() + baseline.has_hot_water_cylinder = False + + # Act + recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) + + # Assert + assert recommendation is not None + for option in recommendation.options: + assert option.overlay.solar is not None + assert option.overlay.solar.pv_diverter_present is None + + +def test_flat_is_not_eligible() -> None: + # Arrange — a flat needs building-level shared-roof coordination (deferred). + baseline = _eligible_house() + baseline.property_type = "Flat" + + # Act / Assert + assert recommend_solar(baseline, _StubProducts(), _solar_potential()) is None + + +def test_listed_building_blocks_solar() -> None: + # Arrange — a listed building protects the fabric (blocks_internal). + baseline = _eligible_house() + + # Act / Assert + assert ( + recommend_solar( + baseline, + _StubProducts(), + _solar_potential(), + PlanningRestrictions(is_listed=True), + ) + is None + ) + + +def test_conservation_area_does_not_block_solar() -> None: + # Arrange — a conservation area blocks external work generally, but PV is + # offered (installed sympathetically) — same gate as ASHP, not blocks_external. + baseline = _eligible_house() + + # Act + recommendation = recommend_solar( + baseline, + _StubProducts(), + _solar_potential(), + PlanningRestrictions(in_conservation_area=True), + ) + + # Assert + assert recommendation is not None + assert len(recommendation.options) == 10 + + +def test_existing_pv_dwelling_is_not_eligible() -> None: + # Arrange — a dwelling that already has PV (existing-PV top-up is deferred). + baseline = _eligible_house() + baseline.sap_energy_source.photovoltaic_arrays = [ + PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=5, overshading=1) + ] + + # Act / Assert + assert recommend_solar(baseline, _StubProducts(), _solar_potential()) is None + + +def test_no_solar_potential_yields_no_recommendation() -> None: + # Arrange — no Google solar data (or no feasible config) → no recommendation. + baseline = _eligible_house() + + # Act / Assert + assert recommend_solar(baseline, _StubProducts(), None) is None + + +def test_infeasible_potential_yields_no_recommendation() -> None: + # Arrange — a potential whose only config faces due north (dropped → empty). + baseline = _eligible_house() + potential = _solar_potential() + north_only = replace(potential, configurations=()) + + # Act / Assert + assert recommend_solar(baseline, _StubProducts(), north_only) is None diff --git a/tests/domain/modelling/test_valuation.py b/tests/domain/modelling/test_valuation.py new file mode 100644 index 00000000..b471efa3 --- /dev/null +++ b/tests/domain/modelling/test_valuation.py @@ -0,0 +1,84 @@ +"""Valuation Uplift — the percentage-primary financial-uplift model (ADR-0018). + +Band-transition uplift compounded from four broker tables (MoneySupermarket, +Lloyds, Knight Frank, Rightmove); always a percentage, an absolute £ only when a +Property Valuation is supplied, capped so the £ uplift never exceeds 2x cost. +""" + +from __future__ import annotations + +from domain.modelling.valuation import ValuationUplift, estimate_valuation_uplift + + +def test_band_jump_yields_percentage_uplift_without_a_current_value() -> None: + # Arrange / Act — D -> C, no current market value supplied. + uplift: ValuationUplift = estimate_valuation_uplift("D", "C") + + # Assert — D->C sources: MSM 2%, Lloyds 2%, Knight Frank 3%, Rightmove 3% + # → min 2%, max 3%, mean 2.5%. The £ forms stay None with no Property Valuation. + assert abs(uplift.lower_pct - 0.02) <= 1e-9 + assert abs(uplift.upper_pct - 0.03) <= 1e-9 + assert abs(uplift.average_pct - 0.025) <= 1e-9 + assert uplift.lower_value is None + assert uplift.upper_value is None + assert uplift.average_value is None + assert uplift.post_retrofit_value is None + + +def test_a_current_value_yields_absolute_pound_uplift() -> None: + # Arrange / Act — D -> C with a £200k market value, no cost cap. + uplift: ValuationUplift = estimate_valuation_uplift( + "D", "C", current_value=200_000.0 + ) + + # Assert — £ increase at each source % plus the resulting post-retrofit value. + assert uplift.lower_value is not None and abs(uplift.lower_value - 4_000.0) <= 1e-6 + assert uplift.upper_value is not None and abs(uplift.upper_value - 6_000.0) <= 1e-6 + assert ( + uplift.average_value is not None + and abs(uplift.average_value - 5_000.0) <= 1e-6 + ) + assert ( + uplift.post_retrofit_value is not None + and abs(uplift.post_retrofit_value - 205_000.0) <= 1e-6 + ) + + +def test_roi_cap_rescales_the_uplift_to_twice_the_cost() -> None: + # Arrange / Act — a £1m property whose raw 2.5% average (£25k) exceeds twice + # a £10k retrofit, so the uplift is capped at 2x cost. + uplift: ValuationUplift = estimate_valuation_uplift( + "D", "C", current_value=1_000_000.0, total_cost=10_000.0 + ) + + # Assert — average £ uplift binds to 2x cost, and the bounds rescale by the + # same 0.8 scalar (0.025 -> 0.02). + assert ( + uplift.average_value is not None + and abs(uplift.average_value - 20_000.0) <= 1e-6 + ) + assert abs(uplift.average_pct - 0.02) <= 1e-9 + assert abs(uplift.lower_pct - 0.016) <= 1e-9 + assert abs(uplift.upper_pct - 0.024) <= 1e-9 + + +def test_multi_band_jump_compounds_steps_and_takes_a_spot_source() -> None: + # Arrange / Act — F -> C: MSM/Lloyds compound three steps; Rightmove gives a + # 15% whole-jump spot; Knight Frank has no F->C entry. + uplift: ValuationUplift = estimate_valuation_uplift("F", "C") + + # Assert — min = compounded MSM (~4.05%), max = Rightmove 15%, mean of the + # three covering sources. + assert abs(uplift.lower_pct - 0.040502) <= 1e-6 + assert abs(uplift.upper_pct - 0.15) <= 1e-9 + assert abs(uplift.average_pct - (0.040502 + 0.07476992 + 0.15) / 3) <= 1e-6 + + +def test_no_improvement_yields_zero_uplift() -> None: + # Arrange / Act — same band in and out. + uplift: ValuationUplift = estimate_valuation_uplift("C", "C") + + # Assert — every source compounds over no steps / matches no spot. + assert abs(uplift.lower_pct) <= 1e-12 + assert abs(uplift.upper_pct) <= 1e-12 + assert abs(uplift.average_pct) <= 1e-12 diff --git a/tests/domain/modelling/test_ventilation_recommendation.py b/tests/domain/modelling/test_ventilation_recommendation.py new file mode 100644 index 00000000..e2add92c --- /dev/null +++ b/tests/domain/modelling/test_ventilation_recommendation.py @@ -0,0 +1,96 @@ +"""Behaviour of the ventilation Recommendation Generator: detecting a dwelling +that lacks adequate mechanical ventilation and emitting a priced MEV +Recommendation. Like wall/roof/floor it does detection + pricing; unlike them it +is consumed by the Measure Dependency path, not the free candidate pool (it is a +forced dependency of fabric insulation — ADR-0016). See CONTEXT.md. +""" + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.ventilation_recommendation import ( + recommend_ventilation, +) +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import VentilationOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed per-unit ventilation cost.""" + + def get(self, measure_type: str) -> Product: + # unit_cost_per_m2 carries the catalogue row's fully-loaded total cost; + # for ventilation that total is per installed unit. + return Product( + measure_type=measure_type, unit_cost_per_m2=450.0, contingency_rate=0.26 + ) + + +def test_naturally_ventilated_dwelling_yields_a_mev_recommendation() -> None: + # Arrange — 000490 lodges no mechanical ventilation kind. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert — one MEV Option targeting the whole-dwelling ventilation system. + assert recommendation is not None + assert recommendation.surface == "Ventilation" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "mechanical_ventilation" + assert option.overlay.ventilation == VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) + + +def test_recommendation_prices_two_installed_units_with_contingency() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert — two MEV units at £450 each, carrying the product's contingency. + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 900.0) <= 1e-9 + assert abs(cost.contingency_rate - 0.26) <= 1e-9 + + +def test_already_mechanically_ventilated_yields_no_recommendation() -> None: + # Arrange — a dwelling that already lodges a mechanical ventilation kind + # must not be told to install MEV (legacy has_ventilation guard). + baseline: EpcPropertyData = build_epc() + assert baseline.sap_ventilation is not None + baseline.sap_ventilation.mechanical_ventilation_kind = "EXTRACT_OR_PIV_OUTSIDE" + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_dwelling_with_no_sap_ventilation_yields_a_recommendation() -> None: + # Arrange — no SapVentilation at all counts as not mechanically ventilated. + baseline: EpcPropertyData = build_epc() + baseline.sap_ventilation = None + + # Act + recommendation: Recommendation | None = recommend_ventilation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py new file mode 100644 index 00000000..b47d9eb1 --- /dev/null +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -0,0 +1,108 @@ +"""Behaviour of the wall Recommendation Generator: detecting a treatable +wall and emitting a Recommendation whose Measure Option carries the +Simulation Overlay for the intervention. See CONTEXT.md / ADR-0016. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, +) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.generators.wall_recommendation import recommend_cavity_wall +from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed cavity Product.""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=18.5, contingency_rate=0.10 + ) + + +def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart: + return next(p for p in epc.sap_building_parts if p.identifier is identifier) + + +def test_uninsulated_main_cavity_wall_yields_a_cavity_fill_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN: cavity (4), uninsulated (4) + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Main wall" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "cavity_wall_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert _part(simulated, BuildingPartIdentifier.MAIN).wall_insulation_type == 2 + + +def test_already_insulated_main_wall_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type = 2 # filled + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) + + # Assert + assert recommendation is None + + +def test_cavity_option_carries_fully_loaded_cost_from_area_and_product() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN gross heat-loss area 45.93 m^2 + products = _StubProducts() # cavity 18.5 GBP/m^2, contingency 0.10 + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline, products) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 45.93 * 18.5) <= 0.01 + assert abs(cost.contingency_rate - 0.10) <= 1e-9 + + +def test_non_cavity_main_wall_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.wall_construction = 2 # solid (not cavity); still uninsulated + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) + + # Assert + assert recommendation is None + + +def test_park_home_wall_yields_no_solid_wall_recommendation() -> None: + # Arrange — a park home (wall_construction code 8) with an uninsulated + # as-built wall. Code 8 is NOT system-built (ADR-0019); a park home's + # proprietary panel is never EWI/IWI-suitable, so the generator excludes it. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.wall_construction = 8 + main.wall_insulation_type = 4 # as-built / uninsulated — the trigger + + # Act + recommendation: Recommendation | None = recommend_solid_wall( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None diff --git a/tests/domain/modelling/test_window_extraction_001431.py b/tests/domain/modelling/test_window_extraction_001431.py new file mode 100644 index 00000000..b7a81c19 --- /dev/null +++ b/tests/domain/modelling/test_window_extraction_001431.py @@ -0,0 +1,52 @@ +"""Window-extraction completeness pin for cert 001431. + +The Modelling glazing overlay's draught-proofing recompute (RdSAP 10 §8.1 — a +count over openable windows + doors) needs every openable §11 window captured +with its `draught_proofed` flag. The Elmhurst Summary §11 block lodges 17 +openable windows; two extraction gaps previously surfaced only 14: + + 1. The extractor rejected the one "Double glazing, known data" row whose + data-source cell is "BFRC data" (laid out as its own line, with no frame + factor) — it does not fit the ` ` Manufacturer-line shape. + 2. The mapper's `_is_elmhurst_roof_window` reclassified the two "Double pre + 2002" rows (U 3.1 / 3.4 > 3.0) as roof windows, even though both are + lodged on an "External wall" — a false positive of the U-value backstop. + +With both closed, all 17 windows are `sap_windows` (none mis-routed to +`sap_roof_windows`), and 14 carry `draught_proofed=True` — reconstructing +Elmhurst's lodged 84% draught-proofing (16/19 = (14 windows + 2 doors) / +(17 windows + 2 doors)). +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +def test_all_17_openable_windows_captured_on_001431() -> None: + # Arrange / Act + epc: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_before.pdf" + ) + + # Assert — every openable §11 window is captured as a vertical window; + # none of the wall-lodged rows leak into the roof-window list. + assert len(epc.sap_windows) == 17 + assert not epc.sap_roof_windows # None or empty — no wall window misrouted + + +def test_draughtproofing_count_reconstructs_lodged_84_percent() -> None: + # Arrange / Act + epc: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_before.pdf" + ) + + # Assert — 14 of the 17 openable windows are draught-proofed, the numerator + # behind Elmhurst's lodged 84% (with the 2 lodged draught-proofed doors). + draughtproofed: int = sum( + 1 for window in epc.sap_windows if window.draught_proofed + ) + assert draughtproofed == 14 diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py index e77ee6da..000d28ef 100644 --- a/tests/domain/property_baseline/test_calculator_rebaseliner.py +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -72,39 +72,45 @@ class _StubCalculator(SapCalculator): def test_pre_10_2_cert_is_rebaselined_to_the_calculator_output() -> None: # Arrange — a SAP 10.0 cert: lodged figures are a superseded methodology, so # the calculator's output becomes Effective Performance (ADR-0013 amendment). - calculator = _StubCalculator( - _sap_result(sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4) + sap_result = _sap_result( + sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4 ) + calculator = _StubCalculator(sap_result) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.0) # Act - effective, reason = rebaseliner.rebaseline( + result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged() ) - # Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded. - assert effective == Performance( + # Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded; + # the SapResult rides on the result for Bill Derivation. + assert result.effective == Performance( sap_score=70, epc_band=Epc.C, co2_emissions=1.9, primary_energy_intensity=185 ) - assert reason == "pre_sap10" + assert result.reason == "pre_sap10" + assert result.sap_result is sap_result def test_a_10_2_cert_keeps_the_lodged_figures() -> None: # Arrange — a SAP 10.2 cert: the API's lodged figures are on-target, so they # stand; the calculator runs only to validate. - calculator = _StubCalculator(_sap_result(sap_score=72)) + sap_result = _sap_result(sap_score=72) + calculator = _StubCalculator(sap_result) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.2) # Act - effective, reason = rebaseliner.rebaseline( + result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged() ) - # Assert - assert effective == _lodged() - assert reason == "none" + # Assert — lodged kept as effective, but the SapResult still rides along for + # Bill Derivation (the bill prices it regardless of which figures win). + assert result.effective == _lodged() + assert result.reason == "none" + assert result.sap_result is sap_result def test_a_10_2_cert_logs_divergence_when_the_calculator_disagrees( diff --git a/tests/domain/property_baseline/test_rebaseliner.py b/tests/domain/property_baseline/test_rebaseliner.py index f760dbf0..b4d671a7 100644 --- a/tests/domain/property_baseline/test_rebaseliner.py +++ b/tests/domain/property_baseline/test_rebaseliner.py @@ -29,16 +29,18 @@ def test_sap10_epc_is_not_rebaselined_so_effective_equals_lodged() -> None: rebaseliner = StubRebaseliner() # Act - effective, reason = rebaseliner.rebaseline(10, epc, lodged) + result = rebaseliner.rebaseline(10, epc, lodged) - # Assert — Effective Performance equals Lodged, reason "none". - assert effective == lodged - assert reason == "none" + # Assert — Effective Performance equals Lodged, reason "none", no SapResult + # (the stub runs no calculator). + assert result.effective == lodged + assert result.reason == "none" + assert result.sap_result is None def test_pre_sap10_epc_raises_because_rebaselining_is_not_implemented() -> None: - # Arrange — a cert lodged under a pre-SAP10 schema genuinely needs ML - # rebaselining, which does not exist yet; the stub must not fabricate a + # Arrange — a cert lodged under a pre-SAP10 schema genuinely needs + # rebaselining, which this stub does not do; it must not fabricate a # "none" answer for it. epc = _epc(sap_version=9.94) rebaseliner = StubRebaseliner() diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py index 531381b9..bb0998c5 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py @@ -19,6 +19,7 @@ Distinct features vs prior fixtures: first 5.27) — the upper storey is smaller than the ground """ +import copy from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -170,7 +171,10 @@ def build_epc() -> EpcPropertyData: heated_rooms_count=3, door_count=2, low_energy_fixed_lighting_bulbs_count=8, - sap_windows=list(SECTION_6_VERTICAL_WINDOWS), + # Deep-copy so each build_epc() owns its windows (the module-level + # SECTION_6_VERTICAL_WINDOWS tuple holds shared SapWindow objects; a + # caller mutating a returned window would otherwise leak across calls). + sap_windows=[copy.deepcopy(window) for window in SECTION_6_VERTICAL_WINDOWS], percent_draughtproofed=78, extensions_count=2, blocked_chimneys_count=0, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py index f87730d9..e1f63d0c 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py @@ -15,6 +15,7 @@ Distinct features vs prior fixtures: flat ceiling — just stud walls (1.5/1.3 height) + slopes """ +import copy from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -139,7 +140,10 @@ def build_epc() -> EpcPropertyData: door_count=2, percent_draughtproofed=100, low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL, - sap_windows=list(SECTION_6_VERTICAL_WINDOWS), + # Deep-copy so each build_epc() owns its windows (the module-level + # SECTION_6_VERTICAL_WINDOWS tuple holds shared SapWindow objects; a + # caller mutating a returned window would otherwise leak across calls). + sap_windows=[copy.deepcopy(window) for window in SECTION_6_VERTICAL_WINDOWS], blocked_chimneys_count=0, dwelling_type="Mid-Terrace house", built_form="Mid-Terrace", diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py index 490091da..af392b42 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py @@ -16,6 +16,7 @@ Differs from 000487 along several useful axes: - No alternative wall (vs 1 timber-frame alt wall on the extension) """ +import copy from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -184,7 +185,10 @@ def build_epc() -> EpcPropertyData: door_count=2, # cert lodges 2 doors total percent_draughtproofed=100, low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL, - sap_windows=list(SECTION_6_VERTICAL_WINDOWS), + # Deep-copy so each build_epc() owns its windows (the module-level + # SECTION_6_VERTICAL_WINDOWS tuple holds shared SapWindow objects; a + # caller mutating a returned window would otherwise leak across calls). + sap_windows=[copy.deepcopy(window) for window in SECTION_6_VERTICAL_WINDOWS], extensions_count=1, blocked_chimneys_count=0, dwelling_type="Mid-Terrace house", diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py index 4c82aa7f..104fa0cb 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py @@ -13,6 +13,7 @@ values captured below. Treat the LINE_X constants as authoritative; if they diverge from our code, the bug is on our side. """ +import copy from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -194,7 +195,10 @@ def build_epc() -> EpcPropertyData: door_count=1, percent_draughtproofed=100, low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL, - sap_windows=list(SECTION_6_VERTICAL_WINDOWS), + # Deep-copy so each build_epc() owns its windows (the module-level + # SECTION_6_VERTICAL_WINDOWS tuple holds shared SapWindow objects; a + # caller mutating a returned window would otherwise leak across calls). + sap_windows=[copy.deepcopy(window) for window in SECTION_6_VERTICAL_WINDOWS], extensions_count=1, blocked_chimneys_count=0, dwelling_type="Enclosed Mid-Terrace house", diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py index a9e26137..9bf66262 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py @@ -21,6 +21,7 @@ Distinct features vs prior fixtures: - DP = 100%, so (15) = 0.05 (lowest window-infiltration component) """ +import copy from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -150,7 +151,11 @@ def build_epc() -> EpcPropertyData: heated_rooms_count=4, door_count=2, low_energy_fixed_lighting_bulbs_count=8, - sap_windows=list(SECTION_6_VERTICAL_WINDOWS), + # Deep-copy so each build_epc() owns its windows: SECTION_6_VERTICAL_ + # WINDOWS is a module-level tuple of shared SapWindow objects, and a + # caller that mutates a returned window (e.g. flipping glazing_type to + # test a glazing measure) would otherwise leak into every later call. + sap_windows=[copy.deepcopy(window) for window in SECTION_6_VERTICAL_WINDOWS], percent_draughtproofed=100, extensions_count=1, blocked_chimneys_count=0, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py index 2745b20a..d161dd9b 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py @@ -21,6 +21,7 @@ Distinct features vs prior fixtures: as 000480 but on a single-part dwelling """ +import copy from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -177,7 +178,10 @@ def build_epc() -> EpcPropertyData: ], percent_draughtproofed=75, low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL, - sap_windows=list(SECTION_6_VERTICAL_WINDOWS), + # Deep-copy so each build_epc() owns its windows (the module-level + # SECTION_6_VERTICAL_WINDOWS tuple holds shared SapWindow objects; a + # caller mutating a returned window would otherwise leak across calls). + sap_windows=[copy.deepcopy(window) for window in SECTION_6_VERTICAL_WINDOWS], blocked_chimneys_count=0, dwelling_type="Mid-Terrace house", built_form="Mid-Terrace", diff --git a/tests/domain/test_building_geometry.py b/tests/domain/test_building_geometry.py new file mode 100644 index 00000000..403d79bd --- /dev/null +++ b/tests/domain/test_building_geometry.py @@ -0,0 +1,50 @@ +"""Behaviour of shared building geometry derived from EpcPropertyData — +reusable outside the SAP calculator (e.g. for Modelling cost quantities).""" + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from domain.building_geometry import ( + gross_heat_loss_wall_area, + ground_floor_area, + roof_area, +) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +def test_gross_heat_loss_wall_area_sums_perimeter_times_height_per_storey() -> None: + # Arrange + # 000490 MAIN: floor 0 (perimeter 7.42 m x height 2.95 m) + floor 1 + # (7.42 m x 3.24 m) = 21.889 + 24.0408 = 45.93 m^2. Party walls are + # excluded by construction (heat-loss perimeter, not total perimeter). + epc = build_epc() + + # Act + area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN) + + # Assert + assert abs(area - 45.93) <= 0.01 + + +def test_roof_area_is_the_parts_greatest_floor_area() -> None: + # Arrange + # RdSAP10 §3.8: roof area is the greatest of the floor areas on each + # level. 000490 MAIN has two floors of 14.85 m^2, so the roof is 14.85. + epc = build_epc() + + # Act + area: float = roof_area(epc, BuildingPartIdentifier.MAIN) + + # Assert + assert abs(area - 14.85) <= 0.01 + + +def test_ground_floor_area_is_the_lowest_floors_area() -> None: + # Arrange — 000490 MAIN floor 0 total area is 14.85 m^2 + epc = build_epc() + + # Act + area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN) + + # Assert + assert abs(area - 14.85) <= 0.01 diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/harness/test_cohort.py b/tests/harness/test_cohort.py new file mode 100644 index 00000000..beac2505 --- /dev/null +++ b/tests/harness/test_cohort.py @@ -0,0 +1,50 @@ +"""Run a directory of API-shaped EPC JSONs through Modelling, offline.""" + +from __future__ import annotations + +from pathlib import Path + +from harness.cohort import ( + CertResult, + format_cohort_csv, + format_cohort_summary, + run_cohort, +) + +_GOLDEN = ( + Path(__file__).resolve().parents[1] + / "domain/sap10_calculator/rdsap/fixtures/golden" +) + + +def test_run_cohort_models_each_api_json_offline() -> None: + # Arrange — two real API-shaped EPC certs (identical to the EPC response). + paths: list[Path] = sorted(_GOLDEN.glob("*.json"))[:2] + assert len(paths) == 2 + + # Act — no database, no network. + results: list[CertResult] = run_cohort(paths, goal_band="C") + + # Assert — one result per cert, each either modelled or carrying its error. + assert len(results) == 2 + for result in results: + assert result.name + assert result.error is not None or result.measures >= 0 + # The summary renders without raising and counts the cohort. + summary: str = format_cohort_summary(results) + assert "2" in summary + + +def test_cohort_carries_each_plan_and_renders_a_csv() -> None: + # Arrange / Act + paths: list[Path] = sorted(_GOLDEN.glob("*.json"))[:3] + results: list[CertResult] = run_cohort(paths) + + # Assert — each cert either modelled (carries its Plan) or errored. + for result in results: + assert (result.plan is not None) != (result.error is not None) + # CSV: a header row plus one row per cert, browsable in a spreadsheet. + csv: str = format_cohort_csv(results) + lines: list[str] = csv.splitlines() + assert lines[0].startswith("cert,") + assert len(lines) == len(results) + 1 diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py new file mode 100644 index 00000000..5712a62c --- /dev/null +++ b/tests/harness/test_console.py @@ -0,0 +1,251 @@ +"""The one-property console entrypoint for interactive sense-checking.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from datatypes.epc.domain.epc import Epc +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.contingencies import contingency_rate +from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall +from harness.console import DEFAULT_CATALOGUE, run_modelling, run_one +from repositories.product.product_json_repository import ProductJsonRepository +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc as _build_uninsulated_cavity_and_floor_epc, +) + +# Every Measure Type the fabric generators can emit; the harness catalogue +# must price all of them or an offline run raises mid-pipeline. +_GENERATOR_MEASURE_TYPES = ( + "cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "sloping_ceiling_insulation", + "flat_roof_insulation", + "suspended_floor_insulation", + "solid_floor_insulation", + "mechanical_ventilation", + "double_glazing", + "secondary_glazing", + "low_energy_lighting", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "gas_boiler_upgrade", + "system_tune_up", + "system_tune_up_zoned", + "secondary_heating_removal", +) + + +def _uninsulated_lodged_epc() -> EpcPropertyData: + epc = _build_uninsulated_cavity_and_floor_epc() + return dataclasses.replace( + epc, + energy_rating_current=57, + current_energy_efficiency_band=Epc.D, + co2_emissions_current=3.0, + energy_consumption_current=300, + ) + + +def test_run_one_returns_a_plan_and_prints_the_table( + capsys: pytest.CaptureFixture[str], +) -> None: + # Arrange + epc: EpcPropertyData = _uninsulated_lodged_epc() + + # Act — run one property end-to-end with no database, against the default + # sample catalogue. + plan = run_one(epc, goal_band="C") + + # Assert — a multi-measure Plan came back, and its sense-check table printed. + assert len(plan.measures) >= 1 + printed: str = capsys.readouterr().out + assert "Plan SAP" in printed + assert "cavity_wall_insulation" in printed + + +def test_run_modelling_inspects_a_plan_without_baseline_or_lodged_performance() -> None: + # Arrange — the RAW 000490 fixture, with NO lodged recorded-performance, so + # the Baseline stage could not run on it. Modelling re-scores the EPC itself. + epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc() + + # Act — Modelling only, no Ingestion / Baseline, no database. + plan = run_modelling(epc, goal_band="C", print_table=False) + + # Assert — a multi-measure Plan came straight out of Modelling. + assert len(plan.measures) >= 1 + + +def test_run_modelling_recommends_solid_wall_insulation_for_solid_brick() -> None: + # Arrange — an uninsulated solid-brick dwelling (cert 001431 before), + # which has no cavity to fill, so any wall measure must be solid-wall. + epc: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Assert — the solid-wall generator is wired in and OFFERS a solid-wall + # Option for this dwelling. Whether the Optimiser then selects it depends on + # the package: the efficient ASHP bundle (ADR-0025) now often reaches the + # band first, so we assert the Option is OFFERED (the wiring/eligibility + # intent) rather than selected. + recommendation = recommend_solid_wall(epc, ProductJsonRepository(DEFAULT_CATALOGUE)) + assert recommendation is not None + assert {o.measure_type for o in recommendation.options} & { + "external_wall_insulation", + "internal_wall_insulation", + } + + # And Modelling still produces a plan end to end (now ASHP-led). + plan = run_modelling(epc, goal_band="C", print_table=False) + assert len(plan.measures) >= 1 + + +def test_run_modelling_listed_building_yields_no_wall_insulation() -> None: + # Arrange — the same uninsulated solid-brick dwelling that gets IWI when + # unrestricted; listing it protects the fabric, blocking both EWI and IWI. + epc: EpcPropertyData = parse_recommendation_summary( + "solid_brick_ewi_001431_before.pdf" + ) + + # Act — thread a listed-building restriction through to the generator. + plan = run_modelling( + epc, + goal_band="C", + print_table=False, + planning_restrictions=PlanningRestrictions(is_listed=True), + ) + + # Assert — no wall-insulation measure survives the restriction. + measure_types = {measure.measure_type for measure in plan.measures} + assert not ( + measure_types & {"external_wall_insulation", "internal_wall_insulation"} + ) + + +def _single_glazed_epc() -> EpcPropertyData: + """The cavity/floor dwelling with all windows single-glazed — the glazing + generator's trigger, sized so the upgrade reaches the optimised package.""" + epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc() + for window in epc.sap_windows: + window.glazing_type = 1 # SAP10.2 Table U2 code 1 = single. + return epc + + +def test_run_modelling_recommends_double_glazing_for_single_glazed_windows() -> None: + # Arrange — a single-glazed dwelling; the glazing generator is wired into + # the candidate pool. + epc: EpcPropertyData = _single_glazed_epc() + + # Act — Modelling only, no database, unrestricted. + plan = run_modelling(epc, goal_band="C", print_table=False) + + # Assert — double glazing reaches the optimised package. + measure_types = {measure.measure_type for measure in plan.measures} + assert "double_glazing" in measure_types + + +def test_run_modelling_protected_dwelling_yields_secondary_glazing() -> None: + # Arrange — the same single-glazed dwelling, listed (blocks external work). + epc: EpcPropertyData = _single_glazed_epc() + + # Act — thread a listed-building restriction through to the generator. + plan = run_modelling( + epc, + goal_band="C", + print_table=False, + planning_restrictions=PlanningRestrictions(is_listed=True), + ) + + # Assert — the picked glazing Measure is secondary, never double. + measure_types = {measure.measure_type for measure in plan.measures} + assert "secondary_glazing" in measure_types + + +def _incandescent_lit_epc() -> EpcPropertyData: + """The cavity/floor dwelling lit entirely by incandescent bulbs — the + lighting generator's trigger, sized so the LED upgrade reaches the package.""" + epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc() + epc.led_fixed_lighting_bulbs_count = 0 + epc.cfl_fixed_lighting_bulbs_count = 0 + epc.incandescent_fixed_lighting_bulbs_count = 10 + epc.low_energy_fixed_lighting_bulbs_count = 0 + return epc + + +def test_run_modelling_recommends_low_energy_lighting_for_non_led_bulbs() -> None: + # Arrange — a dwelling lit by incandescent bulbs; the lighting generator is + # wired into the candidate pool. + epc: EpcPropertyData = _incandescent_lit_epc() + + # Act — Modelling only, no database. + plan = run_modelling(epc, goal_band="C", print_table=False) + + # Assert — the LED upgrade reaches the optimised package. + measure_types = {measure.measure_type for measure in plan.measures} + assert "low_energy_lighting" in measure_types + assert "double_glazing" not in measure_types + + +def _electric_storage_lit_epc() -> EpcPropertyData: + epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc() + main = epc.sap_heating.main_heating_details[0] + main.main_fuel_type = 30 + main.sap_main_heating_code = 402 + main.main_heating_control = 2401 + return epc + + +def test_run_modelling_recommends_hhr_storage_for_an_electric_dwelling() -> None: + # Arrange — an electrically-heated dwelling on old storage heaters; the + # heating generator is wired into the candidate pool (ADR-0024). + epc: EpcPropertyData = _electric_storage_lit_epc() + + # Assert — the heating generator is wired in and OFFERS the HHR storage + # bundle for an electric dwelling. The Optimiser now selects the ASHP bundle + # instead — the efficient Vaillant (ADR-0025) beats resistance storage on SAP + # — so HHR is offered-but-not-selected; assert it is OFFERED to preserve the + # wiring check, and that the optimised package leads with ASHP. + recommendation = recommend_heating(epc, ProductJsonRepository(DEFAULT_CATALOGUE)) + assert recommendation is not None + assert "high_heat_retention_storage_heaters" in { + o.measure_type for o in recommendation.options + } + + plan = run_modelling(epc, goal_band="C", print_table=False) + assert "air_source_heat_pump" in {m.measure_type for m in plan.measures} + + +def test_sample_catalogue_prices_every_generator_measure_type() -> None: + # Arrange — the default offline catalogue. + products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE) + + # Act / Assert — get() and contingency_rate() each raise on a missing + # Measure Type, so an offline run over arbitrary EPCs never dies on a + # missing catalogue or contingency entry. + for measure_type in _GENERATOR_MEASURE_TYPES: + products.get(measure_type) + contingency_rate(measure_type) + + +def test_run_one_threads_a_current_market_value_onto_the_plan() -> None: + # Arrange + epc: EpcPropertyData = _uninsulated_lodged_epc() + + # Act — supply a Property Valuation so the Plan can value the uplift. + plan = run_one( + epc, goal_band="C", current_market_value=250_000.0, print_table=False + ) + + # Assert — the value reached the Plan, which derives its Valuation Uplift + # from it (the £ amount is 0 here as 000490 stays within band D). + assert plan.current_market_value == 250_000.0 + assert plan.valuation.average_value is not None diff --git a/tests/harness/test_epc_bulk.py b/tests/harness/test_epc_bulk.py new file mode 100644 index 00000000..f4d71cb6 --- /dev/null +++ b/tests/harness/test_epc_bulk.py @@ -0,0 +1,43 @@ +"""Parse records from the gov EPC bulk export (NDJSON, stringified `document`).""" + +from __future__ import annotations + +import json + +from harness.epc_bulk import is_sap_version, parse_bulk_line + + +def test_parse_bulk_line_unwraps_the_stringified_document() -> None: + # Arrange — a bulk record wraps the per-cert payload in a `document` string. + inner: dict[str, object] = { + "schema_type": "RdSAP-Schema-21.0.1", + "sap_version": 10.2, + "energy_rating_current": 71, + } + line: str = json.dumps( + {"certificate_number": "0000-1111-2222-3333-4444", "document": json.dumps(inner)} + ) + + # Act + parsed = parse_bulk_line(line) + + # Assert — the cert number and the parsed inner document come back. + assert parsed is not None + cert_number, document = parsed + assert cert_number == "0000-1111-2222-3333-4444" + assert document["schema_type"] == "RdSAP-Schema-21.0.1" + assert document["energy_rating_current"] == 71 + + +def test_parse_bulk_line_ignores_blank_lines() -> None: + # Arrange / Act / Assert — trailing/blank NDJSON lines are skipped. + assert parse_bulk_line("") is None + assert parse_bulk_line(" \n") is None + + +def test_is_sap_version_matches_regardless_of_numeric_or_string_form() -> None: + # Arrange / Act / Assert — the export carries sap_version as a number. + assert is_sap_version({"sap_version": 10.2}, "10.2") is True + assert is_sap_version({"sap_version": "10.2"}, "10.2") is True + assert is_sap_version({"sap_version": 10.1}, "10.2") is False + assert is_sap_version({}, "10.2") is False diff --git a/tests/harness/test_plan_table.py b/tests/harness/test_plan_table.py new file mode 100644 index 00000000..51031151 --- /dev/null +++ b/tests/harness/test_plan_table.py @@ -0,0 +1,91 @@ +"""The sense-check table the DB-less harness prints for a Plan.""" + +from __future__ import annotations + +from domain.modelling.measure_type import MeasureType +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.scoring.scoring import MeasureImpact +from harness.plan_table import format_plan_table + + +def _plan() -> Plan: + baseline = Score( + sap_continuous=57.4, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=300.0 + ) + post = Score( + sap_continuous=61.2, co2_kg_per_yr=2100.0, primary_energy_kwh_per_yr=240.0 + ) + measures = ( + PlanMeasure( + measure_type=MeasureType.CAVITY_WALL_INSULATION, + description="Cavity wall insulation", + cost=Cost(total=500.0, contingency_rate=0.1), + impact=MeasureImpact( + sap_points=3.1, + co2_savings_kg_per_yr=600.0, + energy_savings_kwh_per_yr=1200.0, + ), + kwh_savings=900.0, + energy_cost_savings=120.0, + ), + PlanMeasure( + measure_type=MeasureType.MECHANICAL_VENTILATION, + description="Mechanical extract ventilation", + cost=Cost(total=900.0, contingency_rate=0.26), + impact=MeasureImpact( + sap_points=-1.3, + co2_savings_kg_per_yr=-50.0, + energy_savings_kwh_per_yr=-200.0, + ), + kwh_savings=-150.0, + energy_cost_savings=-30.0, + ), + ) + return Plan(measures=measures, baseline=baseline, post_retrofit=post) + + +def test_table_shows_valuation_uplift_with_pounds() -> None: + # Arrange — a £200k property modelled D (57.4) -> C (72.0). + baseline = Score( + sap_continuous=57.4, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=300.0 + ) + post = Score( + sap_continuous=72.0, co2_kg_per_yr=2100.0, primary_energy_kwh_per_yr=240.0 + ) + plan = Plan( + measures=(), + baseline=baseline, + post_retrofit=post, + current_market_value=200_000.0, + ) + + # Act + table: str = format_plan_table(plan) + + # Assert — the valuation line shows the average % uplift and its £ forms. + assert "valuation uplift" in table + assert "+2.5%" in table + assert "£5,000" in table + assert "£205,000" in table + + +def test_table_shows_package_transition_and_each_measure() -> None: + # Arrange + plan: Plan = _plan() + + # Act + table: str = format_plan_table(plan) + + # Assert — the package SAP transition (both bands resolve to D), and each + # measure's signed SAP contribution against its type. + assert "57.4" in table + assert "61.2" in table + assert "(D)" in table + assert "cavity_wall_insulation" in table + assert "+3.1" in table + assert "mechanical_ventilation" in table + assert "-1.3" in table + # The package cost of works (500 + 900) appears. + assert "1,400" in table diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py new file mode 100644 index 00000000..af020aee --- /dev/null +++ b/tests/harness/test_report.py @@ -0,0 +1,318 @@ +"""Per-property inspection report over a dump of API-shaped EPC JSONs.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from domain.sap10_calculator.validation.parity_report import ParityReport +from harness.report import ( + MeasureTrigger, + PropertyReport, + build_property_report, + build_property_reports, + format_report_csv, + format_report_markdown, + parity_report_for, +) +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + +_GOLDEN = ( + Path(__file__).resolve().parents[1] + / "domain/sap10_calculator/rdsap/fixtures/golden" +) + +# Two real golden certs straddling the |Δ| > 0.5 calculator-error flag: +# 0036 — lodged 63, calculated 62.747 -> Δ 0.253 (not flagged) +# 0240 — lodged 73, calculated 71.727 -> Δ 1.273 (flagged) +_WITHIN_TOLERANCE = "0036-6325-1100-0063-1226" +_DIVERGENT = "0240-0200-5706-2365-8010" + +# 0330 fires all three trigger kinds: an uninsulated cavity wall (cavity fill), +# its dependent mechanical ventilation, and an uninsulated solid floor. +_THREE_MEASURES = "0330-2249-8150-2326-4121" + + +def _triggers_by_measure(report: PropertyReport) -> dict[str, MeasureTrigger]: + return {trigger.measure_type: trigger for trigger in report.measure_triggers} + + +def test_calculator_error_is_lodged_minus_calculated_and_within_tolerance() -> None: + # Arrange + path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json" + + # Act + report: PropertyReport = build_property_report(path) + + # Assert — lodged SAP read straight off the cert; calculated un-rounded. + assert report.lodged_sap == 63 + assert report.calculated_sap is not None + assert abs(report.calculated_sap - 62.747) <= 0.01 + assert report.sap_error is not None + assert abs(report.sap_error - (63 - report.calculated_sap)) <= 1e-9 + assert report.sap_error_exceeds_threshold is False + assert report.calculator_error is None + + +def test_calculator_error_flags_divergence_beyond_half_a_sap_point() -> None: + # Arrange + path: Path = _GOLDEN / f"{_DIVERGENT}.json" + + # Act + report: PropertyReport = build_property_report(path) + + # Assert — Δ 1.273 > 0.5, so the shadow-validation flag fires. + assert report.lodged_sap == 73 + assert report.sap_error is not None + assert report.sap_error > 0.5 + assert report.sap_error_exceeds_threshold is True + + +def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: + # Arrange + path: Path = _GOLDEN / f"{_THREE_MEASURES}.json" + + # Act + report: PropertyReport = build_property_report(path) + + # Assert — the Plan ran and every fired measure names its trigger fields. + assert report.plan is not None + assert report.plan_error is None + # This gas dwelling lodges an electric secondary heater (SAP 691) on a + # category-2 main, so secondary-heating removal (ADR-0028) is a very cheap + # SAP lever (\£250); the Optimiser reaches the target band via the fabric + # stack + that removal, leaving the \£12k ASHP unselected (it owns the + # economics — ADR-0024). + triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) + assert set(triggers) == { + "cavity_wall_insulation", + "mechanical_ventilation", + "solid_floor_insulation", + "secondary_heating_removal", + } + # Cavity-fill fired off an uninsulated cavity wall; its dependent MEV fired + # because no mechanical ventilation is lodged. + assert triggers["cavity_wall_insulation"].triggers == { + "wall_construction": 4, + "wall_insulation_type": 4, + } + assert triggers["mechanical_ventilation"].triggers == { + "mechanical_ventilation_kind": None, + } + # Solid-floor insulation fired off an uninsulated solid ground floor. + assert triggers["solid_floor_insulation"].triggers == { + "floor_insulation_thickness": None, + "floor_construction_type": "Solid", + } + # Secondary-heating removal fired off the lodged secondary (SAP code 691). + assert triggers["secondary_heating_removal"].triggers == { + "secondary_heating_type": 691, + } + + +def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None: + # No golden API cert selects the boiler upgrade (it competes with — and on + # houses loses to — the ASHP bundle within the one heating Recommendation), + # so the trigger branch is exercised directly, like the cert_to_inputs unit + # tests of internal helpers. + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a mains-gas wet boiler (SAP code 114) with a hot-water cylinder: + # the boiler-upgrade eligibility attributes the report should explain. + epc = parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf") + + # Act + triggers = _triggers_for(epc, "gas_boiler_upgrade") + + # Assert — the wet-boiler SAP code, the mains-gas connection that makes the + # gas end-state installable, and the cylinder that shapes the bundle. + assert triggers == { + "sap_main_heating_code": 114, + "mains_gas": True, + "has_hot_water_cylinder": True, + } + + +def test_secondary_heating_removal_surfaces_its_eligibility_triggers() -> None: + # No golden API cert selects secondary-heating removal, so the trigger branch + # is exercised directly. The generator fires on any lodged secondary, so the + # lodged SAP code is what the report should explain (ADR-0028). + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a parseable 001431 cert with a secondary heating system lodged + # (SAP code 691, electric panel/convector/radiant heaters). + epc = parse_recommendation_summary("cavity_wall_001431_before.pdf") + epc.sap_heating.secondary_heating_type = 691 + + # Act / Assert + assert _triggers_for(epc, "secondary_heating_removal") == { + "secondary_heating_type": 691, + } + + +def test_system_tune_up_surfaces_its_eligibility_triggers() -> None: + # Like the boiler-upgrade trigger, no golden cert selects a tune-up, so the + # branch is covered directly. + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a wet boiler (SAP code 102) with "no control" (2101): the wet- + # boiler code and the improvable control are what the report should explain. + epc = parse_recommendation_summary("tune_up_from_2101_001431_before.pdf") + + # Act / Assert — both tune-up measure types surface the same eligibility. + expected = {"sap_main_heating_code": 102, "main_heating_control": 2101} + assert _triggers_for(epc, "system_tune_up") == expected + assert _triggers_for(epc, "system_tune_up_zoned") == expected + + +def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: + # Arrange + path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json" + + # Act + report: PropertyReport = build_property_report(path) + + # Assert — 0036 reaches the target band with solid-floor insulation plus + # secondary-heating removal (it lodges an electric secondary, SAP 691, on a + # gas main — a cheap SAP lever, ADR-0028), and nothing else. The cheaper-to- + # target pair displaces the LED upgrade the Optimiser used to add. + triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) + assert set(triggers) == {"solid_floor_insulation", "secondary_heating_removal"} + assert triggers["solid_floor_insulation"].triggers == { + "floor_insulation_thickness": None, + "floor_construction_type": "Solid", + } + assert triggers["secondary_heating_removal"].triggers == { + "secondary_heating_type": 691, + } + + +def test_cohort_builder_models_each_path_capturing_errors(tmp_path: Path) -> None: + # Arrange — two real certs plus one the mapper rejects. + bad: Path = tmp_path / "broken.json" + bad.write_text(json.dumps({"not": "an epc"})) + paths: list[Path] = [ + _GOLDEN / f"{_WITHIN_TOLERANCE}.json", + _GOLDEN / f"{_DIVERGENT}.json", + bad, + ] + + # Act + reports: list[PropertyReport] = build_property_reports(paths) + + # Assert — one report per path, the bad one carrying its error. + assert [report.name for report in reports] == [ + _WITHIN_TOLERANCE, + _DIVERGENT, + "broken", + ] + assert reports[2].calculator_error is not None + + +def test_cohort_parity_report_excludes_unscorable_certs() -> None: + # Arrange — a within-tolerance cert, a divergent cert, and an unscorable one. + reports: list[PropertyReport] = [ + PropertyReport(name="a", lodged_sap=63, calculated_sap=62.747), + PropertyReport(name="b", lodged_sap=73, calculated_sap=71.727), + PropertyReport( + name="c", lodged_sap=None, calculated_sap=None, calculator_error="boom" + ), + ] + + # Act + parity: ParityReport = parity_report_for(reports) + + # Assert — only the two scorable certs form parity cases; b is the worst. + assert parity.case_count == 2 + assert parity.worst_cases[0].certificate_number == "b" + # ParityReport's residual is predicted − actual (calculated − lodged); we + # under-predict both certs, so the global bias is negative. + assert parity.global_bias < 0 + expected_mae: float = (abs(63 - 62.747) + abs(73 - 71.727)) / 2 + assert abs(parity.global_mae - expected_mae) <= 1e-9 + + +def test_markdown_renders_the_three_sections(tmp_path: Path) -> None: + # Arrange — a measure-bearing within-tolerance cert, a flagged cert, and an + # unscorable one. + bad: Path = tmp_path / "broken.json" + bad.write_text(json.dumps({"not": "an epc"})) + reports: list[PropertyReport] = build_property_reports( + [ + _GOLDEN / f"{_WITHIN_TOLERANCE}.json", + _GOLDEN / f"{_DIVERGENT}.json", + bad, + ] + ) + + # Act + markdown: str = format_report_markdown(reports) + + # Assert — the three sections are present. + assert "## 1. Calculator error" in markdown + assert "## 2. Plans + costings" in markdown + assert "## 3. Recommended measures" in markdown + # Section 1 carries the cohort parity stats and a flag on the divergent cert. + assert "MAE" in markdown + assert _DIVERGENT in markdown + assert "broken" in markdown # the unscorable cert still appears, as an error + # Section 3 explains a fired measure via its trigger fields. + assert "solid_floor_insulation" in markdown + assert "floor_construction_type" in markdown + + +def test_csv_has_one_row_per_property_with_flags_and_triggers(tmp_path: Path) -> None: + # Arrange + bad: Path = tmp_path / "broken.json" + bad.write_text(json.dumps({"not": "an epc"})) + reports: list[PropertyReport] = build_property_reports( + [ + _GOLDEN / f"{_WITHIN_TOLERANCE}.json", + _GOLDEN / f"{_DIVERGENT}.json", + bad, + ] + ) + + # Act + csv: str = format_report_csv(reports) + + # Assert — header plus one row per property. + lines: list[str] = csv.splitlines() + assert lines[0].startswith("cert,") + assert len(lines) == len(reports) + 1 + # Every data row is comma-safe (no row splits into extra columns). + column_count: int = len(lines[0].split(",")) + assert all(len(line.split(",")) == column_count for line in lines[1:]) + rows: dict[str, str] = {line.split(",")[0]: line for line in lines[1:]} + # The divergent cert carries the |Δ| > 0.5 flag and the within-tolerance one doesn't. + flag_index: int = lines[0].split(",").index("sap_error_flag") + assert rows[_DIVERGENT].split(",")[flag_index] == "1" + assert rows[_WITHIN_TOLERANCE].split(",")[flag_index] == "0" + # The measure-bearing cert flattens its triggers into the row. + assert "solid_floor_insulation" in rows[_WITHIN_TOLERANCE] + assert "floor_construction_type=Solid" in rows[_WITHIN_TOLERANCE] + # The unscorable cert keeps its error. + assert "ValueError" in rows["broken"] + + +def test_unparseable_cert_is_captured_not_raised(tmp_path: Path) -> None: + # Arrange — a payload the mapper rejects must not abort the report. + bad: Path = tmp_path / "broken.json" + bad.write_text(json.dumps({"not": "an epc"})) + + # Act + report: PropertyReport = build_property_report(bad) + + # Assert — the raise is recorded as this property's calculator error. + assert report.name == "broken" + assert report.lodged_sap is None + assert report.calculated_sap is None + assert report.sap_error is None + assert report.sap_error_exceeds_threshold is False + assert report.calculator_error is not None + assert "ValueError" in report.calculator_error + # No Plan either — but it is recorded, not raised. + assert report.plan is None + assert report.measure_triggers == () diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index edcc24ac..462b0acb 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -10,28 +10,58 @@ from types import TracebackType from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference +from domain.modelling.plan import Plan +from domain.modelling.scenario import Scenario from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property.properties import Properties from domain.property.property import Property +from repositories.plan.plan_repository import PlanRepository +from repositories.product.product_repository import ProductRepository from repositories.property_baseline.property_baseline_repository import PropertyBaselineRepository from repositories.epc.epc_repository import EpcRepository from repositories.property.property_repository import ( PropertyIdentityInsert, PropertyRepository, ) +from repositories.scenario.scenario_repository import ScenarioRepository from repositories.solar.solar_repository import SolarRepository +from repositories.spatial.spatial_repository import SpatialRepository from repositories.unit_of_work import UnitOfWork +from domain.modelling.product import Product class FakePropertyRepo(PropertyRepository): - def __init__(self, by_id: dict[int, Property]) -> None: + """Holds Properties by id. When an ``epc_repo`` is supplied it composes the + effective EPC from it at read time — mirroring `PropertyPostgresRepository`, + so an EPC that Ingestion persists becomes visible on the Property that + Baseline / Modelling read back (the through-repos hand-off, in memory).""" + + def __init__( + self, + by_id: dict[int, Property], + epc_repo: Optional["FakeEpcRepo"] = None, + ) -> None: self._by_id = by_id + self._epc_repo = epc_repo + + def _hydrate(self, property_id: int) -> Property: + prop = self._by_id[property_id] + if self._epc_repo is None: + return prop + return Property( + identity=prop.identity, + epc=self._epc_repo.get_for_property(property_id), + site_notes=prop.site_notes, + current_market_value=prop.current_market_value, + ) def get(self, property_id: int) -> Property: - return self._by_id[property_id] + return self._hydrate(property_id) def get_many(self, property_ids: list[int]) -> Properties: - return Properties([self._by_id[property_id] for property_id in property_ids]) + return Properties([self._hydrate(property_id) for property_id in property_ids]) def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: self.inserted: list[PropertyIdentityInsert] = list(rows) @@ -75,14 +105,41 @@ class FakeEpcRepo(EpcRepository): class FakeSolarRepo(SolarRepository): - def __init__(self) -> None: + """In-memory Google Solar insights store. Seed `by_property` to hydrate a + Property's potential for a Modelling read; `saved` records writes for an + Ingestion-side assertion. Returns None for an unseeded Property (no solar + data fetched) — the solar Generator then offers nothing.""" + + def __init__( + self, by_property: Optional[dict[int, dict[str, Any]]] = None + ) -> None: self.saved: list[tuple[int, dict[str, Any]]] = [] + self._by_property: dict[int, dict[str, Any]] = dict(by_property or {}) def save(self, property_id: int, insights: dict[str, Any]) -> None: self.saved.append((property_id, insights)) + self._by_property[property_id] = insights - def get(self, property_id: int) -> Optional[dict[str, Any]]: # pragma: no cover - raise NotImplementedError + def get(self, property_id: int) -> Optional[dict[str, Any]]: + return self._by_property.get(property_id) + + +class FakeSpatialRepo(SpatialRepository): + """In-memory per-UPRN spatial cache. Seed `by_uprn` to hydrate Properties in + a read test; `saved` records writes for an Ingestion-side assertion.""" + + def __init__( + self, by_uprn: Optional[dict[int, PlanningRestrictions]] = None + ) -> None: + self._by_uprn: dict[int, PlanningRestrictions] = dict(by_uprn or {}) + self.saved: list[tuple[int, SpatialReference]] = [] + + def save(self, uprn: int, reference: SpatialReference) -> None: + self.saved.append((uprn, reference)) + self._by_uprn[uprn] = reference.restrictions + + def get_for_uprns(self, uprns: list[int]) -> dict[int, PlanningRestrictions]: + return {uprn: self._by_uprn[uprn] for uprn in uprns if uprn in self._by_uprn} class FakePropertyBaselineRepo(PropertyBaselineRepository): @@ -99,6 +156,52 @@ class FakePropertyBaselineRepo(PropertyBaselineRepository): raise NotImplementedError +class FakeScenarioRepository(ScenarioRepository): + def __init__(self, by_id: Optional[dict[int, Scenario]] = None) -> None: + self._by_id = by_id or {} + + def get_many(self, scenario_ids: list[int]) -> list[Scenario]: + missing = [sid for sid in scenario_ids if sid not in self._by_id] + if missing: + raise ValueError(f"no scenario for ids {missing}") + return [self._by_id[sid] for sid in scenario_ids] + + +class FakePlanRepository(PlanRepository): + """Idempotent in-memory Plan store keyed by ``(property_id, scenario_id)`` — + a re-run replaces rather than duplicates (ADR-0017). ``saved`` is the store + a test (or the console harness) reads the Plan back from.""" + + def __init__(self) -> None: + self.saved: dict[tuple[int, int], Plan] = {} + self._next_id = 1 + + def save( + self, + plan: Plan, + *, + property_id: int, + scenario_id: int, + portfolio_id: int, + is_default: bool, + ) -> int: + self.saved[(property_id, scenario_id)] = plan + plan_id = self._next_id + self._next_id += 1 + return plan_id + + +class _UnsetProductRepo(ProductRepository): + """Default for a `FakeUnitOfWork` built without a catalogue — raises if a + generator actually reaches for a Product, so the omission is loud.""" + + def get(self, measure_type: str) -> Product: # pragma: no cover + raise ValueError( + f"no product catalogue wired into this FakeUnitOfWork " + f"(asked for {measure_type!r})" + ) + + class FakeUnitOfWork(UnitOfWork): """A unit that holds in-memory repos and counts commits.""" @@ -108,12 +211,20 @@ class FakeUnitOfWork(UnitOfWork): property: FakePropertyRepo, epc: Optional[FakeEpcRepo] = None, solar: Optional[FakeSolarRepo] = None, + spatial: Optional[FakeSpatialRepo] = None, property_baseline: Optional[FakePropertyBaselineRepo] = None, + scenario: Optional[FakeScenarioRepository] = None, + product: Optional[ProductRepository] = None, + plan: Optional[FakePlanRepository] = None, ) -> None: self.property = property self.epc = epc or FakeEpcRepo() self.solar = solar or FakeSolarRepo() + self.spatial = spatial or FakeSpatialRepo() self.property_baseline = property_baseline or FakePropertyBaselineRepo() + self.scenario = scenario or FakeScenarioRepository() + self.product = product or _UnsetProductRepo() + self.plan = plan or FakePlanRepository() self.commits = 0 def __enter__(self) -> "FakeUnitOfWork": diff --git a/tests/orchestration/test_ara_first_run_pipeline.py b/tests/orchestration/test_ara_first_run_pipeline.py index 8d78ff2c..bb0399ab 100644 --- a/tests/orchestration/test_ara_first_run_pipeline.py +++ b/tests/orchestration/test_ara_first_run_pipeline.py @@ -34,8 +34,10 @@ class _SpyModelling: def __init__(self, log: list[tuple[object, ...]]) -> None: self._log = log - def run(self, property_ids: list[int], scenario_ids: list[int]) -> None: - self._log.append(("modelling", property_ids, scenario_ids)) + def run( + self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int + ) -> None: + self._log.append(("modelling", property_ids, scenario_ids, portfolio_id)) def test_run_sequences_the_three_stages_threading_only_property_ids() -> None: @@ -60,5 +62,5 @@ def test_run_sequences_the_three_stages_threading_only_property_ids() -> None: assert log == [ ("ingestion", [10, 11]), ("baseline", [10, 11]), - ("modelling", [10, 11], [7]), + ("modelling", [10, 11], [7], 1), ] diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index e60ac716..cadd5daa 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -13,29 +13,44 @@ from pathlib import Path from typing import Any, Optional from sqlalchemy import Engine -from sqlmodel import Session, select +from sqlmodel import Session, col, select from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.property_baseline.rebaseliner import StubRebaseliner +from domain.sap10_calculator.calculator import Sap10Calculator +from domain.modelling.portfolio_goal import PortfolioGoal +from infrastructure.postgres.modelling import ScenarioModel from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) from infrastructure.postgres.property_baseline_performance_table import ( PropertyBaselinePerformanceModel, ) from infrastructure.postgres.epc_property_table import EpcPropertyModel +from infrastructure.postgres.modelling import PlanModel, RecommendationModel +from infrastructure.postgres.product_table import MaterialRow from infrastructure.postgres.property_table import PropertyRow +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc as _build_uninsulated_cavity_and_floor_epc, +) from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator from orchestration.ara_first_run_pipeline import AraFirstRunPipeline from orchestration.ingestion_orchestrator import IngestionOrchestrator from orchestration.modelling_orchestrator import ModellingOrchestrator +from repositories.epc.epc_postgres_repository import EpcPostgresRepository from repositories.property_baseline.property_baseline_postgres_repository import ( PropertyBaselinePostgresRepository, ) +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, +) from repositories.geospatial.geospatial_repository import GeospatialRepository -from repositories.materials.materials_repository import MaterialsRepository from repositories.postgres_unit_of_work import PostgresUnitOfWork -from repositories.scenario.scenario_repository import ScenarioRepository _JSON_SAMPLES = Path(__file__).resolve().parents[2] / "backend/epc_api/json_samples" @@ -98,6 +113,95 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( uprn=12345, ) ) + # Modelling now runs for real: it reads scenario 7 (the command's + # scenario_ids) through the repo, so the row must exist. + session.add( + ScenarioModel( + id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True + ) + ) + # The sample EPC's solid floor is uninsulated, so the floor generator + # fires during candidate generation and prices against this Product. The + # ventilation Measure Dependency is built for every not-yet-ventilated + # dwelling, so its Product must exist too (ADR-0016). The EPC also lodges + # a single-glazed window, so the glazing generator fires and reaches for + # the double-glazing Product (ADR-0022). + session.add_all( + [ + MaterialRow( + id=5, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), + MaterialRow( + id=1, + type="solid_floor_insulation", + total_cost=25.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Solid floor insulation", + ), + MaterialRow( + id=2, + type="mechanical_ventilation", + total_cost=450.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Mechanical extract ventilation unit", + ), + MaterialRow( + id=3, + type="double_glazing", + total_cost=600.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Double glazing", + ), + MaterialRow( + id=4, + type="low_energy_lighting", + total_cost=8.0, + cost_unit="gbp_per_unit", + is_active=True, + description="LED bulb", + ), + MaterialRow( + id=6, + type="gas_boiler_upgrade", + total_cost=3000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Gas condensing boiler", + ), + MaterialRow( + id=7, + type="system_tune_up", + total_cost=500.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Heating controls + cylinder tune-up", + ), + MaterialRow( + id=8, + type="system_tune_up_zoned", + total_cost=900.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Zoned heating controls + cylinder tune-up", + ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), + ] + ) session.commit() def unit_of_work() -> PostgresUnitOfWork: @@ -113,10 +217,12 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( baseline=PropertyBaselineOrchestrator( unit_of_work=unit_of_work, rebaseliner=StubRebaseliner(), + fuel_rates=FuelRatesStaticFileRepository(), ), modelling=ModellingOrchestrator( - scenario_repo=ScenarioRepository(), - materials_repo=MaterialsRepository(), + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), ), ) command = _FakeCommand(portfolio_id=1, property_ids=[10], scenario_ids=[7]) @@ -144,3 +250,481 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( assert baseline.space_heating_kwh == 13120.0 assert len(epc_rows) == 1 assert len(baseline_rows) == 1 + + +def test_modelling_optimises_and_persists_a_multi_measure_plan( + db_engine: Engine, +) -> None: + # Arrange — an EPC with an uninsulated cavity wall AND an uninsulated + # suspended floor (loft already at 300mm), so the wall + floor Generators + # both fire and the Optimiser selects from two groups. We drive the + # Modelling stage directly off a repo-seeded EPC rather than the full + # pipeline: this calculator fixture has no lodged recorded-performance + # fields, so the Baseline stage (not under test here) can't run on it. + # SAP-numeric correctness is pinned in test_elmhurst_cascade_pins; here we + # prove the multi-measure Plan is optimised, priced, attributed and + # persisted. The property is band D (~57.4) and tops out at ~61, so the + # goal-C target is unreachable — this exercises the least-cost-to-target + # objective's **max-gain fallback** (ADR-0016 amendment): best effort, all + # measures, below target. + with Session(db_engine) as session: + session.add( + PropertyRow( + id=30, + portfolio_id=1, + postcode="A0 0AA", + address="3 Some Street", + uprn=33333, + ) + ) + session.add( + ScenarioModel( + id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True + ) + ) + session.add_all( + [ + MaterialRow( + id=5, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), + MaterialRow( + id=1, + type="cavity_wall_insulation", + total_cost=18.5, + cost_unit="gbp_per_m2", + is_active=True, + description="Cavity wall insulation", + ), + MaterialRow( + id=2, + type="suspended_floor_insulation", + total_cost=25.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Suspended floor insulation", + ), + MaterialRow( + id=3, + type="mechanical_ventilation", + total_cost=450.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Mechanical extract ventilation unit", + ), + MaterialRow( + id=4, + type="low_energy_lighting", + total_cost=8.0, + cost_unit="gbp_per_unit", + is_active=True, + description="LED bulb", + ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), + ] + ) + session.commit() + EpcPostgresRepository(session).save( + _build_uninsulated_cavity_and_floor_epc(), + property_id=30, + portfolio_id=1, + ) + session.commit() + + def unit_of_work() -> PostgresUnitOfWork: + return PostgresUnitOfWork(lambda: Session(db_engine)) + + # Act + ModellingOrchestrator( + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), + ).run(property_ids=[30], scenario_ids=[7], portfolio_id=1) + + # Assert — one Plan with three Plan Measures: the wall + floor the Optimiser + # chose, plus the ventilation Measure Dependency the wall forces in + # (ADR-0016). Each is priced and attributed, linked by plan_id. + with Session(db_engine) as session: + plan = session.exec( + select(PlanModel).where(col(PlanModel.property_id) == 30) + ).first() + assert plan is not None + rec_rows = session.exec( + select(RecommendationModel).where( + col(RecommendationModel.plan_id) == plan.id + ) + ).all() + + assert plan.scenario_id == 7 + assert plan.portfolio_id == 1 + assert plan.is_default is True + assert plan.post_sap_points is not None + assert plan.post_epc_rating is not None + assert plan.cost_of_works is not None + assert plan.cost_of_works > 0.0 + # Plan-level energy/bill figures derived from the post-package bill vs the + # baseline bill at the run's Fuel Rates (ADR-0014 amendment). The package + # improves the property, so it consumes less energy and costs less to run. + assert plan.post_energy_bill is not None and plan.post_energy_bill > 0.0 + assert plan.post_energy_consumption is not None + assert plan.post_energy_consumption > 0.0 + assert plan.energy_bill_savings is not None and plan.energy_bill_savings > 0.0 + assert plan.energy_consumption_savings is not None + assert plan.energy_consumption_savings > 0.0 + + by_type = {rec.type: rec for rec in rec_rows} + assert set(by_type) == { + "cavity_wall_insulation", + "suspended_floor_insulation", + "mechanical_ventilation", + # The sample EPC lodges 8 low-energy-unknown bulbs, so the LED upgrade is + # a cheap positive-SAP candidate the Optimiser also keeps (ADR-0023). + "low_energy_lighting", + # The efficient representative heat pump (Vaillant aroTHERM plus 5 kW, + # ADR-0025) now raises SAP even on this gas dwelling, so the Optimiser + # also keeps the ASHP bundle in the least-cost-to-band package (ADR-0024). + "air_source_heat_pump", + # The sample lodges an electric secondary (SAP 691), so removal is offered + # (ADR-0028); the Optimiser keeps it in its all-beneficial-measures package + # — its SAP gain is 0 once the ASHP (category 4) ignores the secondary, but + # the heater is still physically removed at its own cost. + "secondary_heating_removal", + } + # Each persisted measure carries the catalogue id of the Product it installs + # (the MaterialRow ids seeded above), replacing the retired + # recommendation_materials BOM with a single material_id on the row. + assert by_type["cavity_wall_insulation"].material_id == 1 + assert by_type["suspended_floor_insulation"].material_id == 2 + assert by_type["mechanical_ventilation"].material_id == 3 + assert by_type["low_energy_lighting"].material_id == 4 + assert by_type["secondary_heating_removal"].material_id == 9 + for rec in rec_rows: + assert rec.default is True + assert rec.already_installed is False + assert rec.sap_points is not None + assert rec.estimated_cost is not None + # The forced ventilation costs two £450 units and is priced even though it + # was never a free choice in the pool. + vent_cost: float | None = by_type["mechanical_ventilation"].estimated_cost + assert vent_cost is not None + assert abs(vent_cost - 900.0) <= 1e-6 + # The insulation measures earn positive SAP; ventilation's contribution is + # not positive (it only ever costs SAP — ADR-0016). + wall_sap: float | None = by_type["cavity_wall_insulation"].sap_points + vent_sap: float | None = by_type["mechanical_ventilation"].sap_points + assert wall_sap is not None and vent_sap is not None + assert wall_sap > 0.0 + assert vent_sap <= 0.0 + # Per-measure bill savings (telescoping cascade, ADR-0014 amendment): each + # measure carries its delivered-kWh and £ saving, and they telescope exactly + # to the Plan's headline savings. Ventilation increases energy, so its + # savings are negative — and the telescoping still holds. + for rec in rec_rows: + assert rec.kwh_savings is not None + assert rec.energy_cost_savings is not None + vent_kwh: float | None = by_type["mechanical_ventilation"].kwh_savings + assert vent_kwh is not None and vent_kwh < 0.0 + kwh_total: float = sum(rec.kwh_savings or 0.0 for rec in rec_rows) + cost_total: float = sum(rec.energy_cost_savings or 0.0 for rec in rec_rows) + assert plan.energy_consumption_savings is not None + assert plan.energy_bill_savings is not None + assert abs(kwh_total - plan.energy_consumption_savings) <= 1e-6 + assert abs(cost_total - plan.energy_bill_savings) <= 1e-6 + + +def test_modelling_recommends_nothing_when_already_at_the_target_band( + db_engine: Engine, +) -> None: + # Arrange — the same band-D property (~57.4), but a goal of band D, which it + # already meets. Least-cost-to-target recommends the cheapest package that + # *reaches* the target — and the target is already reached, so the cheapest + # package is the empty one. (The old max-gain objective would have + # recommended wall + floor + ventilation here, improving within the band the + # property is already in — exactly the over-recommendation this objective + # removes.) ADR-0016 amendment. + with Session(db_engine) as session: + session.add( + PropertyRow( + id=31, + portfolio_id=1, + postcode="A0 0AA", + address="4 Some Street", + uprn=44444, + ) + ) + session.add( + ScenarioModel( + id=8, goal=PortfolioGoal.INCREASING_EPC, goal_value="D", is_default=True + ) + ) + # The fabric Generators + the ventilation dependency builder still run + # during candidate generation, so their Products must exist even though + # nothing is ultimately selected. + session.add_all( + [ + MaterialRow( + id=14, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), + MaterialRow( + id=10, + type="cavity_wall_insulation", + total_cost=18.5, + cost_unit="gbp_per_m2", + is_active=True, + description="Cavity wall insulation", + ), + MaterialRow( + id=11, + type="suspended_floor_insulation", + total_cost=25.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Suspended floor insulation", + ), + MaterialRow( + id=12, + type="mechanical_ventilation", + total_cost=450.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Mechanical extract ventilation unit", + ), + MaterialRow( + id=13, + type="low_energy_lighting", + total_cost=8.0, + cost_unit="gbp_per_unit", + is_active=True, + description="LED bulb", + ), + MaterialRow( + id=9, + type="secondary_heating_removal", + total_cost=250.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Secondary heating removal", + ), + ] + ) + session.commit() + EpcPostgresRepository(session).save( + _build_uninsulated_cavity_and_floor_epc(), + property_id=31, + portfolio_id=1, + ) + session.commit() + + def unit_of_work() -> PostgresUnitOfWork: + return PostgresUnitOfWork(lambda: Session(db_engine)) + + # Act + ModellingOrchestrator( + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), + ).run(property_ids=[31], scenario_ids=[8], portfolio_id=1) + + # Assert — a Plan is persisted with no measures and zero cost; the + # post-retrofit figure is the unchanged baseline (still band D). + with Session(db_engine) as session: + plan = session.exec( + select(PlanModel).where(col(PlanModel.property_id) == 31) + ).first() + assert plan is not None + rec_rows = session.exec( + select(RecommendationModel).where( + col(RecommendationModel.plan_id) == plan.id + ) + ).all() + + assert rec_rows == [] + assert plan.cost_of_works == 0.0 + assert plan.post_epc_rating is Epc.D + # No measures → post bill equals the baseline bill → zero savings, but the + # post-retrofit bill/consumption are still the (non-zero) current figures. + assert plan.post_energy_bill is not None and plan.post_energy_bill > 0.0 + assert plan.post_energy_consumption is not None + assert plan.post_energy_consumption > 0.0 + assert plan.energy_bill_savings == 0.0 + assert plan.energy_consumption_savings == 0.0 + + +class _NoEpcFetcher: + """An EPC fetcher that returns nothing — the EPC is seeded directly so this + e2e drives only the spatial-reference half of Ingestion.""" + + def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]: + return None + + +class _SpatialByUprn(GeospatialRepository): + """Resolves a per-UPRN spatial reference (coordinates nulled — the Solar leg + is not under test).""" + + def __init__(self, by_uprn: dict[int, SpatialReference]) -> None: + self._by_uprn = by_uprn + + def coordinates_for(self, uprn: int) -> Optional[Coordinates]: + return None + + def spatial_for(self, uprn: int) -> Optional[SpatialReference]: + return self._by_uprn.get(uprn) + + +def test_listed_uprn_ingested_blocks_solid_wall_insulation_in_modelling( + db_engine: Engine, +) -> None: + # Arrange — two solid-brick uninsulated dwellings: one in a listed building, + # one unrestricted. Ingestion caches each UPRN's planning protections; the + # EPC is seeded directly (the solid-wall mechanics are pinned elsewhere). + listed_reference = SpatialReference( + coordinates=None, restrictions=PlanningRestrictions(is_listed=True) + ) + unrestricted_reference = SpatialReference( + coordinates=None, restrictions=PlanningRestrictions() + ) + solid_brick_epc = parse_recommendation_summary("solid_brick_ewi_001431_before.pdf") + with Session(db_engine) as session: + session.add_all( + [ + PropertyRow( + id=40, + portfolio_id=1, + postcode="A0 0AA", + address="Listed House", + uprn=44444, + ), + PropertyRow( + id=41, + portfolio_id=1, + postcode="A0 0AA", + address="Unrestricted House", + uprn=55555, + ), + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + is_default=True, + ), + ] + ) + # The solid-brick EPC fires the floor + solid-wall Generators and the + # ventilation dependency, so every Product they reach for must exist. + session.add_all( + [ + MaterialRow( + id=5, + type="air_source_heat_pump", + total_cost=12000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Air source heat pump", + ), + MaterialRow( + id=1, + type="external_wall_insulation", + total_cost=100.0, + cost_unit="gbp_per_m2", + is_active=True, + description="External wall insulation", + ), + MaterialRow( + id=2, + type="internal_wall_insulation", + total_cost=90.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Internal wall insulation", + ), + MaterialRow( + id=3, + type="solid_floor_insulation", + total_cost=25.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Solid floor insulation", + ), + MaterialRow( + id=4, + type="mechanical_ventilation", + total_cost=450.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Mechanical extract ventilation unit", + ), + ] + ) + session.commit() + epc_repo = EpcPostgresRepository(session) + epc_repo.save(solid_brick_epc, property_id=40, portfolio_id=1) + epc_repo.save(solid_brick_epc, property_id=41, portfolio_id=1) + session.commit() + + def unit_of_work() -> PostgresUnitOfWork: + return PostgresUnitOfWork(lambda: Session(db_engine)) + + geospatial_repo = _SpatialByUprn( + {44444: listed_reference, 55555: unrestricted_reference} + ) + + # Act — Ingestion caches the protections per UPRN, then Modelling reads them + # back off the Property (through the repo) and gates the solid-wall measures. + IngestionOrchestrator( + unit_of_work=unit_of_work, + epc_fetcher=_NoEpcFetcher(), + geospatial_repo=geospatial_repo, + solar_fetcher=_UnusedSolarFetcher(), + ).run([40, 41]) + ModellingOrchestrator( + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), + ).run(property_ids=[40, 41], scenario_ids=[7], portfolio_id=1) + + # Assert — a listed building blocks the fabric-protected measures: both + # solid-wall Options AND the ASHP bundle (all gated on `blocks_internal`, + # ADR-0024). So the listed dwelling gets neither, while the unrestricted one + # gets the ASHP bundle (which the efficient Vaillant now makes the Optimiser + # select — ADR-0025, so walls are no longer needed to reach the band). The + # only difference between them is the planning status Ingestion cached, + # proving the gate end to end (ADR-0019/0020/0024). + _PROTECTED_TYPES = { + "external_wall_insulation", + "internal_wall_insulation", + "air_source_heat_pump", + } + with Session(db_engine) as session: + listed_types = _plan_measure_types(session, property_id=40) + unrestricted_types = _plan_measure_types(session, property_id=41) + + assert _PROTECTED_TYPES.isdisjoint(listed_types) + assert "air_source_heat_pump" in unrestricted_types + + +def _plan_measure_types(session: Session, *, property_id: int) -> set[str]: + plan = session.exec( + select(PlanModel).where(col(PlanModel.property_id) == property_id) + ).first() + assert plan is not None + rec_rows = session.exec( + select(RecommendationModel).where(col(RecommendationModel.plan_id) == plan.id) + ).all() + return {rec.type for rec in rec_rows} diff --git a/tests/orchestration/test_first_run_without_database.py b/tests/orchestration/test_first_run_without_database.py new file mode 100644 index 00000000..08769c8e --- /dev/null +++ b/tests/orchestration/test_first_run_without_database.py @@ -0,0 +1,46 @@ +"""First Run end-to-end with NO database, via the harness console entrypoint. + +`harness.console.run_one` wires the full AraFirstRunPipeline (Ingestion -> +Baseline -> Modelling) against in-memory fakes. This proves the whole flow runs +start-to-finish with no Session ever opened and yields a multi-measure Plan; +`tests/harness/test_console.py` covers the entrypoint's print/return contract. +""" + +from __future__ import annotations + +import dataclasses + +from datatypes.epc.domain.epc import Epc +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from harness.console import run_one +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc as _build_uninsulated_cavity_and_floor_epc, +) + + +def _uninsulated_lodged_epc() -> EpcPropertyData: + # 000490: an uninsulated cavity wall + suspended floor (loft already 300mm), + # so the wall + floor Generators fire and the ventilation Dependency follows. + # The calculator fixture carries no lodged recorded-performance, so we fill it + # in (it already carries the RHI block) so the Baseline stage can run inside + # the full pipeline. + epc = _build_uninsulated_cavity_and_floor_epc() + return dataclasses.replace( + epc, + energy_rating_current=57, + current_energy_efficiency_band=Epc.D, + co2_emissions_current=3.0, + energy_consumption_current=300, + ) + + +def test_first_run_produces_a_multi_measure_plan_without_a_database() -> None: + # Arrange + epc: EpcPropertyData = _uninsulated_lodged_epc() + + # Act — the whole First Run, no Session ever opened. + plan = run_one(epc, goal_band="C", print_table=False) + + # Assert — a multi-measure Plan that improves on the baseline SAP. + assert len(plan.measures) >= 1 + assert plan.post_sap_continuous >= plan.baseline.sap_continuous diff --git a/tests/orchestration/test_ingestion_orchestrator.py b/tests/orchestration/test_ingestion_orchestrator.py index be2d86b4..0c178185 100644 --- a/tests/orchestration/test_ingestion_orchestrator.py +++ b/tests/orchestration/test_ingestion_orchestrator.py @@ -7,6 +7,8 @@ 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.geospatial.spatial_reference import SpatialReference from domain.property.property import Property, PropertyIdentity from orchestration.ingestion_orchestrator import IngestionOrchestrator from repositories.geospatial.geospatial_repository import GeospatialRepository @@ -14,6 +16,7 @@ from tests.orchestration.fakes import ( FakeEpcRepo, FakePropertyRepo, FakeSolarRepo, + FakeSpatialRepo, FakeUnitOfWork, ) @@ -29,11 +32,22 @@ class _FakeEpcFetcher: class _FakeGeospatialRepo(GeospatialRepository): - def __init__(self, coordinates: Optional[Coordinates]) -> None: - self._coordinates = coordinates + def __init__( + self, + coordinates: Optional[Coordinates], + restrictions: PlanningRestrictions = PlanningRestrictions(), + ) -> None: + self._reference: Optional[SpatialReference] = ( + SpatialReference(coordinates=coordinates, restrictions=restrictions) + if coordinates is not None + else None + ) def coordinates_for(self, uprn: int) -> Optional[Coordinates]: - return self._coordinates + return self._reference.coordinates if self._reference is not None else None + + def spatial_for(self, uprn: int) -> Optional[SpatialReference]: + return self._reference class _FakeSolarFetcher: @@ -88,6 +102,37 @@ def test_ingestion_persists_epc_and_threads_coords_into_solar() -> None: assert uow.commits == 1 +def test_ingestion_caches_the_spatial_reference_by_uprn() -> None: + # Arrange — the geospatial repo resolves a listed-building UPRN. + epc = object.__new__(EpcPropertyData) + reference = SpatialReference( + coordinates=Coordinates(longitude=-0.1278, latitude=51.5074), + restrictions=PlanningRestrictions(is_listed=True), + ) + spatial_repo = FakeSpatialRepo() + uow = FakeUnitOfWork( + property=FakePropertyRepo({10: _property(uprn=12345)}), + epc=FakeEpcRepo(), + spatial=spatial_repo, + ) + orchestrator = IngestionOrchestrator( + unit_of_work=lambda: uow, + epc_fetcher=_FakeEpcFetcher(epc), + geospatial_repo=_FakeGeospatialRepo( + reference.coordinates, restrictions=reference.restrictions + ), + solar_fetcher=_FakeSolarFetcher({}), + ) + + # Act + orchestrator.run([10]) + + # Assert — the resolved reference is cached against the UPRN so Modelling + # reads the protections back (ADR-0020), and the batch commits once. + assert spatial_repo.saved == [(12345, reference)] + assert uow.commits == 1 + + def test_ingestion_skips_property_without_uprn() -> None: # Arrange epc_repo = FakeEpcRepo() diff --git a/tests/orchestration/test_modelling_solar_threading.py b/tests/orchestration/test_modelling_solar_threading.py new file mode 100644 index 00000000..21a35742 --- /dev/null +++ b/tests/orchestration/test_modelling_solar_threading.py @@ -0,0 +1,138 @@ +"""Slice 8 — the ModellingOrchestrator threads a Property's Google Solar +potential (SolarRepository → typed SolarPotential) into the solar Recommendation +Generator (ADR-0026), mirroring how planning_restrictions is threaded. + +Tests the new branching directly: the projection guard (`_solar_potential_for`) +and the candidate wiring (`_candidate_recommendations` includes a Solar PV +Recommendation only when a feasible potential is present). The end-to-end +run-through-repos path is covered by the DB integration tests; here we keep the +seam fast and isolated. +""" + +import json +from pathlib import Path +from typing import Any + +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.product import Product +from domain.modelling.recommendation import Recommendation +from orchestration.modelling_orchestrator import ( + _candidate_recommendations, # pyright: ignore[reportPrivateUsage] + _solar_potential_for, # pyright: ignore[reportPrivateUsage] +) +from repositories.product.product_repository import ProductRepository +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) +from tests.orchestration.fakes import FakeSolarRepo + +_INSIGHTS_FIXTURE: Path = ( + Path(__file__).resolve().parents[1] + / "domain" + / "modelling" + / "fixtures" + / "google_building_insights_001431.json" +) + + +def _insights() -> dict[str, Any]: + with _INSIGHTS_FIXTURE.open(encoding="utf-8") as handle: + data: dict[str, Any] = json.load(handle) + return data + + +class _StubProducts(ProductRepository): + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=0.0, + contingency_rate=0.15, + id=909, + ) + + +def _eligible_house() -> EpcPropertyData: + return parse_recommendation_summary("solar_pv_001431_before.pdf") + + +def test_solar_potential_for_returns_none_when_no_insights() -> None: + # Arrange — an unseeded Property: Ingestion fetched no solar data. + solar = FakeSolarRepo() + + # Act / Assert + assert _solar_potential_for(solar, property_id=42) is None + + +def test_solar_potential_for_returns_none_for_an_error_payload() -> None: + # Arrange — the Solar API found no building; Ingestion persisted the error + # dict (no `solarPotential` block). + solar = FakeSolarRepo(by_property={7: {"error": "ENTITY_NOT_FOUND"}}) + + # Act / Assert + assert _solar_potential_for(solar, property_id=7) is None + + +def test_solar_potential_for_projects_valid_insights() -> None: + # Arrange + solar = FakeSolarRepo(by_property={7: _insights()}) + + # Act + potential = _solar_potential_for(solar, property_id=7) + + # Assert — the real London example projects to the 46-rung ladder. + assert potential is not None + assert abs(potential.panel_capacity_watts - 400.0) <= 1e-4 + assert len(potential.configurations) == 46 + + +def test_candidate_recommendations_includes_solar_when_potential_present() -> None: + # Arrange — a solar-eligible house with a feasible potential. + epc = _eligible_house() + potential = _solar_potential_for( + FakeSolarRepo(by_property={1: _insights()}), property_id=1 + ) + + # Act + recommendations: list[Recommendation] = _candidate_recommendations( + epc, _StubProducts(), PlanningRestrictions(), potential, None + ) + + # Assert — a "Solar PV" Recommendation is among the candidates. + assert "Solar PV" in {r.surface for r in recommendations} + + +def test_candidate_recommendations_excludes_solar_without_potential() -> None: + # Arrange — same house, but no solar potential threaded. + epc = _eligible_house() + + # Act + recommendations = _candidate_recommendations( + epc, _StubProducts(), PlanningRestrictions(), None, None + ) + + # Assert + assert "Solar PV" not in {r.surface for r in recommendations} + + +def test_considered_measures_restricts_candidates_to_the_allowlist() -> None: + # Arrange — a solar-eligible house, with its solar potential present, so the + # unrestricted run offers Solar PV alongside any fabric/heating candidates. + epc = _eligible_house() + potential = _solar_potential_for( + FakeSolarRepo(by_property={1: json.loads(_INSIGHTS_FIXTURE.read_text())}), 1 + ) + + # Act — restrict the run to Solar PV only. + recommendations = _candidate_recommendations( + epc, _StubProducts(), PlanningRestrictions(), potential, frozenset({MeasureType.SOLAR_PV}) + ) + + # Assert — every surviving Option is solar_pv; nothing else leaks through. + option_types = { + option.measure_type + for recommendation in recommendations + for option in recommendation.options + } + assert option_types == {MeasureType.SOLAR_PV} diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 12c3d660..9183a8b3 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from datatypes.epc.domain.epc import Epc @@ -7,17 +9,31 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, RenewableHeatIncentive, ) +from domain.fuel_rates.fuel import Fuel +from domain.billing.bill import BillSection from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property_baseline.performance import Performance -from domain.property_baseline.rebaseliner import RebaselineNotImplemented, StubRebaseliner +from domain.property_baseline.rebaseliner import ( + RebaselineNotImplemented, + RebaselineResult, + Rebaseliner, + StubRebaseliner, +) from domain.property.property import Property, PropertyIdentity +from domain.sap10_calculator.calculator import SapResult from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, +) from tests.orchestration.fakes import ( FakePropertyBaselineRepo, FakePropertyRepo, FakeUnitOfWork, ) +if TYPE_CHECKING: + from datatypes.epc.domain.epc_property_data import EpcPropertyData + def _property(*, sap_version: float) -> Property: epc = object.__new__(EpcPropertyData) @@ -47,13 +63,15 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None: orchestrator = PropertyBaselineOrchestrator( unit_of_work=lambda: uow, rebaseliner=StubRebaseliner(), + fuel_rates=FuelRatesStaticFileRepository(), ) # Act orchestrator.run([10]) # Assert — one Baseline Performance persisted (both halves equal, kWh off the - # RHI), and the batch committed exactly once. + # RHI, no bill because the stub ran no calculator), and the batch committed + # exactly once. lodged = Performance( sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180 ) @@ -65,6 +83,7 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None: rebaseline_reason="none", space_heating_kwh=5000.0, water_heating_kwh=2000.0, + bill=None, ), 10, ) @@ -82,6 +101,7 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None: orchestrator = PropertyBaselineOrchestrator( unit_of_work=lambda: uow, rebaseliner=StubRebaseliner(), + fuel_rates=FuelRatesStaticFileRepository(), ) # Act / Assert — the raise propagates; the batch is neither persisted nor @@ -90,3 +110,85 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None: orchestrator.run([10]) assert property_baseline_repo.saved == [] assert uow.commits == 0 + + +_LIGHTING_KWH = 400.0 + + +def _sap_result_with_lighting() -> SapResult: + """A minimal scored picture carrying only lighting energy — enough for Bill + Derivation to produce one electric section. Mirrors the constructor shape in + tests/domain/property_baseline/test_energy_breakdown.py::_sap_result.""" + return SapResult( + sap_score=72, + sap_score_continuous=72.0, + ecf=0.0, + total_fuel_cost_gbp=0.0, + co2_kg_per_yr=0.0, + space_heating_kwh_per_yr=0.0, + space_cooling_kwh_per_yr=0.0, + fabric_energy_efficiency_kwh_per_m2_yr=0.0, + main_heating_fuel_kwh_per_yr=0.0, + main_2_heating_fuel_kwh_per_yr=0.0, + secondary_heating_fuel_kwh_per_yr=0.0, + space_cooling_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=0.0, + pumps_fans_kwh_per_yr=0.0, + lighting_kwh_per_yr=_LIGHTING_KWH, + appliances_kwh_per_yr=0.0, + cooking_kwh_per_yr=0.0, + main_heating_fuel_code=None, + main_2_heating_fuel_code=None, + secondary_heating_fuel_code=None, + hot_water_fuel_code=None, + pv_exported_kwh_per_yr=0.0, + primary_energy_kwh_per_yr=0.0, + primary_energy_kwh_per_m2=0.0, + monthly=(), + intermediate={}, + ) + + +class _ScoringRebaseliner(Rebaseliner): + """A rebaseliner that returns a fixed scored picture (a SapResult) so the + orchestrator's Bill Derivation wiring exercises (StubRebaseliner returns + sap_result=None, which never bills).""" + + def __init__(self, result: SapResult) -> None: + self._result = result + + def rebaseline( + self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance + ) -> RebaselineResult: + return RebaselineResult( + effective=lodged, reason="none", sap_result=self._result + ) + + +def test_run_derives_and_persists_a_bill_when_the_rebaseliner_scores() -> None: + # Arrange — a rebaseliner that hands back a SapResult with lighting energy, + # so the orchestrator prices it into a Bill at the committed snapshot. + property_baseline_repo = FakePropertyBaselineRepo() + uow = FakeUnitOfWork( + property=FakePropertyRepo({10: _property(sap_version=10.2)}), + property_baseline=property_baseline_repo, + ) + orchestrator = PropertyBaselineOrchestrator( + unit_of_work=lambda: uow, + rebaseliner=_ScoringRebaseliner(_sap_result_with_lighting()), + fuel_rates=FuelRatesStaticFileRepository(), + ) + + # Act + orchestrator.run([10]) + + # Assert — the persisted baseline carries a populated bill; the LIGHTING + # section is the lighting kWh priced at the snapshot's electricity rate + # (read from the snapshot, not hard-coded). + rates = FuelRatesStaticFileRepository().get_current() + expected_cost = _LIGHTING_KWH * rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) / 100.0 + (baseline, _) = property_baseline_repo.saved[0] + assert baseline.bill is not None + lighting = baseline.bill.sections[BillSection.LIGHTING] + assert lighting.kwh == _LIGHTING_KWH + assert abs(lighting.cost_gbp - expected_cost) <= 1e-9 diff --git a/tests/repositories/fuel_rates/test_static_file_fuel_rates_repository.py b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py similarity index 50% rename from tests/repositories/fuel_rates/test_static_file_fuel_rates_repository.py rename to tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py index 38d3a0a6..6b177296 100644 --- a/tests/repositories/fuel_rates/test_static_file_fuel_rates_repository.py +++ b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py @@ -3,14 +3,14 @@ from __future__ import annotations import pytest from domain.fuel_rates.fuel import Fuel, UnpricedFuel -from repositories.fuel_rates.static_file_fuel_rates_repository import ( - StaticFileFuelRatesRepository, +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, ) def test_get_current_loads_the_committed_snapshot_mains_gas_rate() -> None: # Arrange - repository = StaticFileFuelRatesRepository() + repository = FuelRatesStaticFileRepository() # Act rates = repository.get_current() @@ -21,25 +21,33 @@ def test_get_current_loads_the_committed_snapshot_mains_gas_rate() -> None: def test_snapshot_prices_metered_and_delivered_fuels_plus_seg() -> None: # Arrange - rates = StaticFileFuelRatesRepository().get_current() + rates = FuelRatesStaticFileRepository().get_current() # Act / Assert — electricity carries a daily standing charge; oil is # delivered (no meter) so its standing charge is 0; SEG is a flat credit. assert rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) == 24.67 assert rates.standing_charge_p_per_day(Fuel.ELECTRICITY) == 57.21 - assert rates.unit_rate_p_per_kwh(Fuel.OIL) == 9.16 + assert rates.unit_rate_p_per_kwh(Fuel.OIL) == 12.11 assert rates.standing_charge_p_per_day(Fuel.OIL) == 0.0 assert rates.seg_export_p_per_kwh == 15.0 -@pytest.mark.parametrize( - "fuel", [Fuel.HEAT_NETWORK, Fuel.COAL, Fuel.ELECTRICITY_OFF_PEAK] -) -def test_unpriced_fuels_raise_rather_than_defaulting(fuel: Fuel) -> None: - # Arrange — house coal + heat network have no national rate, and off-peak - # needs the day/night split a later slice adds (ADR-0014). - rates = StaticFileFuelRatesRepository().get_current() +def test_coal_and_heat_network_carry_proxy_rates() -> None: + # Arrange — house coal + heat network have no national rate, but the + # snapshot now prices them as documented proxies (see the JSON _note) so + # those certs model instead of erroring. + rates = FuelRatesStaticFileRepository().get_current() + + # Act / Assert + assert rates.unit_rate_p_per_kwh(Fuel.COAL) == 7.13 + assert rates.unit_rate_p_per_kwh(Fuel.HEAT_NETWORK) == 16.0 + assert rates.standing_charge_p_per_day(Fuel.HEAT_NETWORK) == 69.4 + + +def test_off_peak_remains_unpriced_pending_the_day_night_accessor() -> None: + # Arrange — off-peak still needs the day/night split a later slice adds (ADR-0014). + rates = FuelRatesStaticFileRepository().get_current() # Act / Assert with pytest.raises(UnpricedFuel): - rates.unit_rate_p_per_kwh(fuel) + rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY_OFF_PEAK) diff --git a/tests/repositories/geospatial/test_geospatial_repository.py b/tests/repositories/geospatial/test_geospatial_repository.py index 4b0834c9..a85bb468 100644 --- a/tests/repositories/geospatial/test_geospatial_repository.py +++ b/tests/repositories/geospatial/test_geospatial_repository.py @@ -13,7 +13,11 @@ from pathlib import Path import pandas as pd +from typing import Optional + from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference from repositories.geospatial.geospatial_s3_repository import GeospatialS3Repository @@ -35,6 +39,11 @@ def _write_open_uprn(base: Path) -> None: "UPRN": [12345, 12346], "LATITUDE": [51.5074, 51.6000], "LONGITUDE": [-0.1278, -0.2000], + # Planning flags co-located with the coordinates in the partition + # (legacy column names — confirm exact names in the S3 deep-dive). + "conservation_status": [True, False], + "is_listed_building": [False, True], + "is_heritage_building": [False, False], } ).to_parquet(spatial / "0_100000.parquet") @@ -69,3 +78,56 @@ def test_coordinates_for_returns_none_when_no_partition_covers_uprn( # Act / Assert — uprn beyond every partition's range assert repo.coordinates_for(500000) is None + + +def test_planning_restrictions_for_reads_the_co_located_flags(tmp_path: Path) -> None: + # Arrange — same partition, planning flags alongside the coordinates. + _write_open_uprn(tmp_path) + repo = GeospatialS3Repository(_reader(tmp_path)) + + # Act + restrictions = repo.planning_restrictions_for(12345) + + # Assert — the three flags come back as the Property's PlanningRestrictions. + assert restrictions == PlanningRestrictions( + in_conservation_area=True, is_listed=False, is_heritage=False + ) + + +def test_planning_restrictions_for_returns_none_when_uprn_absent( + tmp_path: Path, +) -> None: + # Arrange + _write_open_uprn(tmp_path) + repo = GeospatialS3Repository(_reader(tmp_path)) + + # Act / Assert + assert repo.planning_restrictions_for(99999) is None + + +def test_spatial_for_returns_coordinates_and_restrictions_together( + tmp_path: Path, +) -> None: + # Arrange — one partition row carries the coordinates and the planning flags. + _write_open_uprn(tmp_path) + repo = GeospatialS3Repository(_reader(tmp_path)) + + # Act — a single reference lookup yields both, so Ingestion reads the row once. + reference: Optional[SpatialReference] = repo.spatial_for(12346) + + # Assert + assert reference == SpatialReference( + coordinates=Coordinates(longitude=-0.2000, latitude=51.6000), + restrictions=PlanningRestrictions( + in_conservation_area=False, is_listed=True, is_heritage=False + ), + ) + + +def test_spatial_for_returns_none_when_uprn_absent(tmp_path: Path) -> None: + # Arrange + _write_open_uprn(tmp_path) + repo = GeospatialS3Repository(_reader(tmp_path)) + + # Act / Assert + assert repo.spatial_for(99999) is None diff --git a/tests/repositories/plan/__init__.py b/tests/repositories/plan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py new file mode 100644 index 00000000..3e428bd8 --- /dev/null +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -0,0 +1,177 @@ +"""Behaviour of the Postgres-backed PlanRepository: persisting a Plan and its +Plan Measures to the live ``plan`` / ``recommendation`` tables (ADR-0017). + +The Plan is the parent; each selected Plan Measure is a ``recommendation`` row +linked by the new ``plan_id`` FK. A re-run replaces (delete the Plan for the +(property, scenario) → cascade its recommendations → insert fresh), so the +batch write is idempotent (ADR-0012). CO₂ is stored in tonnes (calculator kg +÷ 1000) to match the live column contract. +""" + +from __future__ import annotations + +from sqlalchemy import Engine +from sqlmodel import Session, col, select + +from datatypes.epc.domain.epc import Epc +from domain.modelling.measure_type import MeasureType +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring.scoring import MeasureImpact +from infrastructure.postgres.modelling import PlanModel, RecommendationModel +from repositories.plan.plan_postgres_repository import PlanPostgresRepository + + +def _plan() -> Plan: + measures: tuple[PlanMeasure, ...] = ( + PlanMeasure( + measure_type=MeasureType.CAVITY_WALL_INSULATION, + description="Cavity wall insulation", + cost=Cost(total=1000.0, contingency_rate=0.10), + impact=MeasureImpact( + sap_points=8.0, + co2_savings_kg_per_yr=500.0, + energy_savings_kwh_per_yr=2000.0, + ), + kwh_savings=1500.0, + energy_cost_savings=300.0, + ), + ) + return Plan( + measures=measures, + baseline=Score( + sap_continuous=40.0, + co2_kg_per_yr=4000.0, + primary_energy_kwh_per_yr=20000.0, + ), + post_retrofit=Score( + sap_continuous=70.0, + co2_kg_per_yr=3500.0, + primary_energy_kwh_per_yr=18000.0, + ), + ) + + +def test_save_persists_plan_and_its_measures_with_tonnes_and_band( + db_engine: Engine, +) -> None: + # Act + with Session(db_engine) as session: + plan_id: int = PlanPostgresRepository(session).save( + _plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True + ) + session.commit() + + # Assert + with Session(db_engine) as session: + plan_row = session.get(PlanModel, plan_id) + rec_rows = session.exec( + select(RecommendationModel).where( + col(RecommendationModel.plan_id) == plan_id + ) + ).all() + + assert plan_row is not None + assert plan_row.property_id == 10 + assert plan_row.scenario_id == 7 + assert plan_row.portfolio_id == 1 + assert plan_row.is_default is True + assert plan_row.post_sap_points is not None + assert plan_row.post_co2_emissions is not None + assert plan_row.co2_savings is not None + assert plan_row.cost_of_works is not None + assert plan_row.contingency_cost is not None + assert abs(plan_row.post_sap_points - 70.0) <= 1e-9 + assert plan_row.post_epc_rating is Epc.C # SAP 70 → band C + assert abs(plan_row.post_co2_emissions - 3.5) <= 1e-9 # tonnes + assert abs(plan_row.co2_savings - 0.5) <= 1e-9 # (4000-3500)/1000 + assert abs(plan_row.cost_of_works - 1000.0) <= 1e-9 + assert abs(plan_row.contingency_cost - 100.0) <= 1e-9 # 1000 * 0.10 + + assert len(rec_rows) == 1 + rec = rec_rows[0] + assert rec.estimated_cost is not None + assert rec.sap_points is not None + assert rec.co2_equivalent_savings is not None + assert rec.type == "cavity_wall_insulation" + assert rec.measure_type == "cavity_wall_insulation" + assert rec.description == "Cavity wall insulation" + assert abs(rec.estimated_cost - 1000.0) <= 1e-9 + assert abs(rec.sap_points - 8.0) <= 1e-9 + assert abs(rec.co2_equivalent_savings - 0.5) <= 1e-9 # tonnes + assert rec.kwh_savings is not None + assert rec.energy_cost_savings is not None + assert abs(rec.kwh_savings - 1500.0) <= 1e-9 # delivered kWh saved/yr + assert abs(rec.energy_cost_savings - 300.0) <= 1e-9 # £/yr saved + assert rec.default is True + assert rec.already_installed is False + + +def test_save_persists_null_per_measure_savings_when_unbilled( + db_engine: Engine, +) -> None: + # Arrange — a Plan Measure whose per-measure bills were never derived. + measure = PlanMeasure( + measure_type=MeasureType.LOFT_INSULATION, + description="Loft insulation", + cost=Cost(total=500.0, contingency_rate=0.20), + impact=MeasureImpact( + sap_points=3.0, co2_savings_kg_per_yr=200.0, energy_savings_kwh_per_yr=800.0 + ), + ) + plan = Plan( + measures=(measure,), + baseline=Score( + sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0 + ), + post_retrofit=Score( + sap_continuous=45.0, co2_kg_per_yr=3800.0, primary_energy_kwh_per_yr=19000.0 + ), + ) + + # Act + with Session(db_engine) as session: + plan_id: int = PlanPostgresRepository(session).save( + plan, property_id=11, scenario_id=7, portfolio_id=1, is_default=True + ) + session.commit() + + # Assert — the savings columns persist as NULL (ADR-0014 amendment) + with Session(db_engine) as session: + rec_rows = session.exec( + select(RecommendationModel).where(col(RecommendationModel.plan_id) == plan_id) + ).all() + assert len(rec_rows) == 1 + assert rec_rows[0].kwh_savings is None + assert rec_rows[0].energy_cost_savings is None + + +def test_save_is_idempotent_on_rerun_for_the_same_property_and_scenario( + db_engine: Engine, +) -> None: + # Arrange — first run + with Session(db_engine) as session: + PlanPostgresRepository(session).save( + _plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True + ) + session.commit() + + # Act — re-run the same (property, scenario) + with Session(db_engine) as session: + PlanPostgresRepository(session).save( + _plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True + ) + session.commit() + + # Assert — replaced, not duplicated (cascade removed the old measures) + with Session(db_engine) as session: + plan_rows = session.exec( + select(PlanModel).where(col(PlanModel.property_id) == 10) + ).all() + rec_rows = session.exec( + select(RecommendationModel).where(col(RecommendationModel.property_id) == 10) + ).all() + + assert len(plan_rows) == 1 + assert len(rec_rows) == 1 diff --git a/tests/repositories/product/__init__.py b/tests/repositories/product/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/product/test_product_json_repository.py b/tests/repositories/product/test_product_json_repository.py new file mode 100644 index 00000000..30ebfc94 --- /dev/null +++ b/tests/repositories/product/test_product_json_repository.py @@ -0,0 +1,79 @@ +"""Behaviour of the JSON-backed ProductRepository: reading a Product from a +catalogue file — the stopgap source for costs the ETL does not yet supply, +behind the same port as the Postgres-backed catalogue. The per-measure-type +contingency is joined from config, not stored in the file. See CONTEXT.md +(Product, Cost, Contingency).""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from domain.modelling.product import Product +from repositories.product.product_json_repository import ProductJsonRepository + + +def _write_catalogue(tmp_path: Path, payload: dict[str, Any]) -> Path: + path: Path = tmp_path / "products.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_get_maps_a_json_entry_to_a_product_with_contingency( + tmp_path: Path, +) -> None: + # Arrange + catalogue: Path = _write_catalogue( + tmp_path, {"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}} + ) + + # Act + product: Product = ProductJsonRepository(catalogue).get( + "cavity_wall_insulation" + ) + + # Assert + assert product.measure_type == "cavity_wall_insulation" + assert abs(product.unit_cost_per_m2 - 18.5) <= 1e-9 + assert abs(product.contingency_rate - 0.10) <= 1e-9 + + +def test_get_raises_when_measure_type_absent(tmp_path: Path) -> None: + # Arrange + catalogue: Path = _write_catalogue( + tmp_path, {"loft_insulation": {"unit_cost_per_m2": 22.0}} + ) + + # Act / Assert + with pytest.raises(ValueError): + ProductJsonRepository(catalogue).get("cavity_wall_insulation") + + +def test_get_joins_the_secondary_heating_removal_contingency(tmp_path: Path) -> None: + # Arrange — a flat per-dwelling decommission price for the removal measure + # (ADR-0028); the 0.25 contingency is joined from config, not the file. + catalogue: Path = _write_catalogue( + tmp_path, {"secondary_heating_removal": {"unit_cost_per_m2": 250.0}} + ) + + # Act + product: Product = ProductJsonRepository(catalogue).get( + "secondary_heating_removal" + ) + + # Assert + assert product.measure_type == "secondary_heating_removal" + assert abs(product.unit_cost_per_m2 - 250.0) <= 1e-9 + assert abs(product.contingency_rate - 0.25) <= 1e-9 + + +def test_get_raises_when_entry_lacks_unit_cost(tmp_path: Path) -> None: + # Arrange + catalogue: Path = _write_catalogue( + tmp_path, {"cavity_wall_insulation": {"cost_unit": "gbp_per_m2"}} + ) + + # Act / Assert + with pytest.raises(ValueError): + ProductJsonRepository(catalogue).get("cavity_wall_insulation") diff --git a/tests/repositories/product/test_product_postgres_repository.py b/tests/repositories/product/test_product_postgres_repository.py new file mode 100644 index 00000000..7a0c84ec --- /dev/null +++ b/tests/repositories/product/test_product_postgres_repository.py @@ -0,0 +1,100 @@ +"""Behaviour of the Postgres-backed ProductRepository: mapping a row of the +materials catalogue into a Product, with the per-measure-type contingency +joined on. See CONTEXT.md (Product, Cost, Contingency).""" + +import pytest +from sqlalchemy import Engine +from sqlmodel import Session + +from infrastructure.postgres.product_table import MaterialRow +from repositories.product.product_postgres_repository import ( + ProductPostgresRepository, +) +from domain.modelling.product import Product + + +def test_get_maps_active_material_to_product_with_contingency( + db_engine: Engine, +) -> None: + # Arrange + with Session(db_engine) as session: + session.add( + MaterialRow( + id=1, + type="cavity_wall_insulation", + total_cost=18.5, # fully-loaded GBP per m^2 + cost_unit="gbp_per_m2", + is_active=True, + description="Cavity wall insulation", + ) + ) + session.commit() + + # Act + with Session(db_engine) as session: + product: Product = ProductPostgresRepository(session).get( + "cavity_wall_insulation" + ) + + # Assert + assert product.measure_type == "cavity_wall_insulation" + assert abs(product.unit_cost_per_m2 - 18.5) <= 1e-9 + assert abs(product.contingency_rate - 0.10) <= 1e-9 + + +def test_get_picks_the_lowest_id_when_several_active_rows_share_a_type( + db_engine: Engine, +) -> None: + # Arrange — the live catalogue holds many active rows per type (e.g. 74 + # solar_pv); the choice must be deterministic so a re-run prices the same. + with Session(db_engine) as session: + session.add_all( + [ + MaterialRow( + id=7, + type="solar_pv", + total_cost=99.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Solar PV (higher id)", + ), + MaterialRow( + id=3, + type="solar_pv", + total_cost=42.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Solar PV (lowest id)", + ), + ] + ) + session.commit() + + # Act + with Session(db_engine) as session: + product: Product = ProductPostgresRepository(session).get("solar_pv") + + # Assert — the lowest-id active row wins, deterministically. + assert product.id == 3 + assert abs(product.unit_cost_per_m2 - 42.0) <= 1e-9 + + +def test_get_raises_when_only_an_inactive_product_exists(db_engine: Engine) -> None: + # Arrange + with Session(db_engine) as session: + session.add( + MaterialRow( + id=1, + type="cavity_wall_insulation", + total_cost=18.5, + cost_unit="gbp_per_m2", + is_active=False, + description="Cavity wall insulation (retired)", + ) + ) + session.commit() + + # Act / Assert + with Session(db_engine) as session: + with pytest.raises(ValueError): + ProductPostgresRepository(session).get("cavity_wall_insulation") diff --git a/tests/repositories/property/test_property_repository.py b/tests/repositories/property/test_property_repository.py index 2456a670..c075964f 100644 --- a/tests/repositories/property/test_property_repository.py +++ b/tests/repositories/property/test_property_repository.py @@ -10,11 +10,14 @@ from sqlalchemy import Engine from sqlmodel import Session from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference from infrastructure.postgres.property_table import PropertyRow from repositories.epc.epc_postgres_repository import EpcPostgresRepository from repositories.property.property_postgres_repository import ( PropertyPostgresRepository, ) +from repositories.spatial.spatial_postgres_repository import SpatialPostgresRepository _JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples" @@ -38,7 +41,9 @@ def test_get_hydrates_identity_and_epc_slice(db_engine: Engine) -> None: # Act with Session(db_engine) as session: - repo = PropertyPostgresRepository(session, EpcPostgresRepository(session)) + repo = PropertyPostgresRepository( + session, EpcPostgresRepository(session), SpatialPostgresRepository(session) + ) prop = repo.get(property_id) # Assert @@ -47,3 +52,62 @@ def test_get_hydrates_identity_and_epc_slice(db_engine: Engine) -> None: assert prop.epc == epc assert prop.source_path == "epc_with_overlay" assert prop.effective_epc == epc + + +def test_get_many_hydrates_planning_restrictions_from_the_spatial_cache( + db_engine: Engine, +) -> None: + # Arrange — a property whose UPRN has a cached listed-building flag. + with Session(db_engine) as session: + row = PropertyRow( + portfolio_id=7, postcode="A0 0AA", address="1 Some Street", uprn=12345 + ) + session.add(row) + session.commit() + property_id = row.id + assert property_id is not None + SpatialPostgresRepository(session).save( + uprn=12345, + reference=SpatialReference( + coordinates=None, + restrictions=PlanningRestrictions(is_listed=True), + ), + ) + session.commit() + + # Act + with Session(db_engine) as session: + repo = PropertyPostgresRepository( + session, EpcPostgresRepository(session), SpatialPostgresRepository(session) + ) + properties = repo.get_many([property_id]) + + # Assert — the protections are hydrated onto the Property (ADR-0020). + assert properties.items[0].planning_restrictions == PlanningRestrictions( + is_listed=True + ) + + +def test_get_many_defaults_to_unrestricted_when_uprn_has_no_spatial_row( + db_engine: Engine, +) -> None: + # Arrange — a property whose UPRN is not in the spatial cache. + with Session(db_engine) as session: + row = PropertyRow( + portfolio_id=7, postcode="A0 0AA", address="1 Some Street", uprn=999 + ) + session.add(row) + session.commit() + property_id = row.id + assert property_id is not None + + # Act + with Session(db_engine) as session: + repo = PropertyPostgresRepository( + session, EpcPostgresRepository(session), SpatialPostgresRepository(session) + ) + properties = repo.get_many([property_id]) + + # Assert — an uncovered UPRN means unrestricted, not blocked (per legacy + # `empty_spatial_df`; ADR-0020). + assert properties.items[0].planning_restrictions == PlanningRestrictions() diff --git a/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py index 6395d0f9..de6de8f4 100644 --- a/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py +++ b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py @@ -4,6 +4,7 @@ from sqlalchemy import Engine from sqlmodel import Session 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.performance import Performance from repositories.property_baseline.property_baseline_postgres_repository import ( @@ -89,3 +90,65 @@ def test_get_for_property_returns_none_when_absent(db_engine: Engine) -> None: # Assert assert loaded is None + + +def _baseline_with_bill() -> PropertyBaselinePerformance: + lodged = Performance( + sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180 + ) + # A bill with two sections present (HEATING + LIGHTING) and the rest absent — + # proves the per-section flattening and the absent-section None round-trip. + bill = Bill( + sections={ + BillSection.HEATING: BillSectionCost(kwh=8000.0, cost_gbp=459.2), + BillSection.LIGHTING: BillSectionCost(kwh=400.0, cost_gbp=98.68), + }, + standing_charges_gbp=314.18, + seg_credit_gbp=12.5, + total_gbp=859.56, + ) + return PropertyBaselinePerformance( + lodged=lodged, + effective=lodged, + rebaseline_reason="none", + space_heating_kwh=5000.0, + water_heating_kwh=2000.0, + bill=bill, + ) + + +def test_baseline_with_a_bill_round_trips(db_engine: Engine) -> None: + # Arrange + baseline = _baseline_with_bill() + with Session(db_engine) as session: + PropertyBaselinePostgresRepository(session).save(baseline, property_id=11) + session.commit() + + # Act + with Session(db_engine) as session: + loaded = PropertyBaselinePostgresRepository(session).get_for_property(11) + + # Assert — the bill survives with its section costs intact; absent sections + # stay absent (not zero). + assert loaded == baseline + assert loaded is not None + assert loaded.bill is not None + assert set(loaded.bill.sections) == {BillSection.HEATING, BillSection.LIGHTING} + + +def test_baseline_without_a_bill_round_trips_as_none(db_engine: Engine) -> None: + # Arrange — the stub path persists no bill. + baseline = _baseline() + assert baseline.bill is None + with Session(db_engine) as session: + PropertyBaselinePostgresRepository(session).save(baseline, property_id=12) + session.commit() + + # Act + with Session(db_engine) as session: + loaded = PropertyBaselinePostgresRepository(session).get_for_property(12) + + # Assert + assert loaded == baseline + assert loaded is not None + assert loaded.bill is None diff --git a/tests/repositories/scenario/__init__.py b/tests/repositories/scenario/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py new file mode 100644 index 00000000..8e0df21c --- /dev/null +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -0,0 +1,86 @@ +"""Behaviour of the Postgres-backed ScenarioRepository: reading the Scenarios +the Modelling stage scores a Property against, off the live ``scenario`` table. + +The FE creates a Scenario in the scenario-builder and passes its id to the +pipeline (#1130); the orchestrator reads it back here at modelling time. Only +the fields modelling uses are mapped — goal / goal_value / budget / is_default; +the legacy file-path columns are ignored. See CONTEXT.md (Scenario) and +ADR-0017. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import Engine +from sqlmodel import Session + +from domain.modelling.portfolio_goal import PortfolioGoal +from domain.modelling.scenario import Scenario +from infrastructure.postgres.modelling import ScenarioModel +from repositories.scenario.scenario_postgres_repository import ( + ScenarioPostgresRepository, +) + + +def test_get_many_maps_live_scenario_rows_to_domain_in_input_order( + db_engine: Engine, +) -> None: + # Arrange + with Session(db_engine) as session: + session.add( + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + budget=15000.0, + is_default=True, + ) + ) + session.add( + ScenarioModel( + id=9, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="B", + budget=None, + is_default=False, + ) + ) + session.commit() + + # Act + with Session(db_engine) as session: + scenarios: list[Scenario] = ScenarioPostgresRepository(session).get_many( + [9, 7] + ) + + # Assert — to_domain maps the PortfolioGoal enum to its value string + assert [s.id for s in scenarios] == [9, 7] # input order preserved + assert scenarios[0] == Scenario( + id=9, goal="Increasing EPC", goal_value="B", budget=None, is_default=False + ) + assert scenarios[1] == Scenario( + id=7, + goal="Increasing EPC", + goal_value="C", + budget=15000.0, + is_default=True, + ) + + +def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None: + # Arrange + with Session(db_engine) as session: + session.add( + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + is_default=True, + ) + ) + session.commit() + + # Act / Assert + with Session(db_engine) as session: + with pytest.raises(ValueError): + ScenarioPostgresRepository(session).get_many([7, 404]) diff --git a/tests/repositories/spatial/__init__.py b/tests/repositories/spatial/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/spatial/test_spatial_repository.py b/tests/repositories/spatial/test_spatial_repository.py new file mode 100644 index 00000000..ee7ce728 --- /dev/null +++ b/tests/repositories/spatial/test_spatial_repository.py @@ -0,0 +1,71 @@ +"""SpatialRepo caches the OS spatial reference (coords + planning flags) by UPRN. + +The OS Open-UPRN reference set is too large to host in Postgres, so Ingestion +resolves it from S3 and writes a per-UPRN cache row here; Modelling reads the +planning protections back off it (ADR-0020). A real ephemeral Postgres exercises +the upsert-by-UPRN semantics (one shared row per UPRN). +""" + +from __future__ import annotations + +from sqlalchemy import Engine +from sqlmodel import Session + +from domain.geospatial.coordinates import Coordinates +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.geospatial.spatial_reference import SpatialReference +from repositories.spatial.spatial_postgres_repository import SpatialPostgresRepository + + +def test_planning_restrictions_round_trip_by_uprn(db_engine: Engine) -> None: + # Arrange + reference = SpatialReference( + coordinates=Coordinates(longitude=-0.1278, latitude=51.5074), + restrictions=PlanningRestrictions( + in_conservation_area=True, is_listed=False, is_heritage=False + ), + ) + + # Act + with Session(db_engine) as session: + SpatialPostgresRepository(session).save(uprn=12345, reference=reference) + session.commit() + with Session(db_engine) as session: + reloaded = SpatialPostgresRepository(session).get_for_uprns([12345]) + + # Assert + assert reloaded == {12345: reference.restrictions} + + +def test_save_upserts_the_shared_uprn_row(db_engine: Engine) -> None: + # Arrange — the same UPRN re-ingested with corrected flags. + unprotected = SpatialReference( + coordinates=Coordinates(longitude=-0.1, latitude=51.5), + restrictions=PlanningRestrictions(), + ) + listed = SpatialReference( + coordinates=Coordinates(longitude=-0.1, latitude=51.5), + restrictions=PlanningRestrictions(is_listed=True), + ) + + # Act + with Session(db_engine) as session: + repo = SpatialPostgresRepository(session) + repo.save(uprn=999, reference=unprotected) + repo.save(uprn=999, reference=listed) + session.commit() + with Session(db_engine) as session: + reloaded = SpatialPostgresRepository(session).get_for_uprns([999]) + + # Assert — one row per UPRN; the latest write wins. + assert reloaded == {999: PlanningRestrictions(is_listed=True)} + + +def test_get_for_uprns_omits_uncovered_uprns(db_engine: Engine) -> None: + # Arrange / Act — nothing stored for this UPRN. + with Session(db_engine) as session: + reloaded = SpatialPostgresRepository(session).get_for_uprns([404]) + + # Assert — absent UPRNs are simply not in the map (caller defaults them to + # unrestricted). + assert reloaded == {} diff --git a/tests/repositories/test_unit_of_work.py b/tests/repositories/test_unit_of_work.py index 03018562..5008d4c6 100644 --- a/tests/repositories/test_unit_of_work.py +++ b/tests/repositories/test_unit_of_work.py @@ -9,7 +9,11 @@ from sqlmodel import Session from datatypes.epc.domain.epc import Epc from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property_baseline.performance import Performance +from repositories.plan.plan_repository import PlanRepository from repositories.postgres_unit_of_work import PostgresUnitOfWork +from repositories.product.product_repository import ProductRepository +from repositories.scenario.scenario_repository import ScenarioRepository +from repositories.spatial.spatial_repository import SpatialRepository def _session_factory(db_engine: Engine) -> Callable[[], Session]: @@ -60,6 +64,28 @@ def test_an_exception_in_the_block_rolls_the_batch_back(db_engine: Engine) -> No assert uow.property_baseline.get_for_property(10) is None +def test_unit_exposes_the_modelling_repos_bound_to_its_session( + db_engine: Engine, +) -> None: + # Arrange / Act + with PostgresUnitOfWork(_session_factory(db_engine)) as uow: + # Assert — the Modelling stage reads Scenario + Product and writes Plan + # through the same unit (ADR-0017). + assert isinstance(uow.scenario, ScenarioRepository) + assert isinstance(uow.product, ProductRepository) + assert isinstance(uow.plan, PlanRepository) + + +def test_unit_exposes_the_spatial_cache_repo_bound_to_its_session( + db_engine: Engine, +) -> None: + # Arrange / Act + with PostgresUnitOfWork(_session_factory(db_engine)) as uow: + # Assert — Ingestion writes the OS spatial reference cache through the + # same unit it persists the EPC/solar with (ADR-0020). + assert isinstance(uow.spatial, SpatialRepository) + + def test_leaving_the_block_without_commit_persists_nothing(db_engine: Engine) -> None: # Arrange new_unit = lambda: PostgresUnitOfWork(_session_factory(db_engine))