From b24e4d46e4463c496334819c640b07315bb4178a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 14:03:34 +0000 Subject: [PATCH 001/190] refactor(baseline): clearer divergence-threshold constant names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: the threshold constants were obscure. Rename to state intent — _SAP10_2_FLOOR -> _MIN_TRUSTED_SAP_VERSION, _SAP_ABS_TOL -> _MAX_SAP_SCORE_DIVERGENCE, _REL_TOL -> _MAX_RELATIVE_DIVERGENCE — matching the existing _log_divergence vocabulary, and fold the rationale into the comments: the calculator emits a continuous SAP score vs the lodged rounded integer, so a gap up to 0.5 is rounding, beyond it a genuine disagreement worth recording; CO2/PEUI are not rounded so they get a 1% relative band. Behaviour unchanged. Co-Authored-By: Claude Opus 4.8 --- .../calculator_rebaseliner.py | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/domain/property_baseline/calculator_rebaseliner.py b/domain/property_baseline/calculator_rebaseliner.py index 184f56b0..9c412c4e 100644 --- a/domain/property_baseline/calculator_rebaseliner.py +++ b/domain/property_baseline/calculator_rebaseliner.py @@ -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 @@ -49,7 +56,7 @@ class CalculatorRebaseliner(Rebaseliner): # load-bearing, so the batch aborts and the cert is fixed at once. 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: + if sap_version is not None and sap_version < _MIN_TRUSTED_SAP_VERSION: return Performance.from_sap_result(result), "pre_sap10" self._log_divergence( property_id=property_id, sap_version=sap_version, result=result, lodged=lodged @@ -64,15 +71,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( From c431453d75fa5374cbab183b9816cb0728195bca Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 14:05:57 +0000 Subject: [PATCH 002/190] refactor(fuel-rates): name the adapter aggregate-first per house convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: adapters here are __repository (e.g. property_baseline_postgres_repository). Rename the fuel-rates adapter to match — file static_file_fuel_rates_repository.py -> fuel_rates_static_file_repository.py and class StaticFileFuelRatesRepository -> FuelRatesStaticFileRepository, plus its test. git mv preserves history. Co-Authored-By: Claude Opus 4.8 --- ...ository.py => fuel_rates_static_file_repository.py} | 2 +- ...ry.py => test_fuel_rates_static_file_repository.py} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename repositories/fuel_rates/{static_file_fuel_rates_repository.py => fuel_rates_static_file_repository.py} (96%) rename tests/repositories/fuel_rates/{test_static_file_fuel_rates_repository.py => test_fuel_rates_static_file_repository.py} (83%) 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/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 83% 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..a129daf2 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,7 +21,7 @@ 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. @@ -38,7 +38,7 @@ def test_snapshot_prices_metered_and_delivered_fuels_plus_seg() -> None: 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() + rates = FuelRatesStaticFileRepository().get_current() # Act / Assert with pytest.raises(UnpricedFuel): From 19a56461ba76737e3947e5cf67c44d02123efeea Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:04:55 +0000 Subject: [PATCH 003/190] =?UTF-8?q?docs(baseline):=20Bill=20Derivation=20d?= =?UTF-8?q?esign=20=E2=80=94=20fuel=20as=20calculator=20output=20+=20rebas?= =?UTF-8?q?elining=20is=20assemble-and-score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures a /grill-with-docs session resolving how BillDerivation gets the fuel each end use burns, and what Rebaselining actually is. - ADR-0014 amendment: per-end-use fuel is a calculator OUTPUT (resolved Table-32 codes on SapResult: main-1/main-2/secondary/HW + pv_exported_kwh); the adapter is a pure SapResult->EnergyBreakdown map. Corrects stale §3 (is_gas_code... -> sap_fuel.sap_code_to_fuel). Adds COOLING section. Interim, pending ADR-0015. - ADR-0013 amendment: the calculator is the SCORING ENGINE within Rebaselining (assemble the Effective EPC picture, then score), not the whole of it; the Rebaseliner exposes its SapResult so the orchestrator composes Effective Performance AND the Bill from one scoring. - ADR-0015 (new): mappers own cert normalization; EpcPropertyData becomes a strict type. Explains why fuel resolution sits in the calculator today. - CONTEXT.md: Effective EPC = the assembled picture; Rebaselining = assemble (overrides / neighbour-estimation / old-schema remap) then score. - EpcPropertyData docstring points at ADR-0015. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 6 +- datatypes/epc/domain/epc_property_data.py | 10 +++ ...uces-effective-performance-shadow-first.md | 23 +++++++ ...14-bill-derivation-from-real-fuel-rates.md | 31 +++++++++ .../0015-mappers-own-cert-normalization.md | 66 +++++++++++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0015-mappers-own-cert-normalization.md diff --git a/CONTEXT.md b/CONTEXT.md index 3580b93e..f3ffd4fa 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -78,15 +78,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**: diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 1048bed2..6cae0b52 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -566,6 +566,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/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..e10d1f32 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,34 @@ 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 **resolved Table-32 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. + `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). 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. From 4e9ff7c3cbd9da154e3a451d849bbfe73b1828f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:16:40 +0000 Subject: [PATCH 004/190] feat(calculator): thread per-end-use fuel codes + PV export onto SapResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER / SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV export. SapResult already carried the per-end-use kWh but not WHICH fuel each end-use burns, nor the annual exported kWh — so a downstream SapResult->EnergyBreakdown adapter could not pick the right tariff. Surfaces five output-only fields, threaded exactly like the recently merged appliances/cooking change (2f039aeb): main_heating_fuel_code RdSAP10 Table 32 / SAP 10.2 Table 12 fuel main_2_heating_fuel_code code column (the lodged fuel code, e.g. secondary_heating_fuel_code mains gas 26). None when the corresponding hot_water_fuel_code system is absent / fuel not resolvable. pv_exported_kwh_per_yr SAP 10.2 Appendix M1 §3-4 annual export kWh (0.0 when no PV). cert_to_inputs.py populates the four fuel codes from the existing resolvers the cost/CO2 cascade already uses — `_main_fuel_code`, `_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented); Main 2 is the second `main_heating_details` entry, guarded for length. There is a single CalculatorInputs construction site (cert_to_demand_ inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already existed on CalculatorInputs; SapResult collapses its Optional to 0.0. HARD CONSTRAINT honoured — output-only, zero rating drift. These fields do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_* / sap_score / any monthly value. Every golden-fixture, Elmhurst e2e SapResult pin, section cascade pin, and heating-corpus residual stays byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests), 4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in domain/; no new errors in the touched test files). New tests: a synthetic threading test (four fuel codes + PV export pass unchanged through calculate_sap_from_inputs; None PV collapses to 0.0) and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26, no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult fixtures updated for the new SapResult fields (defaults cover Inputs). Co-Authored-By: Claude Opus 4.8 --- domain/sap10_calculator/calculator.py | 31 +++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 19 +++++++ .../test_calculator_rebaseliner.py | 5 ++ .../sap10_calculator/test_calculator.py | 51 +++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 26 ++++++++++ 5 files changed, 132 insertions(+) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 364ad23d..a0764b60 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -325,6 +325,18 @@ class CalculatorInputs: # this field. cert_to_inputs sets this via `additional_standing_ # charges_gbp(main_fuel_code, water_heating_fuel_code, tariff)`. standing_charges_gbp: float = 0.0 + # Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel + # code column) for ADR-0014 BillDerivation fuel attribution. Output- + # only — these do NOT feed ECF / cost / CO2 / primary energy / + # sap_score (the rating cascade already prices each end-use via the + # per-end-use cost/CO2/PE factor fields above). They tell the bill + # adapter WHICH fuel carrier each end-use burns. None when the + # corresponding system is absent (no main / no 2nd main / no + # secondary) or the water-heating fuel is not resolvable. + main_heating_fuel_code: Optional[int] = None + main_2_heating_fuel_code: Optional[int] = None + secondary_heating_fuel_code: Optional[int] = None + hot_water_fuel_code: Optional[int] = None @dataclass(frozen=True) @@ -374,6 +386,20 @@ class SapResult: # gas-cooker split, if ever needed, is a separate follow-up). appliances_kwh_per_yr: float cooking_kwh_per_yr: float + # Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel + # code column) + annual PV export for ADR-0014 BillDerivation. Output- + # only metadata — these do NOT contribute to ecf / total_fuel_cost_gbp + # / co2_kg_per_yr / primary_energy_kwh_per_yr / sap_score. They tell + # the bill adapter WHICH fuel carrier each end-use burns; the fuel + # codes are None when the corresponding system is absent or the water- + # heating fuel is not resolvable. `pv_exported_kwh_per_yr` is the + # annual kWh exported to the grid (SAP 10.2 Appendix M1 §3-4 split), + # 0.0 when there is no PV. + main_heating_fuel_code: Optional[int] + main_2_heating_fuel_code: Optional[int] + secondary_heating_fuel_code: Optional[int] + hot_water_fuel_code: Optional[int] + pv_exported_kwh_per_yr: float primary_energy_kwh_per_yr: float primary_energy_kwh_per_m2: float monthly: tuple[MonthlyEntry, ...] @@ -764,6 +790,11 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: lighting_kwh_per_yr=inputs.lighting_kwh_per_yr, appliances_kwh_per_yr=inputs.appliances_kwh_per_yr, cooking_kwh_per_yr=inputs.cooking_kwh_per_yr, + main_heating_fuel_code=inputs.main_heating_fuel_code, + main_2_heating_fuel_code=inputs.main_2_heating_fuel_code, + secondary_heating_fuel_code=inputs.secondary_heating_fuel_code, + hot_water_fuel_code=inputs.hot_water_fuel_code, + pv_exported_kwh_per_yr=inputs.pv_exported_kwh_per_yr or 0.0, primary_energy_kwh_per_yr=primary_energy_kwh, primary_energy_kwh_per_m2=primary_energy_per_m2, monthly=monthly, diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5e3f5a77..f60c688e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6170,6 +6170,25 @@ def cert_to_inputs( # E_cook = 138 + 28×N, already summed in `cooking_monthly_kwh`. appliances_kwh_per_yr=sum(appliances_monthly_kwh), cooking_kwh_per_yr=sum(cooking_monthly_kwh), + # Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel + # code column) for ADR-0014 BillDerivation fuel attribution. + # Output-only — they tell the bill adapter WHICH carrier each end- + # use burns and do NOT feed cost / CO2 / PE / sap_score (those are + # already priced via the per-end-use factor fields below). Resolved + # via the same helpers the cost/CO2 cascade uses: `_main_fuel_code` + # (None when no main system), `_secondary_fuel_code`, and + # `_water_heating_fuel_code` (None when the WHC fuel is not + # resolvable). Main 2 is the second `main_heating_details` entry, + # if any (None when the cert has a single main system). + main_heating_fuel_code=_main_fuel_code(main), + main_2_heating_fuel_code=_main_fuel_code( + epc.sap_heating.main_heating_details[1] + if epc.sap_heating + and len(epc.sap_heating.main_heating_details) > 1 + else None + ), + secondary_heating_fuel_code=_secondary_fuel_code(epc), + hot_water_fuel_code=_water_heating_fuel_code(epc), space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh( main, _rdsap_tariff(epc), prices ), diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py index b20408a5..e77ee6da 100644 --- a/tests/domain/property_baseline/test_calculator_rebaseliner.py +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -49,6 +49,11 @@ def _sap_result( lighting_kwh_per_yr=0.0, 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=primary_energy_kwh_per_m2, monthly=(), diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index 37e56d16..dba25409 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -131,6 +131,57 @@ def _baseline_inputs() -> CalculatorInputs: ) +def test_fuel_codes_and_pv_export_thread_unchanged_onto_sap_result() -> None: + """Per-end-use fuel codes + PV export reach SapResult untouched. + + ADR-0014 BillDerivation attributes each end-use to a fuel carrier, so + the per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel + code column) and the annual PV export kWh must surface on SapResult. + These are output-only metadata — they must thread byte-identical from + CalculatorInputs through `calculate_sap_from_inputs` onto SapResult and + NOT be recomputed or perturbed. `pv_exported_kwh_per_yr` collapses a + None CalculatorInputs value to 0.0. + """ + # Arrange — set the four fuel codes + PV export to distinct known + # values on the baseline. Mains gas (1) main, LPG (2) main-2, standard + # electricity (30) secondary, mains gas (1) hot water. + inputs = replace( + _baseline_inputs(), + main_heating_fuel_code=1, + main_2_heating_fuel_code=2, + secondary_heating_fuel_code=30, + hot_water_fuel_code=1, + pv_exported_kwh_per_yr=850.0, + ) + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert — threaded unchanged; PV export carried through. + assert result.main_heating_fuel_code == 1 + assert result.main_2_heating_fuel_code == 2 + assert result.secondary_heating_fuel_code == 30 + assert result.hot_water_fuel_code == 1 + assert abs(result.pv_exported_kwh_per_yr - 850.0) <= 1e-9 + + +def test_pv_export_collapses_none_input_to_zero_on_sap_result() -> None: + """`pv_exported_kwh_per_yr` is 0.0 (not None) on SapResult for no-PV. + + CalculatorInputs.pv_exported_kwh_per_yr is Optional[float] (None on + certs without a PV split); SapResult.pv_exported_kwh_per_yr is a plain + float, so the assembly collapses None to 0.0 for the bill adapter. + """ + # Arrange — baseline has no PV split (pv_exported_kwh_per_yr defaults None). + inputs = replace(_baseline_inputs(), pv_exported_kwh_per_yr=None) + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert + assert result.pv_exported_kwh_per_yr == 0.0 + + def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() -> None: # Arrange — replace the baseline inputs' solar with an explicit known # 12-tuple. The §6 orchestrator produces this upstream; the calculator diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 69ad44ba..05bdecbe 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -256,3 +256,29 @@ def test_appliances_and_cooking_kwh_threaded_onto_sap_result() -> None: assert result.appliances_kwh_per_yr == inputs.appliances_kwh_per_yr assert result.cooking_kwh_per_yr == inputs.cooking_kwh_per_yr assert abs(result.cooking_kwh_per_yr - expected_cooking_kwh) <= 1e-9 + + +def test_main_heating_fuel_code_threaded_onto_sap_result_for_mains_gas_cert() -> None: + """Per-end-use fuel codes reach SapResult for a real mains-gas cert. + + ADR-0014 BillDerivation attributes each end-use to a fuel carrier. + Cert 000516 is a mains-gas combi (RdSAP10 Table 32 / SAP 10.2 Table 12 + mains-gas fuel code 26 as lodged), so the cascade must surface fuel + code 26 on `SapResult.main_heating_fuel_code` and thread it unchanged + from CalculatorInputs. Output-only metadata — it does NOT feed + cost / CO2 / PE / sap_score (those are pinned elsewhere in this file). + """ + # Arrange — a mains-gas combi cert. + epc = _FIXTURE_MODULES['000516'].build_epc() + + # Act + inputs = cert_to_inputs(epc) + result = Sap10Calculator().calculate(epc) + + # Assert — mains-gas main fuel code threaded unchanged; single main + # system (no Main 2); secondary defaults to standard electricity (30). + assert inputs.main_heating_fuel_code == 26 + assert result.main_heating_fuel_code == 26 + assert result.main_2_heating_fuel_code is None + assert result.secondary_heating_fuel_code == 30 + assert result.hot_water_fuel_code == 26 From 2cb4dd5833695093c86053793815a43ce3d60c57 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:24:39 +0000 Subject: [PATCH 005/190] feat(baseline): sap_code_to_fuel normalizes via the calculator's own helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuel codes the calculator now puts on SapResult are its own codes — raw gov-API enums or already-Table-32, depending on the source mapper (ADR-0015). sap_code_to_fuel now runs the code through table_32.to_table_32_code (promoted from private _to_table_32_code) — T32-first, then API-translate, the SAME normalization the calculator's pricing/CO2 helpers use — before the Table-32 -> Fuel dispatch, so the bill's carrier matches what the calculator billed (incl. the API/T32 collision codes, e.g. 20 = wood-logs not heat-net). Falls back to the raw code for billing fuels the price table omits (the 41-58 heat-network range), which resolve to HEAT_NETWORK -> UnpricedFuel — stricter than, and intentionally divergent from, the calculator's lossy default-to-mains-gas for an unpriced code (ADR-0014 §5). Co-Authored-By: Claude Opus 4.8 --- domain/property_baseline/sap_fuel.py | 29 ++++++++++++++----- domain/sap10_calculator/tables/table_32.py | 10 +++---- .../domain/property_baseline/test_sap_fuel.py | 16 ++++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/domain/property_baseline/sap_fuel.py b/domain/property_baseline/sap_fuel.py index cd7c6efc..b0523a2f 100644 --- a/domain/property_baseline/sap_fuel.py +++ b/domain/property_baseline/sap_fuel.py @@ -4,13 +4,13 @@ from typing import Final from domain.fuel_rates.fuel import Fuel from domain.sap10_calculator.exceptions import UnmappedSapCode +from domain.sap10_calculator.tables.table_32 import to_table_32_code -# SAP 10.2 / Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to -# the ~47 Table 32 fuel codes (the keys of `table_12.UNIT_PRICE_P_PER_KWH`) — the -# carrier, NOT the PCDB product, so a thousand PCDB heat pumps all share one code. -# Input is a normalised Table 32 fuel code (the calculator sets `main_fuel_type` -# to Table 32 codes); an unmapped code raises `UnmappedSapCode` rather than -# guessing — a bounded, self-surfacing backlog [[reference-unmapped-sap-code]]. +# Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to the ~47 +# Table 32 fuel codes (the keys of `UNIT_PRICE_P_PER_KWH`) — the carrier, NOT the +# PCDB product, so a thousand PCDB heat pumps all share one code. An unmapped code +# raises `UnmappedSapCode` rather than guessing — a bounded, self-surfacing +# backlog [[reference-unmapped-sap-code]]. _CODE_TO_FUEL: Final[dict[int, Fuel]] = { **dict.fromkeys([1, 7], Fuel.MAINS_GAS), # mains gas, grid biogas **dict.fromkeys([2, 3, 5, 9], Fuel.LPG), @@ -29,13 +29,26 @@ _CODE_TO_FUEL: Final[dict[int, Fuel]] = { def sap_code_to_fuel(code: int) -> Fuel: - """Map a SAP 10.2 / Table 32 fuel code to its canonical billing Fuel. + """Map one of the calculator's per-end-use fuel codes to its billing Fuel. + + The code may be a raw gov-API `main_fuel_type` enum or an already-Table-32 + code depending on the source mapper (until [[adr-0015]] normalizes the cert), + so it is first run through the calculator's own ``to_table_32_code`` — + T32-first, then API-translate — the **same** normalization the calculator's + pricing/CO2 helpers use, so the bill's carrier matches what the calculator + billed. The normalized Table-32 code is then dispatched to a billing Fuel. Raises ``UnmappedSapCode`` on a code with no single billing carrier — e.g. dual fuel (10) or the grid-export codes (36/60), which are not an end use's input fuel. """ - fuel = _CODE_TO_FUEL.get(code) + # Normalize to a Table-32 code; fall back to the raw code for billing fuels + # the price table does not carry (the 41-58 heat-network range — `to_table_32_ + # code` returns None there, but they still resolve to HEAT_NETWORK and so to + # UnpricedFuel, which is stricter — and correct — than the calculator's + # lossy default-to-mains-gas for an unpriced code). + normalized = to_table_32_code(code) + fuel = _CODE_TO_FUEL.get(normalized if normalized is not None else code) if fuel is None: raise UnmappedSapCode("fuel_code", code) return fuel diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 398603f7..955bad9c 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -194,7 +194,7 @@ _OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = { } -def _to_table_32_code(fuel_code: Optional[int]) -> Optional[int]: +def to_table_32_code(fuel_code: Optional[int]) -> Optional[int]: """Normalise a fuel code (Table 32 or API enum) to its Table 32 form.""" if fuel_code is None: return None @@ -204,7 +204,7 @@ def _to_table_32_code(fuel_code: Optional[int]) -> Optional[int]: def _is_gas_code(fuel_code: Optional[int]) -> bool: - code = _to_table_32_code(fuel_code) + code = to_table_32_code(fuel_code) return code is not None and code in _GAS_FUEL_CODES @@ -219,9 +219,9 @@ def is_electric_fuel_code(fuel_code: Optional[int]) -> bool: silently mis-classifies as electric. The S0380.135 EES-code → Table 32 mapper lookups set `main_fuel_type` to Table 32 codes (BDI → 10 = dual fuel), so the literal-set checks fail loudly here - unless normalised through `_to_table_32_code` first. + unless normalised through `to_table_32_code` first. """ - code = _to_table_32_code(fuel_code) + code = to_table_32_code(fuel_code) return code is not None and code in _ELECTRIC_FUEL_CODES @@ -235,7 +235,7 @@ def is_liquid_fuel_code(fuel_code: Optional[int]) -> bool: LPG is treated as GAS by Table 4f (separate "Gas boiler" row, 45 kWh/yr) — `is_liquid_fuel_code` returns False for LPG codes. """ - code = _to_table_32_code(fuel_code) + code = to_table_32_code(fuel_code) return code is not None and code in _LIQUID_FUEL_CODES diff --git a/tests/domain/property_baseline/test_sap_fuel.py b/tests/domain/property_baseline/test_sap_fuel.py index 24dcf193..dacdb075 100644 --- a/tests/domain/property_baseline/test_sap_fuel.py +++ b/tests/domain/property_baseline/test_sap_fuel.py @@ -35,6 +35,22 @@ def test_table_32_codes_map_to_their_billing_fuel(code: int, fuel: Fuel) -> None assert sap_code_to_fuel(code) == fuel +@pytest.mark.parametrize( + ("api_code", "fuel"), + [ + (26, Fuel.MAINS_GAS), # gov-API mains-gas enum -> Table 32 code 1 + (0, Fuel.ELECTRICITY), # API "electricity" -> Table 32 code 30 + (25, Fuel.HEAT_NETWORK), # API community heat -> Table 32 code 41 + (14, Fuel.COAL), # API house coal -> Table 32 code 11 + ], +) +def test_raw_api_fuel_codes_normalize_before_mapping(api_code: int, fuel: Fuel) -> None: + # Arrange — the calculator may carry a raw gov-API fuel code (not yet a Table + # 32 code); sap_code_to_fuel normalizes via the calculator's own helper first. + # Act / Assert + assert sap_code_to_fuel(api_code) == fuel + + def test_an_unmapped_code_raises_rather_than_guessing() -> None: # Arrange — code 10 (dual fuel) has no single billing fuel. # Act / Assert From 5e75fb474cd77a2eb11b502d90b8c70408eca8d5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:28:22 +0000 Subject: [PATCH 006/190] feat(baseline): EnergyBreakdown.from_sap_result + COOLING section The SapResult -> EnergyBreakdown adapter (ADR-0014), a classmethod on the target mirroring Performance.from_sap_result. Folds each positive per-end-use delivered kWh into a billable EnergyLine: main/main-2/secondary heating and hot water at their resolved fuel (sap_code_to_fuel); lighting/pumps-fans/ appliances/cooking/cooling as electricity. PV export carries to exported_kwh for the SEG credit. Zero-kWh end uses emit no line; a positive kWh with no fuel code raises rather than billing at a default (strict, mirrors the calculator). Adds BillSection.COOLING (electricity, from space_cooling_fuel_kwh_per_yr). BillDerivation already prices any section it is given, so no change there. Also corrects the ADR-0014 amendment: SapResult carries the calculator's own fuel codes (raw API or Table-32 per mapper, ADR-0015); sap_fuel normalizes. Co-Authored-By: Claude Opus 4.8 --- ...14-bill-derivation-from-real-fuel-rates.md | 12 +- domain/property_baseline/bill.py | 77 ++++++++- .../test_energy_breakdown.py | 148 ++++++++++++++++++ 3 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 tests/domain/property_baseline/test_energy_breakdown.py 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 e10d1f32..d33a7810 100644 --- a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md +++ b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md @@ -109,10 +109,14 @@ 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 **resolved Table-32 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. - `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 + 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. diff --git a/domain/property_baseline/bill.py b/domain/property_baseline/bill.py index fcc49329..110aa237 100644 --- a/domain/property_baseline/bill.py +++ b/domain/property_baseline/bill.py @@ -3,8 +3,13 @@ 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.property_baseline.sap_fuel import sap_code_to_fuel + +if TYPE_CHECKING: + from domain.sap10_calculator.calculator import SapResult class BillSection(Enum): @@ -17,6 +22,7 @@ class BillSection(Enum): APPLIANCES = "APPLIANCES" COOKING = "COOKING" PUMPS_FANS = "PUMPS_FANS" + COOLING = "COOLING" @dataclass(frozen=True) @@ -31,13 +37,78 @@ class EnergyLine: @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.""" + """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: diff --git a/tests/domain/property_baseline/test_energy_breakdown.py b/tests/domain/property_baseline/test_energy_breakdown.py new file mode 100644 index 00000000..ffe7ffb0 --- /dev/null +++ b/tests/domain/property_baseline/test_energy_breakdown.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import pytest + +from domain.fuel_rates.fuel import Fuel +from domain.property_baseline.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) From f7dc9dbccbd602b871964ceb62d1353846679064 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:37:13 +0000 Subject: [PATCH 007/190] feat(baseline): Rebaseliner returns RebaselineResult carrying the SapResult The Rebaseliner is the assemble-and-score step (ADR-0013 amendment); its SapResult is the scored picture that Bill Derivation also prices (ADR-0014), so rebaseline() now returns a RebaselineResult{effective, reason, sap_result} instead of (Performance, reason). CalculatorRebaseliner sets sap_result on both branches (the bill prices it whether lodged or calculated figures win); StubRebaseliner returns sap_result=None (runs no calculator). Orchestrator unpacks the result; the bill wiring lands in the next slice. Also refreshes the stale ML-era docstrings in rebaseliner.py to the assemble-and-score model (the calculator, not ML, is the rebaseliner mechanism per ADR-0013). Co-Authored-By: Claude Opus 4.8 --- .../calculator_rebaseliner.py | 16 +++-- domain/property_baseline/rebaseliner.py | 59 +++++++++++++------ .../property_baseline_orchestrator.py | 6 +- .../test_calculator_rebaseliner.py | 28 +++++---- .../property_baseline/test_rebaseliner.py | 14 +++-- 5 files changed, 81 insertions(+), 42 deletions(-) diff --git a/domain/property_baseline/calculator_rebaseliner.py b/domain/property_baseline/calculator_rebaseliner.py index 9c412c4e..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 @@ -51,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 < _MIN_TRUSTED_SAP_VERSION: - return Performance.from_sap_result(result), "pre_sap10" + return RebaselineResult( + effective=Performance.from_sap_result(result), + reason="pre_sap10", + sap_result=result, + ) self._log_divergence( 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, 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/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index bf82a514..3eb55e54 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -42,14 +42,14 @@ class PropertyBaselineOrchestrator: 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 ) 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, ) 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() From f1799505198d9cabed586543518afcc1e88e86a9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:51:18 +0000 Subject: [PATCH 008/190] feat(baseline): wire BillDerivation into the orchestrator and persist the Bill (ADR-0014) The PropertyBaselineOrchestrator now reads the current Fuel Rates snapshot once per batch, builds a BillDerivation, and prices each scored property's SapResult -> EnergyBreakdown into a Bill carried on PropertyBaselinePerformance (None only on the stub no-calculator path). The Bill is flattened onto nullable bill_* flat columns (per-section kwh+cost, standing charges, SEG credit, total) on the postgres table, with bill_total_annual_bill_gbp as the not-null discriminator on read-back. Section absent from the bill stays None, not 0. Updated all four orchestrator construction sites to inject the FuelRatesRepository port (handler + three test sites), and the FE migration doc to reflect the prefixed columns and that they are now populated. Co-Authored-By: Claude Opus 4.8 --- applications/ara_first_run/handler.py | 4 + .../property-baseline-performance-table.md | 51 +++++---- .../property_baseline_performance.py | 9 +- .../property_baseline_performance_table.py | 77 ++++++++++++- .../property_baseline_orchestrator.py | 20 ++++ ...test_ara_first_run_pipeline_integration.py | 4 + .../test_property_baseline_orchestrator.py | 106 +++++++++++++++++- ...t_property_baseline_postgres_repository.py | 63 +++++++++++ 8 files changed, 307 insertions(+), 27 deletions(-) diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index e82da40f..a546d0f4 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -23,6 +23,9 @@ 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 @@ -85,6 +88,7 @@ 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(), 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/domain/property_baseline/property_baseline_performance.py b/domain/property_baseline/property_baseline_performance.py index 8da9bbf2..3951611d 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.property_baseline.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/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 0e5e1792..908534c0 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.property_baseline.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/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index 3eb55e54..faeaad92 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.property_baseline.bill import EnergyBreakdown +from domain.property_baseline.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,11 +35,18 @@ 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): @@ -45,6 +55,15 @@ class PropertyBaselineOrchestrator: 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, @@ -52,6 +71,7 @@ class PropertyBaselineOrchestrator: 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/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index e60ac716..3d6aeb4a 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -32,6 +32,9 @@ from orchestration.modelling_orchestrator import ModellingOrchestrator 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 @@ -113,6 +116,7 @@ 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(), diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 12c3d660..1e0f5ec2 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.property_baseline.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/property_baseline/test_property_baseline_postgres_repository.py b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py index 6395d0f9..a46a65f9 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.property_baseline.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 From 0ba45a09cc98041c1662b0b5ba386c542b28844f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:13:51 +0000 Subject: [PATCH 009/190] =?UTF-8?q?docs(modelling):=20record=20stage=20des?= =?UTF-8?q?ign=20=E2=80=94=20CONTEXT=20terms=20+=20ADR-0016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe Recommendation as a target surface (partitions the EpcPropertyData surface, so selected overlays never collide); add Measure Option, Simulation Overlay (EpcSimulation), Product, Cost, Contingency, and Measure Dependency. ADR-0016 fixes the scoring/optimisation approach (warm-start grouped-knapsack MILP -> deterministic package re-score -> greedy repair, with a final-package marginal cascade for display attribution), resolving the open question in ADR-0005 §14. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 27 +++++++++++++++++-- ...lti-phase-scenarios-per-phase-recompute.md | 2 +- ...ge-rescore-over-warm-start-optimisation.md | 22 +++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0016-package-rescore-over-warm-start-optimisation.md diff --git a/CONTEXT.md b/CONTEXT.md index f3ffd4fa..74759091 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -208,8 +208,31 @@ Recommendations generated but not selected by the Optimiser in a given Plan Phas _Avoid_: deferred measures, leftover recommendations **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. Recommendations **partition** the modifiable surface of EpcPropertyData: no two Recommendations write the same `(building part, field)`, 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) + +**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`. 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 + +**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. 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..0d811847 100644 --- a/docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md +++ b/docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md @@ -11,4 +11,4 @@ A single-phase Scenario is `phases: []` with all measure type - `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. +- ~~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.~~ **Resolved by [ADR-0016](0016-package-rescore-over-warm-start-optimisation.md):** the answer is not package enumeration but warm-start MILP on independent-vs-baseline scores → deterministic-calculator package re-score → greedy repair, which sidesteps the cross-product. 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..50095a03 --- /dev/null +++ b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md @@ -0,0 +1,22 @@ +# 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 phase 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 Phase** ≈ `(# 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. Phase is the cascade unit; intra-phase ordering follows the same 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. From 350f4c8e762231fcb73af5ef0418b55ee1b26da0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:13:51 +0000 Subject: [PATCH 010/190] feat(modelling): Overlay Applicator folds EpcSimulation onto EpcPropertyData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EpcSimulation is the Simulation Overlay — a narrow all-optional partial mirror of EpcPropertyData/SapBuildingPart (wall surface first), targeting building parts by BuildingPartIdentifier (composition, not inheritance). apply_simulations(baseline, simulations) deep-copies the baseline, folds overlays in order (later wins on a shared field) via a generic non-None field write, and returns a throwaway EpcPropertyData for the calculator; the baseline is never mutated. Four behaviour tests (hand-built EPD from the 000490 fixture, no PDF): targeted-write-leaves-others-untouched, empty-overlay no-op, sequential last-wins, baseline-immutability. pyright strict clean. Slice 1 of the Modelling stage rebuild (ADR-0016). Closes #1153. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/__init__.py | 0 domain/modelling/overlay_applicator.py | 34 ++++++ domain/modelling/simulation.py | 38 +++++++ tests/domain/modelling/__init__.py | 0 .../modelling/test_overlay_applicator.py | 101 ++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 domain/modelling/__init__.py create mode 100644 domain/modelling/overlay_applicator.py create mode 100644 domain/modelling/simulation.py create mode 100644 tests/domain/modelling/__init__.py create mode 100644 tests/domain/modelling/test_overlay_applicator.py diff --git a/domain/modelling/__init__.py b/domain/modelling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/modelling/overlay_applicator.py b/domain/modelling/overlay_applicator.py new file mode 100644 index 00000000..e4df587b --- /dev/null +++ b/domain/modelling/overlay_applicator.py @@ -0,0 +1,34 @@ +"""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 Sequence + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.simulation import EpcSimulation + + +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.""" + 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) + + return result diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py new file mode 100644 index 00000000..a5f36dfa --- /dev/null +++ b/domain/modelling/simulation.py @@ -0,0 +1,38 @@ +"""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 Mapping, Optional + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier + + +@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 + + +def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: + return {} + + +@dataclass(frozen=True) +class EpcSimulation: + """A Simulation Overlay: the per-building-part changes a Measure Option + makes, keyed by `BuildingPartIdentifier`.""" + + building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field( + default_factory=_no_building_parts + ) 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/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py new file mode 100644 index 00000000..4f208158 --- /dev/null +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -0,0 +1,101 @@ +"""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, + SapBuildingPart, +) +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from domain.modelling.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_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 + ) From 214b38ff785592b508792a68e3607bab86a6ea82 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:49:33 +0000 Subject: [PATCH 011/190] =?UTF-8?q?feat(modelling):=20wall=20Recommendatio?= =?UTF-8?q?n=20Generator=20=E2=80=94=20cavity-fill=20detection=20+=20overl?= =?UTF-8?q?ay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_cavity_wall(epc) detects an uninsulated main cavity wall (wall_construction=4, wall_insulation_type=4) and emits a Recommendation whose single Measure Option carries the Simulation Overlay setting MAIN wall_insulation_type=2 (Table 6 'Filled cavity'; cf. domain/sap10_ml/ rdsap_uvalues.py u_wall). Returns None for already-insulated or non-cavity main walls. Recommendation/MeasureOption reshaped per design review: the target is encoded in the Option's overlay (addresses a building part / window / system), not a typed key on Recommendation — generalises to glazing and heating without changing the type. CONTEXT partition wording generalised to match. Three behaviour tests (hand-built EPD, no PDF). Cost (behaviour 4 of #1155) outstanding — needs net heat-loss wall area + ProductRepository. WIP on #1155. pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- domain/modelling/recommendation.py | 44 +++++++++++++ domain/modelling/wall_recommendation.py | 52 ++++++++++++++++ .../modelling/test_wall_recommendation.py | 62 +++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/recommendation.py create mode 100644 domain/modelling/wall_recommendation.py create mode 100644 tests/domain/modelling/test_wall_recommendation.py diff --git a/CONTEXT.md b/CONTEXT.md index 74759091..67fb95d9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -208,7 +208,7 @@ Recommendations generated but not selected by the Optimiser in a given Plan Phas _Avoid_: deferred measures, leftover recommendations **Recommendation**: -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. Recommendations **partition** the modifiable surface of EpcPropertyData: no two Recommendations write the same `(building part, field)`, 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. +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) **Measure Option**: diff --git a/domain/modelling/recommendation.py b/domain/modelling/recommendation.py new file mode 100644 index 00000000..4a287ee9 --- /dev/null +++ b/domain/modelling/recommendation.py @@ -0,0 +1,44 @@ +"""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.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: str + description: str + overlay: EpcSimulation + cost: Optional[Cost] = 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/wall_recommendation.py b/domain/modelling/wall_recommendation.py new file mode 100644 index 00000000..0251a172 --- /dev/null +++ b/domain/modelling/wall_recommendation.py @@ -0,0 +1,52 @@ +"""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.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation + +# 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) -> Optional[Recommendation]: + """Return a cavity-fill Recommendation for the main wall when it is an + uninsulated cavity wall, else None.""" + 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 + + option = MeasureOption( + measure_type="cavity_wall_insulation", + description="Cavity wall insulation (fill the existing cavity)", + overlay=EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + wall_insulation_type=_FILLED_CAVITY + ) + } + ), + ) + return Recommendation(surface="Main wall", options=(option,)) diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py new file mode 100644 index 00000000..74162f4e --- /dev/null +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -0,0 +1,62 @@ +"""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.overlay_applicator import apply_simulations +from domain.modelling.recommendation import Recommendation +from domain.modelling.wall_recommendation import recommend_cavity_wall +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_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) + + # 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) + + # Assert + assert recommendation is None + + +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) + + # Assert + assert recommendation is None From 0ba057587744d512137f3d62b911159c4659d3b5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:53:12 +0000 Subject: [PATCH 012/190] feat(modelling): shared gross heat-loss wall area geometry helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domain/building_geometry.gross_heat_loss_wall_area(epc, identifier) sums heat_loss_perimeter x room_height across a building part's storeys — the heat-loss wall area (party walls excluded by construction), not total wall area. Lives outside the calculator so Modelling cost quantities can reuse it; the calculator computes the same quantity inline today and should be DRY'd onto this later (coordinated with the calculator branch). Pinned at 45.93 m^2 against the 000490 MAIN part. Toward #1155 cost (behaviour 4). pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/building_geometry.py | 33 ++++++++++++++++++++++++++ tests/domain/test_building_geometry.py | 22 +++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 domain/building_geometry.py create mode 100644 tests/domain/test_building_geometry.py diff --git a/domain/building_geometry.py b/domain/building_geometry.py new file mode 100644 index 00000000..a76e0468 --- /dev/null +++ b/domain/building_geometry.py @@ -0,0 +1,33 @@ +"""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) diff --git a/tests/domain/test_building_geometry.py b/tests/domain/test_building_geometry.py new file mode 100644 index 00000000..816e334d --- /dev/null +++ b/tests/domain/test_building_geometry.py @@ -0,0 +1,22 @@ +"""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 +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 From b2c8980dd2b4b746ce32aa7fa0434a7943697e81 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:32:38 +0000 Subject: [PATCH 013/190] feat(modelling): ProductRepository + Postgres materials-table source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product(measure_type, unit_cost_per_m2, contingency_rate). ProductRepository is the DDD port abstracting the catalogue source; ProductPostgresRepository reads the externally-owned material table (defensive SQLModel view MaterialRow) and maps an active row to a Product — total_cost becomes the fully-loaded unit_cost_per_m2 — joining the per-measure-type contingency (contingencies.py, mirrors Costs.CONTINGENCIES; cavity 0.10). Strict-raise on missing/inactive row. A JSON-backed impl will follow behind the same port for ETL-gap costs. Two DB tests against an ephemeral Postgres (map active row; raise on inactive-only). Toward #1155 cost (4b). Also generalises the CONTEXT Simulation Overlay wording: windows are targeted by index, building-part association carried via window_location (_window_bp_index). pyright clean. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- domain/modelling/contingencies.py | 21 +++++++ domain/modelling/product.py | 16 +++++ infrastructure/postgres/product_table.py | 24 +++++++ repositories/product/__init__.py | 0 .../product/product_postgres_repository.py | 34 ++++++++++ repositories/product/product_repository.py | 18 ++++++ tests/repositories/product/__init__.py | 0 .../test_product_postgres_repository.py | 63 +++++++++++++++++++ 9 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/contingencies.py create mode 100644 domain/modelling/product.py create mode 100644 infrastructure/postgres/product_table.py create mode 100644 repositories/product/__init__.py create mode 100644 repositories/product/product_postgres_repository.py create mode 100644 repositories/product/product_repository.py create mode 100644 tests/repositories/product/__init__.py create mode 100644 tests/repositories/product/test_product_postgres_repository.py diff --git a/CONTEXT.md b/CONTEXT.md index 67fb95d9..baedf2f9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -216,7 +216,7 @@ One mutually-exclusive way to satisfy a **Recommendation** — possibly a **bund _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`. 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. +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**: diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py new file mode 100644 index 00000000..f036b786 --- /dev/null +++ b/domain/modelling/contingencies.py @@ -0,0 +1,21 @@ +"""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, +} + + +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/product.py b/domain/modelling/product.py new file mode 100644 index 00000000..fe5c78f3 --- /dev/null +++ b/domain/modelling/product.py @@ -0,0 +1,16 @@ +"""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 + + +@dataclass(frozen=True) +class Product: + measure_type: str + unit_cost_per_m2: float + contingency_rate: float 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/repositories/product/__init__.py b/repositories/product/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/product/product_postgres_repository.py b/repositories/product/product_postgres_repository.py new file mode 100644 index 00000000..13926885 --- /dev/null +++ b/repositories/product/product_postgres_repository.py @@ -0,0 +1,34 @@ +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: + row: MaterialRow | None = self._session.exec( + select(MaterialRow).where( + col(MaterialRow.type) == measure_type, + col(MaterialRow.is_active).is_(True), + ) + ).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), + ) 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/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_postgres_repository.py b/tests/repositories/product/test_product_postgres_repository.py new file mode 100644 index 00000000..13293ea6 --- /dev/null +++ b/tests/repositories/product/test_product_postgres_repository.py @@ -0,0 +1,63 @@ +"""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_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") From bb2c0068ff7817a736e85915d7bd14f0bde7e3ad Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:35:52 +0000 Subject: [PATCH 014/190] =?UTF-8?q?feat(modelling):=20price=20the=20cavity?= =?UTF-8?q?=20Option=20from=20area=20x=20Product=20=E2=80=94=20closes=20#1?= =?UTF-8?q?155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_cavity_wall now takes a ProductRepository and prices the Measure Option: Cost(total = gross_heat_loss_wall_area(MAIN) x product.unit_cost_per_m2, contingency_rate = product.contingency_rate). Detection is unchanged and runs before pricing, so ineligible walls still return None without a catalogue hit. Completes #1155 — the cavity-wall Recommendation Generator now detects an uninsulated main cavity wall and emits a priced Option carrying the filled- cavity overlay. Four behaviour tests (detection x3 + fully-loaded cost). pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/wall_recommendation.py | 23 ++++++++++--- .../modelling/test_wall_recommendation.py | 33 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/domain/modelling/wall_recommendation.py b/domain/modelling/wall_recommendation.py index 0251a172..6b263ba2 100644 --- a/domain/modelling/wall_recommendation.py +++ b/domain/modelling/wall_recommendation.py @@ -11,8 +11,12 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) -from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.building_geometry import gross_heat_loss_wall_area +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 = "cavity_wall_insulation" # RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6 # wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled @@ -23,9 +27,12 @@ _WALL_UNINSULATED = 4 _FILLED_CAVITY = 2 -def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: +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.""" + 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 @@ -38,8 +45,15 @@ def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: ): 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_wall_insulation", + measure_type=_CAVITY_MEASURE_TYPE, description="Cavity wall insulation (fill the existing cavity)", overlay=EpcSimulation( building_parts={ @@ -48,5 +62,6 @@ def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: ) } ), + cost=cost, ) return Recommendation(surface="Main wall", options=(option,)) diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py index 74162f4e..2f850a8a 100644 --- a/tests/domain/modelling/test_wall_recommendation.py +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -9,13 +9,24 @@ from datatypes.epc.domain.epc_property_data import ( SapBuildingPart, ) from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from domain.modelling.wall_recommendation import recommend_cavity_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) @@ -25,7 +36,7 @@ def test_uninsulated_main_cavity_wall_yields_a_cavity_fill_recommendation() -> N baseline: EpcPropertyData = build_epc() # MAIN: cavity (4), uninsulated (4) # Act - recommendation: Recommendation | None = recommend_cavity_wall(baseline) + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) # Assert assert recommendation is not None @@ -43,12 +54,28 @@ def test_already_insulated_main_wall_yields_no_recommendation() -> None: _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type = 2 # filled # Act - recommendation: Recommendation | None = recommend_cavity_wall(baseline) + 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() @@ -56,7 +83,7 @@ def test_non_cavity_main_wall_yields_no_recommendation() -> None: main.wall_construction = 2 # solid (not cavity); still uninsulated # Act - recommendation: Recommendation | None = recommend_cavity_wall(baseline) + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) # Assert assert recommendation is None From 7a478cff6e7709b418e608c40314cb9b4ed106ea Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:41:30 +0000 Subject: [PATCH 015/190] =?UTF-8?q?feat(modelling):=20Package=20Scorer=20?= =?UTF-8?q?=E2=80=94=20compose=20overlays=20+=20score=20on=20the=20calcula?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PackageScorer(calculator: SapCalculator).score(baseline, simulations) folds the Simulation Overlays onto the baseline via the Overlay Applicator and scores the throwaway EpcPropertyData on the injected deterministic SAP calculator, returning Score(sap_continuous, co2_kg_per_yr, primary_energy_kwh_per_yr). Depends on the SapCalculator abstraction, not a concrete engine. This is the reusable scoring primitive (ADR-0016) — the same call serves the optimiser's whole-package re-score and a future live re-score of a user-assembled plan. Two behaviour tests against the real Sap10Calculator on a hand-built EPD: filling the main cavity improves SAP (right-directional through the real physics); an empty package scores the unmodified baseline (pins the SapResult->Score mapping). The Elmhurst before/after cascade PIN (#1154's acceptance) lands once cert 001431 parses (external _extract_windows fix). Co-Authored-By: Claude Opus 4.8 --- domain/modelling/package_scorer.py | 47 ++++++++++++++++ tests/domain/modelling/test_package_scorer.py | 54 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 domain/modelling/package_scorer.py create mode 100644 tests/domain/modelling/test_package_scorer.py diff --git a/domain/modelling/package_scorer.py b/domain/modelling/package_scorer.py new file mode 100644 index 00000000..bacf9e18 --- /dev/null +++ b/domain/modelling/package_scorer.py @@ -0,0 +1,47 @@ +"""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 Sequence + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.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_continuous: float + co2_kg_per_yr: float + primary_energy_kwh_per_yr: float + + +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, + ) diff --git a/tests/domain/modelling/test_package_scorer.py b/tests/domain/modelling/test_package_scorer.py new file mode 100644 index 00000000..ffe50cd5 --- /dev/null +++ b/tests/domain/modelling/test_package_scorer.py @@ -0,0 +1,54 @@ +"""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.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 + ) From 13dd5fe81a46efb6c5280997d6823a5c13e79ae0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:50:49 +0000 Subject: [PATCH 016/190] =?UTF-8?q?feat(modelling):=20per-measure=20scorin?= =?UTF-8?q?g=20=E2=80=94=20marginal=20cascade=20+=20per-Option=20signal=20?= =?UTF-8?q?(#1156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scoring.py adds the telescoping marginal cascade that serves two of the three ADR-0016 scoring roles: - marginal_impacts(scorer, baseline, overlays): applies overlays cumulatively in order and reports each measure's marginal MeasureImpact (sap_points + carbon/energy savings). Role 3 (final-package attribution) — the marginals telescope EXACTLY to the whole-package total. - independent_option_impacts(scorer, baseline, options): role 1 — scores each Option's overlay independently vs baseline, scoring each DISTINCT overlay once (Options sharing an overlay reuse the result). Approximate signal for the optimiser; never surfaced as a measure's true impact. Role 2 (whole-package re-score) is PackageScorer.score directly. Three behaviour tests on the real Sap10Calculator / a counting stand-in (hand-built EPD): single-overlay marginal == improvement-over-baseline; two-overlay marginals telescope to the package total; per-Option dedup scores each distinct overlay once. Closes #1156. pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/scoring.py | 91 ++++++++++++++++ tests/domain/modelling/test_scoring.py | 142 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 domain/modelling/scoring.py create mode 100644 tests/domain/modelling/test_scoring.py diff --git a/domain/modelling/scoring.py b/domain/modelling/scoring.py new file mode 100644 index 00000000..c0558d8a --- /dev/null +++ b/domain/modelling/scoring.py @@ -0,0 +1,91 @@ +"""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.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 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.""" + impacts: list[MeasureImpact] = [] + previous: Score = scorer.score(baseline, []) + for index in range(len(overlays)): + current: Score = scorer.score(baseline, list(overlays[: index + 1])) + 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 + ), + ) + ) + previous = current + return impacts + + +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/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py new file mode 100644 index 00000000..af286018 --- /dev/null +++ b/tests/domain/modelling/test_scoring.py @@ -0,0 +1,142 @@ +"""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.package_scorer import PackageScorer, Score +from domain.modelling.recommendation import MeasureOption +from domain.modelling.scoring import ( + MeasureImpact, + independent_option_impacts, + marginal_impacts, +) +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="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_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 + ) From 3c87be8e1e3597f50f8fa75e81781c7ed35fdd14 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:05:38 +0000 Subject: [PATCH 017/190] feat(modelling): roof (loft) Recommendation Generator + roof-area geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_loft_insulation(epc, products) detects an uninsulated main loft (SapBuildingPart.roof_insulation_thickness == 0) and emits a Recommendation("Roof") with one loft_insulation Option carrying the overlay (roof_insulation_thickness = 270 mm, the recommended top-up) and a priced Cost (roof area x the Product's fully-loaded unit cost + contingency). - building_geometry.roof_area(epc, identifier): the part's greatest per-storey floor area (RdSAP 10 §3.8). Pinned 14.85 m^2 on 000490 MAIN. - BuildingPartOverlay gains roof_insulation_thickness; the generic Overlay Applicator writes it with NO change (validated by the tracer) — the deep-module field-fold paying off. - loft_insulation contingency (0.10) added. Progress on #1158 (generator + geometry); end-to-end + Elmhurst pin pending the orchestrator (#1157) and the parser fix. Four behaviour tests (geometry pin; detect / none / cost). pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/building_geometry.py | 14 ++++ domain/modelling/contingencies.py | 1 + domain/modelling/roof_recommendation.py | 61 ++++++++++++++ domain/modelling/simulation.py | 1 + .../modelling/test_roof_recommendation.py | 81 +++++++++++++++++++ tests/domain/test_building_geometry.py | 15 +++- 6 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/roof_recommendation.py create mode 100644 tests/domain/modelling/test_roof_recommendation.py diff --git a/domain/building_geometry.py b/domain/building_geometry.py index a76e0468..f4ba844b 100644 --- a/domain/building_geometry.py +++ b/domain/building_geometry.py @@ -31,3 +31,17 @@ def gross_heat_loss_wall_area( 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 + ) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index f036b786..f40c4851 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -7,6 +7,7 @@ extended as each measure type lands. _CONTINGENCY_RATES: dict[str, float] = { "cavity_wall_insulation": 0.10, + "loft_insulation": 0.10, } diff --git a/domain/modelling/roof_recommendation.py b/domain/modelling/roof_recommendation.py new file mode 100644 index 00000000..76c60241 --- /dev/null +++ b/domain/modelling/roof_recommendation.py @@ -0,0 +1,61 @@ +"""The roof Recommendation Generator. + +Detects an uninsulated loft on an EpcPropertyData and emits a Recommendation +whose Measure Option carries the loft-insulation Simulation Overlay and a priced +Cost (roof area x the Product's fully-loaded unit cost, with its contingency). +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.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from repositories.product.product_repository import ProductRepository + +_LOFT_MEASURE_TYPE = "loft_insulation" +# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. +_ROOF_UNINSULATED_MM = 0 +# Recommended loft-insulation depth (mm) — the building-regs standard top-up. +_RECOMMENDED_LOFT_THICKNESS_MM = 270 + + +def recommend_loft_insulation( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a loft-insulation Recommendation for the main roof when it is + uninsulated, else None. The Option's cost is the roof 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.roof_insulation_thickness != _ROOF_UNINSULATED_MM: + return None + + product = products.get(_LOFT_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=_LOFT_MEASURE_TYPE, + description="Loft insulation (top up to recommended depth)", + overlay=EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + roof_insulation_thickness=_RECOMMENDED_LOFT_THICKNESS_MM + ) + } + ), + cost=cost, + ) + return Recommendation(surface="Roof", options=(option,)) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index a5f36dfa..9a3c7e64 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -22,6 +22,7 @@ class BuildingPartOverlay: """ wall_insulation_type: Optional[int] = None + roof_insulation_thickness: Optional[int] = None def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py new file mode 100644 index 00000000..8acfc5c6 --- /dev/null +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -0,0 +1,81 @@ +"""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, +) +from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.roof_recommendation import recommend_loft_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_loft_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 == 270 + + +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_loft_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_loft_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/test_building_geometry.py b/tests/domain/test_building_geometry.py index 816e334d..548e6675 100644 --- a/tests/domain/test_building_geometry.py +++ b/tests/domain/test_building_geometry.py @@ -2,7 +2,7 @@ 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 +from domain.building_geometry import gross_heat_loss_wall_area, roof_area from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) @@ -20,3 +20,16 @@ def test_gross_heat_loss_wall_area_sums_perimeter_times_height_per_storey() -> N # 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 From 4c104050715fb5732dc34924b0fda0727a9d2ba5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:12:29 +0000 Subject: [PATCH 018/190] feat(modelling): floor Recommendation Generator + ground-floor-area geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_floor_insulation(epc, products) detects an uninsulated ground floor (SapBuildingPart.floor_insulation_thickness blank/zero) and its construction from floor_construction_type — 'Suspended timber' -> suspended_floor_insulation, 'Solid' -> solid_floor_insulation — emitting the matching single Option (a floor is one construction, like a cavity wall) with the overlay (floor_insulation_thickness = 100 mm) and a priced Cost (ground-floor area x the Product's fully-loaded unit cost + contingency). - building_geometry.ground_floor_area(epc, identifier): the lowest floor's (floor == 0) area. Pinned 14.85 m^2 on 000490 MAIN. - BuildingPartOverlay gains floor_insulation_thickness (generic Applicator writes it unchanged). suspended (0.20) / solid (0.26) floor contingencies. Progress on #1159 (generator + geometry); end-to-end + Elmhurst pin pending the orchestrator (#1157) and parser. Four behaviour tests (suspended / solid / none / cost) + geometry pin. pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/building_geometry.py | 15 +++ domain/modelling/contingencies.py | 2 + domain/modelling/floor_recommendation.py | 84 ++++++++++++++++ domain/modelling/simulation.py | 1 + .../modelling/test_floor_recommendation.py | 97 +++++++++++++++++++ tests/domain/test_building_geometry.py | 17 +++- 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/floor_recommendation.py create mode 100644 tests/domain/modelling/test_floor_recommendation.py diff --git a/domain/building_geometry.py b/domain/building_geometry.py index f4ba844b..b6e535fe 100644 --- a/domain/building_geometry.py +++ b/domain/building_geometry.py @@ -45,3 +45,18 @@ def roof_area(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> float 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/modelling/contingencies.py b/domain/modelling/contingencies.py index f40c4851..8d0230ff 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -8,6 +8,8 @@ extended as each measure type lands. _CONTINGENCY_RATES: dict[str, float] = { "cavity_wall_insulation": 0.10, "loft_insulation": 0.10, + "suspended_floor_insulation": 0.20, + "solid_floor_insulation": 0.26, } diff --git a/domain/modelling/floor_recommendation.py b/domain/modelling/floor_recommendation.py new file mode 100644 index 00000000..a6992a85 --- /dev/null +++ b/domain/modelling/floor_recommendation.py @@ -0,0 +1,84 @@ +"""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.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 + + +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[str]: + """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 "suspended_floor_insulation" + if "solid" in text: + return "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 + ) + } + ), + cost=cost, + ) + return Recommendation(surface="Ground floor", options=(option,)) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 9a3c7e64..5b2ba8a6 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -23,6 +23,7 @@ class BuildingPartOverlay: wall_insulation_type: Optional[int] = None roof_insulation_thickness: Optional[int] = None + floor_insulation_thickness: Optional[int] = None def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: diff --git a/tests/domain/modelling/test_floor_recommendation.py b/tests/domain/modelling/test_floor_recommendation.py new file mode 100644 index 00000000..8ed2871f --- /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.overlay_applicator import apply_simulations +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.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/test_building_geometry.py b/tests/domain/test_building_geometry.py index 548e6675..403d79bd 100644 --- a/tests/domain/test_building_geometry.py +++ b/tests/domain/test_building_geometry.py @@ -2,7 +2,11 @@ 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, roof_area +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, ) @@ -33,3 +37,14 @@ def test_roof_area_is_the_parts_greatest_floor_area() -> None: # 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 From 9ed4ccc28e2ee8ff418a44b8875a5700ccb444e2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:18:31 +0000 Subject: [PATCH 019/190] docs(modelling): handover for the Modelling stage rebuild Captures issue status (#1153-#1161), the built compute spine, key facts/gotchas (hand-built 000490 fixture, calculator entry, worktree-vs-main import trap, test/commit conventions), and the two gates (parser fix -> wire Elmhurst cascade pins; #1157 persist-Plan HITL schema review). For picking the work back up in a fresh session. Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/HANDOVER_MODELLING.md diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md new file mode 100644 index 00000000..8f538e29 --- /dev/null +++ b/docs/HANDOVER_MODELLING.md @@ -0,0 +1,63 @@ +# HANDOVER — Modelling stage rebuild + +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `4c104050`. +**PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. + +## Issue status + +| Issue | What | State | +|---|---|---| +| #1153 | Overlay Applicator + `EpcSimulation` | ✅ closed (`350f4c8e`) | +| #1154 | Package Scorer | code done (`7a478cff`); **Elmhurst cascade pin pending parser** | +| #1155 | wall Recommendation Generator | ✅ closed (`bb2c0068`) | +| #1156 | score Options + attribution | ✅ closed (`13dd5fe8`) | +| #1157 | persist a Plan via `ModellingOrchestrator` | **not started — HITL (persistence-schema review)** | +| #1158 | roof (loft) generator | generator done (`3c87be8e`); end-to-end + pin pending (#1157 + parser) | +| #1159 | floor generator | generator done (`4c104050`); end-to-end + pin pending | +| #1160 | Optimiser (knapsack + greedy repair) | not started (blocked by #1157/#1158/#1159) | +| #1161 | Measure Dependency (ventilation) | not started (blocked by #1160) | + +## Design (already recorded — read these) + +- **CONTEXT.md** terms: Recommendation (a *target surface*; Recommendations **partition** the modifiable EPD surface so overlays never collide), Measure Option (bundle-capable; deduped by overlay), **Simulation Overlay** (`EpcSimulation`), Product, Cost, Contingency, Measure Dependency. Targeting: building parts by `BuildingPartIdentifier`; **windows by index**; systems direct. +- **ADR-0016**: the three scoring roles (per-Option signal → whole-package re-score → final-package marginal cascade attribution) + warm-start MILP → dependency injection → package re-score → greedy repair. Resolves ADR-0005 §14. +- Governing: **ADR-0005** (multi-phase scenarios, per-phase recompute vs rolling Effective EPC), **ADR-0011** (composable stage orchestrators), **ADR-0012** (one Unit of Work per stage, commit once). + +## What's built + +All in `domain/modelling/`, `domain/building_geometry.py`, `repositories/product/`, `infrastructure/postgres/product_table.py`. **25 tests green, pyright strict clean, purely additive.** + +- `simulation.py` — `EpcSimulation(building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay])`; `BuildingPartOverlay` (all-optional: `wall_insulation_type`, `roof_insulation_thickness`, `floor_insulation_thickness`). +- `overlay_applicator.py` — `apply_simulations(baseline, simulations) -> EpcPropertyData`. **Generic field-fold** (adding overlay fields needs NO change here — proven by roof/floor), sequential (later overlay wins), deep-copies (baseline never mutated), targets parts by identifier, writes the `sap_*` fields. Returns a throwaway EPD. +- `recommendation.py` — `Recommendation(surface, options)`, `MeasureOption(measure_type, description, overlay, cost)`, `Cost(total, contingency_rate)`. +- `product.py` / `contingencies.py` — `Product(measure_type, unit_cost_per_m2, contingency_rate)`; per-type contingency (cavity 0.10, loft 0.10, suspended floor 0.20, solid floor 0.26). +- `package_scorer.py` — `PackageScorer(calculator: SapCalculator).score(baseline, simulations) -> Score(sap_continuous, co2_kg_per_yr, primary_energy_kwh_per_yr)`. The reusable scoring primitive (role 2). +- `scoring.py` — `marginal_impacts(scorer, baseline, overlays) -> list[MeasureImpact]` (telescoping cascade, role 3); `independent_option_impacts(scorer, baseline, options)` (role 1, scores each *distinct* overlay once). `MeasureImpact(sap_points, co2_savings_kg_per_yr, energy_savings_kwh_per_yr)`. +- `wall_recommendation.py` — `recommend_cavity_wall(epc, products)`: detect cavity (`wall_construction==4`) + uninsulated (`wall_insulation_type==4`) → overlay sets `wall_insulation_type=2` (Table 6 "Filled cavity"). +- `roof_recommendation.py` — `recommend_loft_insulation(epc, products)`: detect `roof_insulation_thickness==0` → overlay `roof_insulation_thickness=270`. +- `floor_recommendation.py` — `recommend_floor_insulation(epc, products)`: detect uninsulated ground floor + construction (`floor_construction_type` "Suspended"/"Solid") → overlay `floor_insulation_thickness=100`. +- `building_geometry.py` — `gross_heat_loss_wall_area`, `roof_area`, `ground_floor_area` (per part, by identifier; party walls excluded; areas are heat-loss/§3.8 quantities, not totals). +- `repositories/product/` — `ProductRepository` (ABC port, `get(measure_type)->Product`); `ProductPostgresRepository` reads the externally-owned `material` table (defensive SQLModel view `MaterialRow`; `total_cost → unit_cost_per_m2`; joins contingency). A `ProductJsonRepository` (file source, for ETL-gap costs) is intended behind the same port — **the one remaining parser-independent AFK task**. + +## Key facts / gotchas + +- **Hand-built baseline fixture** (no PDF): `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.build_epc()`. Its MAIN is an uninsulated cavity wall + uninsulated suspended ground floor + 300 mm (insulated) loft. Used as the baseline in every generator/scorer test. MAIN gross heat-loss wall area = **45.93 m²**, roof area = **14.85 m²**, ground floor = **14.85 m²**. +- **Calculator entry:** `Sap10Calculator().calculate(epc) -> SapResult` (`sap_score_continuous`, `co2_kg_per_yr`, `primary_energy_kwh_per_yr`). Depend on the **`SapCalculator`** abstraction. Filled-cavity wall code = **2** (`domain/sap10_ml/rdsap_uvalues.py::u_wall`). Calculator reads wall/roof/floor from `SapBuildingPart` structured fields, NOT `EnergyElement` descriptions (those are detection-only). +- **Worktree vs main import trap:** `python /tmp/foo.py` imports the repo from `/workspaces/model` (editable install), NOT this worktree. Run with `PYTHONPATH=` or via `pytest` (rootdir handles it). `pytest` already uses worktree code. +- **Running tests:** `python -m pytest -q`. Do NOT pass `-p no:cov` (pytest.ini injects `--cov` args that then error). DB repo tests spin up ephemeral Postgres via the `db_engine` fixture (`tests/conftest.py`) — slower; SQLModel tables auto-register on import. +- **Conventions:** commit per TDD slice; conventional-commit message ending `Co-Authored-By: Claude Opus 4.8 `; stay on `feature/bill-derivation` (user's choice). Tests use literal `# Arrange / # Act / # Assert`; assert with `abs(x - y) <= tol` (not `pytest.approx`); pyright strict, zero errors; annotate call-return locals. + +## The two gates + +1. **Parser fix (in flight — `feature/per-cert-mapper-validation` agent → main).** Once cert **001431** parses, wire the **Elmhurst before/after cascade pins**: + - Files (main checkout): `/workspaces/model/sap worksheets/Recommendations Elmhurst Files//{before,after}/Summary_*.pdf` — `cavity_wall_insulation - main wall` (001431), `loft_insulation - main building`, `solid_floor`/`suspended_floor - main building`, etc. + - Pipeline: `from backend.documents_parser.parser import parse_site_notes_pdf; epd = parse_site_notes_pdf(path)`. + - Pin: parse `before` → apply the measure's overlay (the matching `recommend_*` Option's `overlay`) → `PackageScorer.score` → compare to `after` (either the `after` worksheet's SAP/kWh/carbon, or `Sap10Calculator().calculate(parse(after))`). `/tmp/spike_diff.py` diffs before/after EPDs to derive/validate the overlay empirically. + - The parser bug being fixed: `_extract_windows` reads `location`/`orientation`/`data_source` as single tokens, but 001431 lodges multi-token values ("External wall", "North West") with a blank Glazing Gap, so `'Manufacturer'` lands on the `u_value` float. (`ec9ef0e8` fixed a *different* symptom.) + - Closing these → #1154 done; #1158/#1159 end-to-end once #1157 exists. +2. **#1157 persist a Plan (HITL).** Design-review the Plan / Plan Phase / Recommendation persistence schema + `ScenarioRepository` method shapes, then build `ModellingOrchestrator.run(property_ids, scenario_ids)` per ADR-0011/0012 (one UoW, commit once, thread only IDs, read via repos). Template: `orchestration/property_baseline_orchestrator.py`. Then roof/floor end-to-end + #1160 optimiser + #1161 ventilation dependency. + +## Relevant memories (auto-loaded) + +- `project_openos_conservation_data_gap` — EWI eligibility needs listed/conservation status, not ingested; blocks the solid-wall EWI slice (later), NOT the fabric tracers. +- `project_calculator_geometry_extraction` — the calculator holds reusable geometry; `building_geometry.py` is the start; DRY the calculator onto it later (coordinate with the calculator branch); **don't edit `heat_transmission.py` now**. From 4c0a907a54828606bea414264f12e0bd0b5c013d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:36:53 +0000 Subject: [PATCH 020/190] test(modelling): Elmhurst before/after cascade pin for cavity wall (#1154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1154 — the Package Scorer's Elmhurst cascade pin. Drives recommend_cavity_wall on the parsed `before` Summary, scores its Option's overlay through PackageScorer, and asserts delta 0 (abs<=1e-4 on SAP/CO2/PE) vs the calculator's score on the re-lodged `after` Summary. Key finding: the handover's stated parser gate (parse_site_notes_pdf throwing 'Manufacturer' on cert 001431) does NOT block these pins. The Elmhurst recommendation Summaries route cleanly through the same ElmhurstSiteNotesExtractor + EpcPropertyDataMapper chain the worksheet e2e fixtures use (_elmhurst_worksheet_001431.build_epc). The Textract path's window bug is unrelated and unused here. The before→after field change is exactly wall_insulation_type 4 (uninsulated) → 2 (filled cavity), which is precisely the overlay recommend_cavity_wall emits; the cascade closes at delta 0.000000 on all three metrics. Before/after Summaries mirrored into tests/domain/modelling/fixtures/ so the pin does not depend on the unstaged workspace. Co-Authored-By: Claude Opus 4.8 --- .../modelling/_elmhurst_recommendation.py | 76 ++++++++++++++++ .../fixtures/cavity_wall_001431_after.pdf | Bin 0 -> 66031 bytes .../fixtures/cavity_wall_001431_before.pdf | Bin 0 -> 66966 bytes .../modelling/test_elmhurst_cascade_pins.py | 81 ++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 tests/domain/modelling/_elmhurst_recommendation.py create mode 100644 tests/domain/modelling/fixtures/cavity_wall_001431_after.pdf create mode 100644 tests/domain/modelling/fixtures/cavity_wall_001431_before.pdf create mode 100644 tests/domain/modelling/test_elmhurst_cascade_pins.py 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/cavity_wall_001431_after.pdf b/tests/domain/modelling/fixtures/cavity_wall_001431_after.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e42637453085f0569ab288ff699016cb7e3a5060 GIT binary patch literal 66031 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%0DSk7(8S5Z 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�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 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 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 + ) From 44d62c0c9b9e886af35c70667343561195f46b3f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:39:21 +0000 Subject: [PATCH 021/190] =?UTF-8?q?feat(modelling):=20loft=20overlay=20270?= =?UTF-8?q?=E2=86=92300=20mm=20+=20Elmhurst=20cascade=20pin=20(#1158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes #1158 end-to-end. recommend_loft_insulation now emits a 300 mm overlay (was 270 mm). The Elmhurst before/after re-lodgement of the loft-insulation measure on cert 001431 lodges the after-cert at 300 mm roof insulation; pinning before→overlay→after requires the overlay to match that depth — at 270 mm the cascade left a +0.173 SAP residual, at 300 mm it closes at delta 0.000000 on SAP/CO2/PE. Adds test_loft_overlay_reproduces_the_relodged_after and updates the roof generator unit test's thickness assertion to 300. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/roof_recommendation.py | 6 ++++-- .../modelling/fixtures/loft_001431_after.pdf | Bin 0 -> 65930 bytes .../modelling/fixtures/loft_001431_before.pdf | Bin 0 -> 66777 bytes .../modelling/test_elmhurst_cascade_pins.py | 20 ++++++++++++++++++ .../modelling/test_roof_recommendation.py | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tests/domain/modelling/fixtures/loft_001431_after.pdf create mode 100644 tests/domain/modelling/fixtures/loft_001431_before.pdf diff --git a/domain/modelling/roof_recommendation.py b/domain/modelling/roof_recommendation.py index 76c60241..aa09b620 100644 --- a/domain/modelling/roof_recommendation.py +++ b/domain/modelling/roof_recommendation.py @@ -20,8 +20,10 @@ from repositories.product.product_repository import ProductRepository _LOFT_MEASURE_TYPE = "loft_insulation" # RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. _ROOF_UNINSULATED_MM = 0 -# Recommended loft-insulation depth (mm) — the building-regs standard top-up. -_RECOMMENDED_LOFT_THICKNESS_MM = 270 +# 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 def recommend_loft_insulation( 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 0000000000000000000000000000000000000000..1b76e03233218ad4876aa9210ac8d874b7d851f0 GIT binary patch literal 65930 zcmeFa1yo#Jy62q)m*5fzPVnF!++BiIu)^J;aQEPDK||0W1thoycMopC-8JYG@9pl} zxBHv^rhC@(nl-m7>tvrgXB$=jv-h)~&5wslSzMBlm5BqHm5hbV&d`#dpIOb_-h^4q z0AyfoXU42-U}oY(#s;~mA}DBLYYfSP{Pgaxvi^~USS1A) zw6F%5I5JCG8-Ps2O^ob}O_=3PY|S7Qb8@lq2nsrZ98C;tkUs<;=ps3(NyR^Wd3iZl z!S!w5EyNka8e`32KazN`yK~>I=YzhmsAv_MF)@)L=$R;9=o=K`92ny57OfYbrzS0n zC!4c@S{o|rQ+poX&)6;`f?ea=`#w_!dY6kCpYBeb=}-o)K|%?2>FV*hJnZVtiyH=A$BR>vxM{^UBF z-fE+DX?NE$xDCeA5v<}7!Y5eSTdBvLl#YA7)=R{40>Nh)A8%0*9 z>#nu7Gt4xki)L>Eyt<|urq}U3%z8tz0>o(eL5j}%^X*AdL#2sn($sR7_$|uT8vP(1 zow{3VKg7|6NB)Q|Tbm;uVDkt@lO=(DlVdMo2IN0DA~2ET^B#s!ogXLt9FMol8e5ss zL|m{M*t?*$-dbCRsN8P9@>$&My7;i)b{5yFwuox6f~A!z?}XBK}YKgG(5pc~sArs>_ZJ>bYI9aK>b~wMJL>If76b; zA@!)^-0@g;QC7zM)lQS^bHSGgXR1srYd`e79Kn9!81xbRyy-`{p)xS{`1v67Z#qI* zFf5+t3NDq`&3ElB^y6Lb<*!Wvh^OxPErrZc)Erj`XPCr`OI*0lLOFbN$Gd=?)9~@- zJwlgNe;U`elJU@kR1b>R=?pCvtW^z8_Rnm4)*|9KdC~g`=F!I!dBLcF+; z?|YAZN#n#;ZIgVwHg5|nI7aWD?n}JrmuX|T{YhRg>(gAhlg+E$B>KsxnyAR4_?90L zOh4~4Sn)8r%gEqTQ!`vhL@V@sE|m&MUZy=wYW{Bf+amS-wPC>0ODPIEcC$Eu^v(`_ zw_xq&So56rq8^tm6u7{bcAD3wiph9Y2f(rFz)2aK0^CPT+dgteD!fbhx z$QIE4JcF|9??`gjtE`sx{ z3vuIz)UcECAMpUE0S0T6%K%S8*$^GvMJmZ$st3@4uy9ENV4$@~1Hf)ts{SGwkSc!1 zcjEKtRMS`SxoYMScI^FQmL7#~bhUZ-2RVMQN^o*IhrGZU7*I7yAqV6 zmK5*hPix~EeCAiS{pgu?mOdQ(U-^To%;vklyCp4X&!(H*`hv(ab|>{?5ppnkxt@c@ z*Km*3brT*|zkD9E{%(q%++;ZP<2NpYM~|y3Oc&3@5N#2HTRGr|%A|Zc;0Jfq(9MxX zwd)*r4?HqhF-hm!!Z+!3Kxw`1#F&ZRR#_&oxx-q7oN`k~oVp3lhtu7&z&Jd*4MV@>&moGNYBTuS#0f6n+YpwkwQdo}xnyX4(dg%)nPLPPif0njkie@ay3H%fl~u3V zfXB#RC2|}mHj}WlKB+kD89mk}vZxe@I||o&gMcDJ(|YKHM}zPU1@CWiq8{>8hgD2X z+ZldcZW>k;tLk>yLfg5m8GfX{2f7!X^^2>? z;SOL`e2cbd$HuxXETBq={ob#4TVSS&cYAn-9Dq-4Psm&_#NfjG17`|@ICyq8OO4!) zKq|~&=#<`YN!7d>cpx9ftH?7zxF1C$(647>JzhEzNapLZC9>VTQ>L5OQ__4gz&OhI z8d&Ot-|CE9RF3@AzYeht%2-Gme z-Z_ya%_6sB@E_u@KkpBvSHAG1E`T@PdMDZQSX)m15@^%2Mu#+e{awh064OzJS$P&6 zPE4>xEpJ_>{#h0h_~FPU0mx7!wo}czhuE+bt;p4fFws^cq@nih_sPT!JTixiF5qNK zeVorcC07!$u~e%MLIKHm@XdDbW@r;Cq)|Fol4nQDC>3s6ftK1By;4iTicjD$FTyLM6(?q%G0m} zJ?;0kDo4orDZopE3V5Ez?FzY$-d2siVY{57-RcZe@8+kl0d6tIP<~x3M_-Y1SIhBihRlF(Tn|QaQBtn39ZF^ z6L~&Evt0_{N4LiQM}uDsyap?#!D{3k4dUZJO3+VF4J2G54;P)2RQG-zcYKb6%!80g z@aUoIKzQ$9KS`;mr=T6dbnEQ1z%SbziO0>W3>9zYzWr52w42Y2n@bFBtDh{Mgs(YqkPhy9V`_{ zI%VSbZ`R0Q1XdQ-7?*@EN(sJKKGl@C*cq3-3sktCNIRuZjxCK^bEC@9xx}O!EH2pB z@{jRKXqz*n28!%q6z*Fe6V#f}F+Z=g(z|q3eBA18J)d@#_<155Qm#npt?3joG$QW~Ob@(#$w~n$UnT9O;KK8PX$$K^$Jy-9$oL^h!JT2|6454> z4{;AYbedeB-17HPAm7j}kwnBnrzWNrb!MUAq_kXhY(<~*7{FOUmaB&WZvS_Q5Kht# zaeLOU{LZYcAh-i3WVtQ%M-C&$kR~JDZvz8kGnY!WELcc)ucsP{jomNS2x|c(y(ce@ zV;UrzF}gVHn^b-ro`#~jhALcr5nHOUzU_Ur4zpQsa;&26Sx84SP_p5Ckz14%?c+qC zCo44mmgYC&6`E_yl~BY$ppR9}WdCl-S`yl&7vDLNw>Ej)R6FN&PBzkex2~)@Im$js z1kVP}D5unYwW5r3r>5~rBcA(#EFMF2uIs{Y({}hAC0faX=uGy7J1_F1#~kW~r||Vg zWn!Q4i89N!7#z5D<=U*n-R9@c&wFfE)B38#C@A;EFVt|_p&uR=9H=Sq^1UnrjyT;p zI(Ve$ua|3@j=3x3MK8#6p*W5I$Fr^ET#LVlEcN&Y_>fI~~XFHE2MJZ?q zrj8eO;oh(j8mDBj9}FPwtI=lTnchb{u(?5OnU{QMYnkKI7p}vJ>a*Ef{vnjDic~p! zUgbQ&lqJ56nj=0|ci*j3+NJ_qq6QNc@sd)An{@MG;Nc<#FCCku@jrc1KtQ$RYg9_n z*yQAT{FxlmI6A|a&yA9rTok!Y6lr*@39@U~e$T6|O~dB(-n*n?!(dKm>zQWlD@`~j zeQpD`7n#;g%dqsMSg2f^--^=?!{|^+?>spP@8LeaK={Wm0*2S=dQ%I?II7(w5Sw;( zu7Tsu_Z^YTsQYPYs3cYFe30<_`6ZUBJ;Fs1vce{hdD^C(13e`K;`s$3TV@ z^pQ|pXXA$p|vU`ts=F)Vm5S=DZSfatZ@-jK1{gtQE8+M0`p&_NraLr?|ft5z>M!eN75ndHi&_lu>tNo-IaHrlu8Nrc}0ZmE>fDaoZIo<$&oM6 zA@ngn7f*u%>4+Uk$fxRO?yiXBnBD+M^%tSF6Q1yZJ5-A{;XeFWbns}@X@V-%v>>>Y z>f+WMC@5L<@tsH;iY@<+$QDi0tvSf~eH|W->)qq~X+-}8u2YJ9Y#+>Ogdof$oaoK< z6{RM^jLa{ip0QhTL$sV3Oi6pS*E{e;@5GgHIHh8O^NK#gu1;pDnvZ-wgbd^|GVnli z%S$HO#lrILEjLWyyIp7EdnS`FITi&GqUWD48vWN}HT05l)AJrnTPd?Dnkef1Vd=gm z+E*13E#n93y2dOEz6${yM|EPa;(qcCYSfdzBHD=C8(}z)Y-fnwov97Z#!03hW1=m^ z>a11~UCrUApfe-&j1D;4W#`nT7vJc@UY$HbksCXnHBon+Y$en^F%_GL7sAu5h&9WC z*wji%MgFGSR=ebsW8`nLeO>d_`16*g7KO*EuSV#QwG4RFxD=zU$^wkde-5Fv7cbl3y-HblI zKhAU$wPgTZO~(eg3FZVJT+3Yy#qw%BSCBEpsBEIZ4R~oHDyaUoUVpXo%qsqi{%X>W zB>NCql~vWbTi0BAp4@fgI&vpN{OcQYib!UrzK&2gDr#c5b+Y%R>2c$RjuoiFC5f*U zSJ7Z}`Xp_g$cAP_KW3BaHPC1Id66q^XgG>KtkoadN4_C>op<_ii$#w_>MeNNk@eQQ z&t|b#+`pKeC#Lcm+L4;Lu9;z%0LF6J#ru`&B| z{!ONjbBnN#bn?f2zZ9a;GABXokAUP`;9GF-J#%{N{2H!R_9wSVqYwD`v65_HuPFla zI&X^NOrQWbZMXOi!+kH{dz^E;@Lq1G!9Z_sCsBe#&T;sS$Vu5cOcjE2p)p7HC&R0K z58~_eKp6IVuw4SKlHMrJ?-ZgYajy<$WDKxr=?3mexO%gE6I)sBUeirs?P{lFDEY|u z@HoRhh8IJ>svxy>$xnQLF+S+sKHN{Xnfm5)JHu*VpHU1NHdvY>O_U~K%;WAwwa8?)KP=$2giqbQuL}z93#?t1aX7>w~ce}z_mc{2-0GDF!PYQigT@^9)kO?VNwqdxT# zVt)t0qcUkA2y^$(C8C2nJ?g4p$)^N90OP*=^0Lz$^u;G3mSBn?zx*trtdvWQ!--@W zvaNk1;RT)?QaE9u0=K&~AtS6O%|w&G&p39Ww_tsqHkK`gHr3x*LSW~=Ew4GbaEdHL zbIvZq-!)E7{iWG6E=Hvig;=`A1-K?a+xJsfKfwOi+cyTvKq@LFzip;f`F7PV3tY9! z=)I1TyzdX_d@qtQZ=$BC^h-q?#sRqTS-Vv7?@5`>U?VlP#sOGCSyh6}{MHe(qf&N- z=qnuz?m`2PI@!Y|>n+X2Z{~RKA@jjX(hTWOe}}@9K<3=7#O8~FbHPx%sA6U2-{L-D z3p5W+?fm&?$UkUD<#8z{u5*XlraOP7^(dYr#-xfJUGVQS8egpX1Y# zg(Fa`FZ6^Wz*a18gh2G8;jfs`6t{$-gqbQDmG8txv)@InZWsia@{qgfc2TFoc1Wt* zpCvjS3Y;K0f07o)EZf-*fQMt~liFAc>v@k&dz}27Y77a33=|9V{imyoaA4ak?*s+C z$H*2Kg(2={NiY#LfwX$O(PN~Zh+>)3l6JW1Z`Khk>^HwLd&9;pS$t;`MM1?BP zCf+dD>1IB~4C9!D8I^q=+xP{lfn-L_;N~l2jSAQIIE~1~qLthg{>|8LKKy{kg5?vb z_?S1-SdsDy>G^Y1#Bsa`j;p&sF|>!lM|P)2&#Qa*xQ{R3ka1|0bA{-ixHEG=I< zqJ*7K7X=xekmr6@Q$PcSVU1506B!*%BSjnrMy%4tQf85+kw(s?;T;YM#zIz;0OuR} zqp*gyM~*Dt08X5$riO}%q}+-vRyH;^0gBagyoK%S2I*gkKZMdIt+-l7t0yjUD?%$P zDp+`Uc;2xIvTn7f%fi*}lyAzIyA&E_DqF}$&Vn=dC&roA=g)uT9_(!TU#W+t^0xabrd zyv1YpQTKBcNA-9EDE{3D+7QGr+}Bjf16koCZ6g=M;ioMWhX^GDwp?#CX!j`whlcuImo&cr zJcD~P-LkfP?Taf)>JD(5w!9cqY4|AzyHw)pa$`!nPl$>P>)qVEwzhWK6u{8LPTp^M zRb6`HKIZqW$)nY*$~-@igexiL&FPTqjVPhS=B5@`V@rAT$kuX$GfL&kO_4pwK?mo9=SScz&NZbH{Y+)RbE)Z1dK2{7 zER46sFnh(b8#b98s4Btmp>7SHkqB?i?Oy*!C7gP91%XRzMT}vx% zZLOSY{W?iW$vj;Ha-sH${cLv6LF1dHIQmepj&$_2l*a(zJ;hAEX5A6;Rj13+3pY3w zP0bIvn^%o*`KexVUNEFEEguE6NI`TXENb^H3NH8;Y&zlVFxY4ZLPC!&N0gd~(KGvC z?{>7aDyepDK|ha_^X9E{t+oIvS4(XG|LzZu?)n?JH&RpI=7Mi$I=E{O@P3s1@N*iD zR;28pylN~>-o(QeTT6Y-edsE(OnIy|6shoyjlK5UDEa4#f(EVjl7a&2W8{#C=_Rx> zPHMcfAO=(`9#i#;>|hwhAP&&!^)(+nl-bZymjwDPdo9kz4(ULLM3b>;(LFOel@%)&%8Yf4mi zzlNoukIhKLAt1qYB)`6yvTEXOo{|lJoXB)JGriMEO@wROl?G@*jLY_rYGSh zv8mtzhDQcsTCi|2@qE_iE_6(p2ovQsPn&jkcl-5?b)v#saNJopZ=!UlRUb+Z4-X^H zQN8HvXD@3m2P5TIa+DqO0;l~M(2?Q8d}(NC43gA|7ez`4n7sYK@WS3K1AmR#gbg}(Gee+>`esQ=^6u@Gn=wf0iDHt3m2_j!lEC+KgfDbjio;-UGINUB0eA*O+4l}cRr7N8QF4wlLt;2 z*#ve|S2i@v&5ge>E6f03XuZ6+uz9J=?^B{WbVlL~&amRqS3fDjQo*E_E3wzpNx8wJ zhsO?gH8wOJ9PAVMAWM{ynOX9+G;elh*W7YiZCaWw;e?)_Bl2*apN~&l?ZfrKG)*p( z-p;{}L}wJGLX^UDpN9smxjvb`Ud{JFhuuyYA<|_o+xfwB)ZVFyMHrN4vYwtED>!|e zNWlDpykB;9N_N!~W+%x8mff~d3Xw|3Zu~gmmY(izxj22AEa^q;f=LUG9$KGTqHT3; zGS?9Y@q1$~;lfJFK3mvW1gD}+6_+^KTp~7x*yNOiHo~9i_bZ!uF}@LAb-LP~yFd80=I2Bs=?6_9HP4p}z?GMwky(em@c~jtzi(BUmkw`n zY{m!X295`n&=Yx2u)b<_Ko|=eS7vwoRsQ?OwH=N1Y460a6L%sZ?d$o-r#--TPeh9 zb`PR7y2vn_n8^O^Q@6Z7u$*>icDBb|9`(w--d&6E)2!w!MI{R`K2W+TEGRUc&l(da zSegzj9~GH&&fXzG(b3f_U4^Q{H2rf1He3tRn}9&cogfws#E%?Ks^1t=z|zsyX#g@7 zwQMiMw^;Kkx;Di8uvL=eZHHU9r)V4_R8#e@0QV8?hj3w)r&|KHu{S;UrZi~pNWC?- zu0+yS=r!o5c;UK9<8m5H_<*FE?E?}o+Qen=$(HQo*mur7kCEG7fn(l^@kJJf6{CXf z6{ytS42xzJDV&DKWigE~P=yR9ZD>4}dUmjrditCuZ=3|4Q?|16+H*-ObYvI#G-u(3{97#QfehCR@Yo5CYMey{w#yLUjz zhuI|Rr>vqTpX?tA=)aw>?Jj{#NFg5ZY)$Q&>Pbnm!xYa(UX#8KmCW zQ`Cx;ymd1xKfiY~e9@432jBi2D$~-2a;^E;9!@-UcXSyH8UeEnu%=_bS%g3w|^; z+FSp1z35JbgRGmeLsEW-z2^F*cPd%q6|1xcn}M&;vv*Wf)SJd;UliIf1G(rctjYq< zEz4T0)p|YJVy7x zgd-tgqT1~(T5PgL8XHwDm#h=;yONTXzR zzvbGGuL&{Ty^)H``czO@wzshoHZ29>Sw7;VW=Y?%u=wrnF5Yj#)>huMun>OgIGP@* zdl01?74`Cvv$fXbUe*wN=N9AZxpQ#u*p0m7TyiCN#m`#HQ|l0X__?lVW_&^PzVuUi zgw_5~I`1qmK_u#N&-mcoe(}Wf79aD}_`#yDX_ZLUFP1Vp{o&zZR}EZTofB}|q>a3s z_IYL`lApgF=lMcHfJd$yJ6Ft}LXl)4!OzOb88wPRK8~=ryDQ8kEoFy35>$7+p0{2x z$^xV7ZS#^bfR|2&jwZ%eRktfXBR(Vb?xJ!nzYE!gFSIPW!4WR%MLW{Itc+rt?wf8J z;m;OD#bTN5A#z)Ky1Ej3KPH6t0uN5FE+`A!7lg>L{PRWQmF3!*ypofWMWvmzwd1B# zOOSR2sxI^B_Lebl-&F#SZQbKFPLYjq_mfbIW0xv?9*t_j1)Bw2>fE30X&E_(dL{N0`wn>hP0Nn*D4*CVy)in)dzog5MXs%{~%6ZY;EZqjHjmk{d{?HnCr84m|%W@0}* zf3>c?UG!7bejb^{cZYdtSzpgP3DbAbv)ezX@rvbhvp}=zsz}jM zblT32|AUVXHlw+N!P?LzCP5psU5A$3cTDNG?m(FtICbCY5=K_30fC{d%}bF&iH7`My}y*w^>_m36dzX!2kFxiP& zl4BYe8HhYD`RbVC9zC{hHGhXLxdU#tIJvl0l){a-WF+D6yJq$q5tRWz-A5H@5+F6t42pj|#*YB^Ne7>Q?R%bCVWV{^VQ{5yZ2U%m@uiBa1aL74 z$O{)dYqGVs%)#)8+j~~tY+al5raA9X>h@J_W{Vs$pWo@JV~Cnma(FR)A!C*3P|c2B zOcvi=@`gij9z}t^Zhvcy0~Ia5{r1rj01G$%G`ug0@zi1EUisk0AI6jh=X6(~@M&99 zlGoK~O$TdmaPS=D>U#VxB&dB-UEOm(B_$aa1}4+X$5(GT0C}uddSUkCo%EU`%CkN{ zePV+~uR%dh&dG_ziOE?`ets{hG1`vP)2i^ehp(^br_5Y)OTXjzf8M1>DSngEHE48Q z!*7&&zp7W(szSSX1rbH856IeMA|2n^)ipFE2x|}L&ij5_aacN^sw$ybVznRr)z4CIxy~Y9+`_m6u36#+ZSxXIHLXi6T0TRgdgLpt6jal zQ&Wv7LJa-=0*eCY&Y&AU| z!FwTTwl!>YeRK_T@20`Pz1{twKGD&4E_)gd263OWeB8`aVZ*YU&z0JKMmPBK>2yl$WSHW`^d8nMne)uQK#f^=>Qq+w^s;OOqSmNrFL} zzkj>SIYnq)&Lv+Mkbjnh4@rxR3=a?Y_iyyxoGi7pd0F=H7*Q}A9Zx|)@g*7>9zJe5 zVS1-dEBPx7KfBIfb;`6qC#H(Q%ldP^%D;X)Jv>kM1~p=2goNaEb`GVM>S!o>s-+d{ z4z!Y>yt^*Qw^l=xgL81s*LRX@w(aiR-rJ_r>4e2MGBpgJe&c;?sm4LcNX$GS3~2o@ z#>3WYkyAp`>d}>S81TI*FDIg}KZAt~5CuDjlZFff5ttv09-_ zfoEGU;-qeRqxQIeQ6vq@iefued`q}P0YcnCe&cmG+l&bW4~I9L<1?_LD2J1iX^N#6 z7>ntbWOZhH>zJhY=v?vMjaw{`S&tl`mZ#xhD*+^7&)G1!FWp+IddI|AsbBgFmQyT} zOZ_=FK7hdK4LZO54pOXUS{h`zNcUz>2`R52FDs=Ay+f?Sw7cNt`j-ZZlM$*JElnrF zLoMD{MIS5dov_(|?_5Eausr2nV(II7#*k$U68IS@^U-x3PjK2wti@DKmqeb`yGx4E zI*iw=CdHCvce18V;zKE>rP(Bo_c|V@ljJw}N>;6efbxCkft+vOun#;s#x7CeVPt`) zc9xFe!D;oU-eeKT5ijoUs2(?sw|*4cd?1C)bfA=KfW(Y~gFhV(m#=uY(T$I(X520rO$B4`sq#|o!22FqlvNqPkRQl9{0@5jjT1IYV& zW&JND?L#r1@rJ1?6Lo&DjtpRA!mJg=rUyT3*A5C5Eru(rBa2R?3%76PZ(b!GU355s zCHwvhn>!Crv^_F_=jc*uWZ`LV~nF_uLIc_uD&6SF-7=NU1Ge*)2%=_tsET=y?cF)^GS4zJ`qSEQqVo7n2+ zr}s?0=oDGFvSX@m5CJ=amN_k>bBTJ~(?CGKPJp#-u+M)*YfpvD3}66saIiJ8pYRdb zT=T8g-0OO`?R5^ZYnH0`_}S{~o5An#)L55(K}Oa$NVX9|`I+xQYKiC82Enl_xYmGz zI@O5MM9<*GWO>T~TLf0iuekf$!3=g$03JkS=h9`-H@l}BDE{HK6z=+v*+*^2kc#>Z zt@^1|^UJZf#Alzfy|yoBm(BG^TzD_@Htk-F2Xbo@4O0HxHyxRK0mfLPT$op*g9Xo6 z*W3XN$jM)K6AB0jD56E5YHZ)tpN%K1w3&z(zkKa%Bk;;^Az+0<fFo=6_Rp;04J3Wuem}Y zgP@fh!|&KUh~sZpm(80ar7x&2tgw-ZEqqWR-*jRaF)Jmcy6Z>nZHKaTy&R?lCPn z7svP7j1ZLEbUu2zpvxV z_#&d}kyhcZZ>npB5MctADK+pUCe~2!=kx^Z4-p79mM>g)@i*}?sL}}1f9bxVq2Aai zXI%K$g0*38W~cNmfdmbH7y%w0L08#F#1su%JV_`T|KMN;ZX*ydB5k(5wPD7>rVlJ- zeEa_U`OMYbT~l#f*m=nJ)9sxdcmeCOQimak1AK`j>)1-Xh{t6wIf7l6B_|aiCMMPm zAM$#sODv+H_!qb6RTqw};TLi$;f~OS;6+4a{unbf-=6Z~xa=C(!1|TC#~l5N&sm_r z7eGGdL({iKaN(=iz5{l0ja60c<)$U4C3qM}8zviw86}#feV&_WxXSG%)rtTqvyD9O zy2{+76NbnAP+AoYOk!f@$EIp&ynwM1d*D)Wn`S49UYh9a`{!`IHWLCv*OBo^^PZyDj+N? zv%IUu5VEt=XBRKpO=(G%K6-e#i$_whn>>cjo~(hF0w5_TohBh6bg6P}BE(&6TT@U} z93CEoxR_9a+aZ1}>$um8CMuR+V#ecD@T_b|Uw5YZKWt%K_A3eJ3yZwD1eutablS}d zXN$?!kL!|kLCm9d-%QBra$tOr$kfutPC#$@y+r0Q4z0A2i%U(y^6cFBc(+Z$9Zw=Z zbvF$Ukz&j^DOKM8*9PR@ZApeaB2c7d2mSr#?GtMB?_i7A{=w!glr4g?MgK=}BPd%0 zWs9I}5tJ>0vPDp~2+9^g*&--g1Z9h$Y!Q?#g0e+Wwg}1=LD?cGTLfi`pls3qjcgIu zzviC)r`aO*f8d^mvPDp~2+9^g*&--g1Z9h$Y!Q?#g0e+Wwg}1=LD?cGTLfi`pllJ8 zErPN|P__ul7D3q}C|d+&i=b=~lr4g?MNqZ~$`(P{qW|`65%<5gdHYYtE#mkGo3~K5 z2+9^g*&=A%B52$qCurOvXxt)b+#+b)B52$qXxt)b+#+b)qQChI|4(nBaf_gFi=c6f zpmB?!af_gFi=c6fpmB@-pN?C^^RKz5|7o^}^B=gUp==S9ErPN|P__ul7D3q}C|d+& zi=b=~lr4g?MNqZ~$`(P{A}Ct~Ws9I}5tJ>0vPDp~2+9^g*&--g1Z9h$Y!Q?#g0e+W zw&=e-Tg3aXZQlOVY!TN#*t~_ZMNqZ~$`(P{A}Ct~Ws7p5Y!Q?#g0e+Wwg}1=LD?cG zTLfi`kdFEv%gTYE>$N!i(mgHyM9&>4TLfi`pllJ8ErPN|P__ul7X3fX7O}GYYyRnf z8ZP4g2mWa&Tm*%Spl}fsE`q{EP`C&R7eV17C|m@Ei=c236fT0oMNqg13Kv1)A}Cx0 zg^QqY5fmxv9kWJ@7}UcZO z6m$YPni$w1e+ZU{A5p>n0=i#iR#xOjjV?*B=eI7$+ zTYwRw-94*J$@XW7+L5pAShVVDS~qQQP;3XgMm z%0h}Woz+opBo#m5AQ&)Y;&LR?sH&U5`j(iQ6aOEQQxkZC`Noav?epG?W}(|OxDTv4}H(-8=U266b&-G zI!F7BA6{{_fNWxG{3i`T&g={=ky+XPEz0JFDjTZof3vdL|1HX9Wr0c?D(!!>v^oAg z(q@H98!GL8v$Q$?tF$HUY(e5CPDYLv_8>b)=BKxR*vcY~76#UDMeVGOnI)|a%$&&B z+5WgGDr)DZ&B(#cOUB5}%|XV>!NEet%EH2>D=7G9i-+s)TVPgoHUzobn=q?6I-96H zo&3>=Bnz{sfs@Hoqs)KRpoFcFow0?j88g7bR>aoH;xF$dEgYRdV&(>pPrZ>h_|rKn z8yh62G05CWn~j@??CD_V!&Cay{-5*xIsK!ar~6NReJTezf9m5?`lmzp4?X;+b|A|!$QQ5jpE~%P zXte)OY5%my{w9{d9}@bXh>Z1bM8^CVJ^e50i;EYkuYbS3AX-o>?!Q;|la&8T*?;r5P?QbKOqi7o9G_ebPs=?OW(5;t z3xlVT@~OX^yqrvI+>q*dI9Qn=qY;lT#BE^0e?yOimC;engNeV~_T031bbOsFU1Kfdrz4|o2wSM+3ga!pE+k0~ zb0){8(l2%Mux!Wzm~bnHl1sMMt~*`5SLDcFE|+0^ zBQUO&EIF1BJ+5y*b_{%UH^7n82S$1xqsOsS#`%2}w+07AjCH{V%Y#N-AHtreu%yKV zjR=ras4<80>#^u$c)#K&b%`&6j1&`FXA?NI+LnEVB`1llwCNIagR@CXZ1w+SJtQtj=6j)b_5k@!3jm6xv-_4B0^P8Bl?ALPc7E9nNmp!((a zUvKG4=;g_9?@m4o>XoQHo+PLT>u0}N3HEvBtcmm0vA*=JmlOOnHx2Rgq-U3i1krxa zLMiQONEQ7MZxCg+eO|y`7q@XZ#BKaWvZXn0<%!7^6KTJz6&WuhM#dVhp~0bkbF_CV zv*Ym+@Ky0!)4S3=`*s6)mEVI4$7cCMx=!yFxy@~xe}%9U#!F`h>LeV8BqMG|ET$Q; ziM#bKrNDD9z}|7ekmM)H#56IQp;WW2oagv(q-n5oJ5sF5?X>UobWTPf@8AuG%2iBv zQ7A>Ep?o28-+REaoiH5BLy_82VNo%i91o61kGs5hSc!Oz`@G)1!Y}X3*Q{iAsRYDh zZN$DV8iX3MxbqnurO0+_x&2N0Yk?uBYM0NEq2j0Y*7T^-(=M^15~u)8b)y2cnLD43 zsnI2*+#u;DYUafvB;`&fd2)+Z%QCftZZZ9%N@o2x=Y^@9yVb9mpEn#|i}|D`GDp{T z7rGax@(QTEV0{xhP(`P6K+b}XHy7$WX_Ff+$+sjQ@OY4~d{dgCm|iGfZ(QYva~!4^ zL7({vKqNn<6umG0L-#AGdYP$me!U}EN?$COZTPNC^#Z`+pj+XDg2!y7UZ29AJetb+ zx0*WcV1_1A>Iq{>^%`60Sga^hiscRBwI#fla=;CChs^|8B2yf72RV5joIaeRYQ`}i zt_DGDkg{Q}O`n!Cd0EsUli@nXvXV*2y)bX6#n%f$PaWFVlr&B|Kl5J&d{~jtD@GrC-AnVYN%J;tj^!sl(sEM|+yix4~iK$tv@dyKd?_*vH#?k-hTQoid4 zxL*kG5vb7pEWds$yfI%B&Y{fL@=_#?OjU2*kQ zP0aId%cRUSsh^-7H99YHzVE`1)y!!|zr!1Be>9|W?vqUCYwS_R@LQ6+9}Nz^ZSI*k z(#-T3!R1ff=#R7U)0Scii3-b7$S;s=i2%hKQgu;cBOTSQ#icbM<0~YS_9X>&!+NEd zN|3TJd0_419>38y<40=5nxzPt_4_zCL;c0yw(lJQh5;$p_=i*jjYZTr9ku|(8@Fx$ zWKad%nK^>D>Q7mGi^F!h4GSRgD^}l7Ohi2oqSw(wKd71IY@{5~`SxGIc1!hknc6?T zFZqew$s}et4BrM5!%jf>s}%qrBEb=^uaeeW$8QG{Z395P>F_NTV|lyTGIAX)L5;Kj z!>t6Lb^BXCp4^^ayRru7lVrlC8^#S7G)|GVJ!=&zr z(3Bo}3is^QmUU_3UgEAJRn@D=7cOq=5}%QzbAOl7<5hbbV>T%nMZT4P1&j?!2L>B` zsjuI>#3H6XAb4Mg%J1T)HEr@%&n;lU4I#{R87=ZkdDUva*RKNNmPYU6Uw)^>@z91K z9a_(*_mfXY{7uF-MF9Vh1gu+b1np^2L<2gnPECD0#jWk5^Y4|Jq;3(S(VGddCJF|+ zNh3DhXq;;gGGvO*$?5LBcy20V9m8=>V@5;r?HT>;jLapbkbzfjlxho}(P7twVp0!S-+U;dya=|(#w2=OkyIW2X+)#L-S+EyB}Bc5 zo?I^nwoizaLPgwqs%Wtdv6vNzL}i^RMxnnwE;BZx0`zpFC_m7w9b--(kcPjlThb0_ zxkFCbaSFlGFx||V9>sBDkI5uPwApD5+qdn5z1%yvPT%j$zLXML2Dwrmrq544aAT?+ z`r2oLlcTyDoE8-g<}gZracP@u+8A~JuFE|Ew+?O1s;xxzTgoR3b<~CUCJyAbyOmZA znTRO~JfPMpMERwtOv~Mgkar^%B@=9R%v+K8-+7JAJ@{6tnO*(8vzhI0wo(3f43qyb zWd0Sy1hPu_55t6=><_7Ei`& z*U~35wKgUxtD?=x9E+-{lMob|!m}ha*SZkGWIVwJH>&DhA$E2y!hOR*`0Vi$qU>O+ zpGU@ZqPWaL0Y`AHS~d` zZAHnlYN_?&w?}*LM34tXIE-lXt0jL@{?A@k&qJ`r0E`(*!}$8f?`yn{3KJ{*-cDiv zk5a8X9?G_DN3xa%A!{Sqvd)YdMwXCaN|fDXn;1*>eGSQ26GGN#u?=HvAtEyNCHvTA zjUr2AUtT@$_r1@P=l#Av-uL?J{vFqO-@ofRf7kE0&igpdWk~cobqK3UdY6!F^`7^{ zY^6v>YxDhrb|Vr_%-?X|%B9R}8~_bD8_?ppZ+3aptN~S+|5*b#b6Ubobk#GN#X|3! zsxGMuCek1^jgB;yRmfAM9#u3LYwq~eB2u8w;jVhPU)g3+khkTYg8SBGI>Ath!sT5q z<4-mj6nleKwB0ek3myJCIR)aG75IV7(KW6FXdN*tl?-L(hmtof--xo?j(;ZW31mS^ z4V(HOh?OE@16X!jTMf^C81TIz-pM0;BW=l~P2km#fZLu(YKNu(Z!QHNFQhd|pTecx zV$M-H{;rq0x0hwkh4BmgF-Lvsi@nHGmp49(oSIS<-9!|oG11QuTHSG}(@$f{*h=Yy zS>IqnL9XCIL!bHjhI*-+w7gv{hAPi*r&Q(|bUR!YA3(tjZ*Rhv8zLv~5OAl>leQi8 znJCZa38^pKeNfSb$8jN$w>}EaZf0pe;OM%ML&Op=2!~WZ+|Ad6*LXF!FY}IJC6`>@ zzZtbUujAu+-y)1&1$Oo+udkP7v|aaYZgaq}qm6S;Mp_$UVK04^*kD#WJaPVDV(*gO zin3vbUrZB>-?5# za^CCBr3cGIF^p)yi^Xgs!yFkvKb^1h&i!+0r|NI}E#U9!Xs{lhmSEv#B3oP=761m; z@A7|$0c!?3DqCb0Kug9JQO)Jo-2i=QsGRVhKz#)> z2RehT42$Q6#o20ZJ=Fu4n&Ku$Iwi9G?7XR~O?M1G`ZIU3NBC&xO_lfuhSWbEM?F~^ z<0%VD;+is}4cSXpW>h9NT3;|5t4+`qMc37s-7}OKDiFhSiR6l?UDlM~UXo?1SG2r1 zom*Ngg5bYX;m5OI`|53-og7h`Ghb2`rHVs`<&z|ubnPxeOzZ_Si|{gS=Tz5jR<#za zjBsp_G?N&Dd_fG$5yU#sGorM9r%Ev^|7Gqsa0=&vF*O$6q@k_-7({@oE5njAdHNF) zCwoEu7eIrdEA9?Vt%=j>%jQNHU&GU_U;uqGr*m`e_3gdIb5cT<$##icAhrBN>p z2wQmNK2iR$$lV3bRp*5e+Oe4o22u|%%eg6a%a^7DD9FAWn9;ZBTfgl)P+U=H!IFk? zYnMH}d)cc7(&3Cf(ZHDj{!Y4|cITkcdoUyVa&ng1w#pFEryM-s$s1~K;xs<2EQI52 zE9G_yp@x{MpwA>5n*!1(nuk3Ry%X*Q$IpOtzNp3HITB*CG$8NQJJpIXIk62>t4}sU z-YiC4ZAG+>IA3Wg=~H_>Ij8nK*+lNtUQ&F6Wtuhi=ow73u2B|iENF)wh^`6-BS|Gr zGpd_u+nrFujHA1Hy1T})D|IVP;dau_luKnB?H%7qHTZIy6~vRXaE3kOwUlfHvj_9h zE?Ib8I_=9$-`6&g8qYZsesa_p(SOc1gHar6sr4-fURdsjDURsQVX#}t(s`@jA-32p zn=oNn-rD>D1j|=iA6lIyE~9y54mK7+51M=^{Fs$i7H-l%Vx^bJ(?5jK_|(ftGHFq@ z)@+&Xr|0$-uBrqB!tT}d5LFGVzczK>GiKT`RthvL53OcyW*U<(_2qa?vx%muMe+!Z z>)CU=#TCE5E;Pasg^3e=u?#AToyxuxyPJzBcazdjY}i;~?c>-Q6|(!LqY&BxzXa@3 z9~$FCz|uG^mzXtX7N|8~1X%s1jgzJ022l z@a)h~Hy2xE^jx2OXx)_3L|JSk77F+87Q zNdU~~`mt62;N{I7`UWGxZL(AuM1Tw1?IEFhX+=VLL1Q9cdjy?CFHZv$=+=D8z zaPEq}zALMV#r*Vs{%PkcA`R1SC*5A^ZE9>((3A1)a!aF**Q$hxDSC~(s>-##l7l!WWOU=Jw6&G6q5 z3w~;Kv5*rz$@Jt+NYW+>Epum^C>AfeTv&YJ{%3Oz7ZtD4up$?3~i|@on9Hn#)^Dc5CG#Q~Ej0ap5+XXR-Z z&o4$YONIvSOa$)|1{D@aREyd!ws89GbW;V-7jcx0s$(N*8gdgTST)LGdWY;Zm}d2? zf49&pRSL;Q0agY}k{uQ(Jp99W98koDhq1UX(PYV|%+~%-;O2Jo*+~FQolv$~Q*~9PL^_rOL6%6w<4*CV!|HKMSik`H3pJ+Z}?sNgpOx&y#V2zNRv^%zlxQ%Mu89dG78Ru8vVTBJJtR*FwEN+6gXBHYLN&UAu)mh`iW%3PSE zvtSV6Dp{Vo ztzH(nQsaxIR|=`lVr5j1VzRTP*(~x_jzutsRTSN-s_v(@RGyp_&UzR&)&GnR z$Cr)6plU}Bowb-FY_sos++RzWVQ1%FMOse6`%r6yz?_^$bLzdS*JR>?Wi#8lOO-mI zrs~r#Ps>xk3+mdrC1$ezD6EL?p!3OI>eqvVbG+clenOz2sq#CfLP|PCX7U0deI*qk z+4x~d$c2h(-t^c}_ORCbtp*j_0ji-~sqtzhZl#>1C_+%QN7>;4D7~F?%W-*Sv z&y?M9wH05M98y7C=?$c4|bZAzL-q1H{dk_g5zvedH^#ks+$XqjLK2GT)sXLGjQ+9C^d@So zjyCdJI6)_HOX7Ja)2ljl`fg=JaZdNEf%g;D=(k))smhZECXT4Mhw@DR4fCGxCjT4e z{ik&PpZvbUfWPE@EpgB%6b2Yy7q+y?h!gY+>hCS=Uv_31bV-eplAsA!Q&gh7F8uA%Pw{J~ zjw_5<+vMdQC32dvO2|9UQ*xA7DE5#<8_UbaBqd;j4T$#XV*1;96o$*r`NC@ab~>}KWZ98t*0o>;~lFcE#yS+-`FSNL9NDj ze(Q1?WsWDcOO9W)W`=T@*1J?HXU5yp(yBh*^2lkxB|X(rf=w=psCz9j?^Q_q=Jv9$ zlXHc_E?Gwr3d!IYuF`siZhac#f#pjC7D##s#}0@JWBQ89U4l14db%Rt_=8CD(S7K( zvNVp#g5}_Q=eAS@lt8G0@5;(pvw?#(o|qQD(>rXht|Q?APxn;VU$$pW@;wABNpw4< zX2y4_PWbPeI<-o&J9RBEN!c2pvsXez>&ZO$&lGOH{9rWJ9H@?$U7hiNDMOtsBWwHQ zwNz6;$Y*uWd%!FoZo_K`*S+b(i_IobDhG+y2(kmlTcxi>pQ6$!wERctt0Q09Ly+@3pgEQ&Vt=%Msr2gNGcPS^kf6sO+3( z?R{HwZ@rE$xT|Gd7eXOy`O;!LkTzF}X#>SUsxJDxCx{Ktxqzi|qODocd`E6ke5b@# zxVuD~&sElJ7~0P8tUY_;&zU=&gPv-0@{Nep5|3w> zUbW-H%G19k(amXTCABkY>&e14w+Nyyc0s6fqWA0#pMvj4jd)53Mntf%qAJ< z^4VCFgBnC1a|QdVI+)Ksx!XLGdnfzHIXa4L z?eofLBIdP3YiadcuzN(bHARVjvjyJ6DK#df8!pXe8R zv0N2}%G{cJ4zME+zQXHrORYz|%%$LJewVUVYd7{{op+Aqa!U`VBlofdp6#WSl>J-Q z*;Rk92lL;z6Yod9DjMY+J3tR6C}Ta2%nkW@Boz@bn;A8V(whG8EN(u75BMV@w*Tl6 zT~GguUP`gu(V-458Fqp<@%xvtk~7T$v;qDvy5J_jL+i>%VSa(hk4!P+52U}ZCRr`8 z^f#DZnye~?7i5B=^cM-mr>ee0eEg{ruk7PJBCswl3!zE+J9B!uU@U2Wx%IRX{~ACT z6bgX?ErEYx$Hew)03H6spvQXL2@C{2=C$83DEt?}{f2=cQ1Eey`a1@P{BkOP!@#g( z@B{_{|LP_9Jx)>ra=h9T7z_kHX3^ii2Zcx-x15~7BqgQ(hC#u{J3SEx{rg&=Fc|FQ zTA(ngV@LSJyHa53zhe-olevOQOZ>VvevNTO3RR{OeNrEeAD@!Y`ZR3Oabza1^93784>3`jLwDR(_ Xu5M@-w?9r33`CXd?ar_X2EU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6280eca58afa2defddde993cc9f1abc373ed2244 GIT binary patch literal 66777 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 literal 0 HcmV?d00001 diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 8f7f5e83..bc2d6f32 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -20,6 +20,7 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.package_scorer import PackageScorer, Score from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation +from domain.modelling.roof_recommendation import recommend_loft_insulation from domain.modelling.simulation import EpcSimulation from domain.modelling.wall_recommendation import recommend_cavity_wall from domain.sap10_calculator.calculator import Sap10Calculator, SapResult @@ -79,3 +80,22 @@ def test_cavity_wall_overlay_reproduces_the_relodged_after() -> None: _assert_overlay_reproduces_after( before, after, recommendation.options[0].overlay ) + + +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_loft_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py index 8acfc5c6..f801ee7d 100644 --- a/tests/domain/modelling/test_roof_recommendation.py +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -46,7 +46,7 @@ def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None: 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 == 270 + assert _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 300 def test_already_insulated_loft_yields_no_recommendation() -> None: From a0b6a952c37c65a4b8e9f6d69351e552670d40db Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:41:54 +0000 Subject: [PATCH 022/190] feat(modelling): floor insulation-type overlay field + cascade pins (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes #1159 end-to-end with solid and suspended-floor before/after cascade pins on cert 001431, both closing at delta 0.000000. Adds floor_insulation_type_str to BuildingPartOverlay (the generic field-fold applicator picks it up with no change) and has recommend_floor_insulation set it to "Retro-fitted". 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.py: "retro" + no U-value supplied → sealed). Without it the suspended-floor cascade left a +1.40 SAP gap (the floor stayed "unsealed", wrong U-value); with it the cascade closes exactly. Solid floors are unaffected by the seal logic and stay at delta 0; both Elmhurst after-certs lodge "Retro-fitted", so setting it uniformly is faithful. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/floor_recommendation.py | 9 +++- domain/modelling/simulation.py | 1 + .../fixtures/solid_floor_001431_after.pdf | Bin 0 -> 65186 bytes .../fixtures/solid_floor_001431_before.pdf | Bin 0 -> 65950 bytes .../fixtures/suspended_floor_001431_after.pdf | Bin 0 -> 65196 bytes .../suspended_floor_001431_before.pdf | Bin 0 -> 65959 bytes .../modelling/test_elmhurst_cascade_pins.py | 39 ++++++++++++++++++ 7 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/domain/modelling/fixtures/solid_floor_001431_after.pdf create mode 100644 tests/domain/modelling/fixtures/solid_floor_001431_before.pdf create mode 100644 tests/domain/modelling/fixtures/suspended_floor_001431_after.pdf create mode 100644 tests/domain/modelling/fixtures/suspended_floor_001431_before.pdf diff --git a/domain/modelling/floor_recommendation.py b/domain/modelling/floor_recommendation.py index a6992a85..f2360677 100644 --- a/domain/modelling/floor_recommendation.py +++ b/domain/modelling/floor_recommendation.py @@ -21,6 +21,12 @@ 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: @@ -75,7 +81,8 @@ def recommend_floor_insulation( overlay=EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay( - floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM + floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM, + floor_insulation_type_str=_RETROFITTED_INSULATION, ) } ), diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 5b2ba8a6..7f9b7469 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -24,6 +24,7 @@ class BuildingPartOverlay: wall_insulation_type: Optional[int] = None roof_insulation_thickness: Optional[int] = None floor_insulation_thickness: Optional[int] = None + floor_insulation_type_str: Optional[str] = None def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: 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 0000000000000000000000000000000000000000..ea3ffbfa79d94228dab26e12493be19bea8d6049 GIT binary patch literal 65186 zcmeFa1yo#Jw(lE3f;$9)2Pe3@y9TRZg}Xyx!GZ^O3+@o4fFQwxy9aj*?i%zJ-|6nt zr~AErr~8iEPytOMZSPHFtXxCNTq$ zfwi3(ld^%Ci4!R+rxwWAK&oZ}SQvxMAq6orNt;-hnS)4K z*w`U;S=%|P+8Y>|Fo~PESQwe8NQp9uT7aCCO&rDSZ0zl9O(1RIW>Pe;ft16{Bxzv{ zGI3;*v^D^lh?^MM8JjT4o7kE`3g+Ns~($VIsvtl<2% z?-t~YZjHX~updq|*xkAB*7HGMSX8tM)tHFT5cEtGH<$v6DC;HBc8k`F?5Ro1;>qSr zpw^~}`qbV>uV<_m5`iy;CQHh{Ua4!C5~cLz`Ty0cW3^k6|G|Cp!c3LTu8|zIl`uRw zNpy?xeS;^MBSyOEfPjb4_LC^yQS~d85Nu#b)4^9BLXE*zU`##eJas@S?~%dh=V51} z80jbGe6%@Z72Sq;zhEpmrp@5i7JCzSuXP*1)T;f;=G+{-`)^jCpskKI@chYjCY{x0 z>(cJ7S6GcRH+Ip%ej?A=^j_Wi$@h#hFOdAOOHP)tm(||T^wjEN&+e~ZCj(uq+8?P4 zyiTQz<_lYIfx|vvyW@z-sa55y6N}ctXd#WCoi49g@^ek}rM*woWGJr__vUz;Za+kI zJt(VDIlXk!slcqQWk&0#1Vu({<&~ob!<6}Vu#yNeluK*R{d5*c2)l8bjIdE;ak}nW zZ#%y>*;=C?#I5uF z*80<{(S=9;ur6DhBW_^x2zrwxzI~HpFF_jQ7c3!|(D8W>eX!1tlYTP1r|bX5OLSuU zRG6uiGrPY`joBJ1al(!cTMh`CvD{uZAxdey`FP9M)2&*N5mNlHQ-5+i-Y#owWkwx# z!D3+Vg4%j(Z5gC;yZzdG@mJTyhyAv*m{zq#WQ$cy#Uza-w{f=mb4xD&XhPDj;}+LE zK6+rPEV;~Zh0mNfg)KX?c&qtIR#y1kSne;Ic&F_FN1mymf>K1U*e)$v;hy`OcAQPA zM;+&m$FhsEGN!L~nw;4Mxna(f>6q4^(DJeb`-P*>M)2~cA7O{ez+B_!gG|3^31q=A zxSFdtl%6-$+FNMHyIjj(oBUp#y63kPGDT3aU%{VY5G^io;y4Rs@zEad0(MSA$CvjA zT-JQ4UE4~=gAbBFlD|o%Z?RyhYH+fDX4|tK7Q?}V){j4rHXhFdrr8v=huu^Mt~Xcj zJ@zGx6Ir!Q^6}WbE39B2y?eSZ@uFX*jsEs0S-q@xbLCDZk9L#j7w;OvB8%c%{#RhS zdGEoBhtXXIdY9VT;X*dVHDImv$qK{<%z>v zK>PFb%C3*M9v5X;u$0N^n!v@qH^^Xd{g0p7+3Q9HSk&id%V3(Ku_S%Hk_rx;tbo~a zvp1fg%2y2&2{Xh?EGK(1FFpCyQ|$27PMzm&2D`_WNEvT`qA)N=)IiJLF4N< z$LhLq4{N#EW7gHCXo*dRLqC4w(0}Z4b$!{zJuyU61n*W3_@Oc>p9=WF6)|*kq*3EK z$JGOe2v$tc`L^&)Iu%e_uRAejqPJC+PGs(|9ww*U)Dfd@g8kui_sl;Amv+;T@7V>9 zrds(OIzzxNF8K+Aa|-^krOWS{CHtbGlp2-5LHeTY%PIjI;E?@6!%atSJd>zQ_eOrF zZ_YVHaZ_yuUz<3<<$IgLa&^`%0$G>ztuGosebh`cf^@|*@hJ%4wG-XuRprX6H>|*8 z#IF)rjuXEUFtxs@IP4ic*2Occ6o@+t*Li_}B0|%8Xaq-ta190TZ?YmD@>GXaOikPA zSFSfpZjMY{Yl5}Xr)LPyyQF$B+)&k+6asO$-t3Wn%in0{bSp%Vqp&St2Q+2Kot z7z~}#87`@s*8mUXLwFRq2MG2fs0I4OaJ?O zOA>s4W()k+r?YvS2Ogq6rf0YNBZjd8>W&QP;MgV0WHho(P*ssm(E=yU_eWvc=MxPX z3{CCU=eL>Xa#4UQ))lO&b61dK4nvNwSK3e_F%iws&;0l zocUri$x(G5SI-z8)FH*d2kLv|+hN#|0@H3hJ=EdhFn0b{W@a|{H=06X^eRWjyztph z0j0;5ELHuu!{zVa&dN)2@!&90f@3}Q%1_p&dJ;KE(i9xXG2$9e`(ykQvwYIY+`NpX zaYy=Q&IeP)gPLwibLON-BD<5{HWtddT>wRl*}lje`Rdj+X3nv%LI%-H$A|J1OhFI( zeXYt7(tdLA(x3vahjBaogk{3B5*^LCcl*_tAT?2qA2WAlzXxAaPH<#iF#N*K97#dB zTj)vpS<`=$A(+W&8VK-34Qs(SZZAPnxVlFo`Jz3#TqT4ICP{&e-b$@noU<>;?6f1& z=eBk7V<9r(I=`F-fp&^dEFSI7)KPEK&ONlRsFt360A?@iv-sk~>zlt#r!5(Gx;g5s zG$Y=(Syi#&U=?F%-|Fv(t3(iJbqr-XsII^?*o#8|AFs-nj2^w%p9Xhd*`3f>%r}wc z(>L2C0e*CA?0+^`q31DJH4RiF>u3-k|51W=dTJoy5`MVooS?e5a@>&}0~rS)gW%Ca z*Macf!G3~LQBOfTyy@2Y-H6%NM7g#`X@E1zmgT0Dya4i*>eYxzcb z#!s-wHZ&+36sy)F zo4rdoiK>_4h7;98d!`3ozGNYXk*|{WRB++`z_^9^q2p}!XL#%oB;(F7Q;BF3^M{y+ z9$HP#FK+q!NRUtHmQW(>pi>h=iz>a)a8g>XCc2`}c?{sJAj{c954-=nLC*OL_* ze@FfM)fK91%au^rfWNm@?PULM$$A3nr6=Dxp_evU%v3wabygq_tlEh&YhaZD~-7C2Qs(~(KxRQzfIfWv6pBi3ZgOE7w){sj~sKT7oNh?8K?A>ycc9kNbikz2i+Md3{6v4jW(4Z-B` z!Y-T}Rs!Rs47P)TSNm!-nYgC+VGpcs5L*U?4|P3#eEPz5I9`1=bIUjAZL1<>)}CiM zci`I+pGM6QZ>zg%>!h|R|CWfsctzZVB%&tW{FiXB;ewZr&C+;Zz9_&WTkiOQQq+-)xPH5|yX5DK|SSNih z1J)Pm)=kSWbR?L_oWH&mryPdRB9q*Ca1h+XetrS}k6-xoZ&LN977(#jyNMw-?d)6w z`<)Ljq06ZIX=$(|W%PW2@ca2C=Bhn{MG?}%CarU~NdcCGuy!{1#V><~KUm>?uf6~> zz#&-}TSV3?ZYCrMb0VY!^R^`T^tN7}&EGt1Q%-XFP7y7e>GQ?UjeEAazXmVidigD( zGnGr!-GTX6z@@p)?6OxWmm#?z@E(LWeBphO-Zp=I>5Io1#P` z-m{YMxFQTCon@9SHhLM|v=s5{;$mG_e-|w-JihRK^w#z10Wl%?Oy*L0yS0$!6GS!a z9L9@jReZu>0%2R&dg+iNOx+K^a;2`*X#|K)lf^Aj<6e219MP=gsq}{2VIs?%nhLwq z&0i8CCCfYC*f%iYInWY!$hr-pW29|@`%ZV|-6y4zLcL!TBD#x|W-aG*ymxZs^LGe- z%+JBqAV)Z2LlE+=`kAvUA~~ivKwOh6w0^=J>UW21(I(u77mWrUjW~@{rJNQ7mr`Eb zngazTi$3#;v?1B@?}%(sH{F_poZo-P#df`Wd_VolcY*ViJRi#&qXs?zBLO?|*T$++ z6G2*f?x;ufR?H9$M;c?oUfuN$93ii`GB$@)RA64wXPC9g3{~@y>_bRLE+Yd6G`GBD zq**L1@7{950P^lS6WueKF)|`E1M#p&AcyCNJFPQoTfd~|c9S_)8F&WtzYWq!pcrnP+p*xpJFaNO-q&*9d zO}|x1mVqZ>uC)w!)VLI*smcJ1&1d`DruWhk&f7PD8IW)>Ruv0j0=rGkoNh)R-yf&D ziQ3YGuBM{{+yt`%53c1dhN5}2o-4>0qE|MNeYAJ*#+?ZYXE-{hTs-eT4xmU;)?c4WEr>a$tw z6}e9nR>=`v$0-%6&8N6CaYYnympJ3NEn`XSD-~uENidu|5pK$@}fRkM#|*G%9esi@l{XN9g67t^WF+`6d&vd4M!F9A&{TD zT&Zpkwg(uyJ2!wv6zH`DFk?&i-eZB~Z@IBCB{IPG?#{-`?w_B{mI+d&SqF#$)Ewc~ za4rxUu;*HqN|NEnR+Wco*;sn2*S;Qy?$gdlT?lx|_Td0UR~<=W*~GjA$v0^X&R{@shb zI2|YePT4KKLwDcvtB!Gw72eC~G#KdZ?Ieto$T|+a5jiQ_cv%JSTxiUm`Ni;R|0B_L zs{c#2dazv_j*{Le_U|OZCUMUWCPZ|wY3U};NvL|Wd=qO~-Com8VclA%WH8xCb!d!X zAN`A=l`2SSUGfvvFUAMG+K2l|eb(PUWWK_qT1H^S@- znE(Qwb+YfGVS!^?HhlKoId2yej?RVJvIM^*MJbXM3JZpHs3~y{;z|aW62*|^CU@9{ z0gSCthZtyhv@e@YY5m7K>Cchp@~=?qFK=c631&$9Lru8lO}MYvde4T@<3 z{;qK{s@!Iem?)J>BqHfr7vQ=8P2W#leLwq^cN7N7KuSubPuq-Z^6jc!7C35`k$W8_ zdDRbSd@m9)ZX%{A^-Dz@#sN678M~D7?@5@I8!gSn6mvZHknvzOVTRU zcK-Y`#2+-Ha;WaBIwl@%nE)9kVE9^4q&1cnhlw7!tO8D z7konQXDgOBf-m~na3v}@$t`XuZl;P_rJBfSwp!HchF*{{53!qe7kMgVhq$KwS-jJs zzzKr$7inRPvYl-|I9U2Vsm-O3p7&TZ$BEA=#}Lp-LD4U(f4aH|`?t;VOpw!k9N7XR z(Z}2@3C1JGk<^SgejKSMQ=xBcoU&dD9;Uu#I z6Xq{Qy4_g$g5ByCUfb1e|7f(7F&d|QQ$`HHF<0ZfNvnWPf9QHcVI&2oN zV;zmjtd{oLKxZ-Ba_^6AZ6a`k&y)2llmc0l2{fq!G*1TU;UK9@#q ze1-_|ZU4y){3kE>lehY>yxjj?_cX*){m;3lS^j~0nv0!-`MJLRroezQGECThY7%&rm-UFHNx}f$gjq6!`QFx{KZfo1|Qj+9zCw^;bK0&fJMZnQO*+f{riJ<|B#e? z?XVIyK3yb4Gy?AXSxo^AB>HteT?|AtRE;F@moLRCZ7gLLsT--~TpD<>iP0A_nglo~ z=#D}f+8)_6eEc}DtC|`rCK7TgwpdtMSp~?~&T$vEuN$OS;(rLGOj>cajMhwCXLbES|Oy9WluCQgiEac8#rk zJvHa~bQjK7B)@j5itpcFQFFh8(^;K4R#O}3+aPxSE^3;IqGBt=P$IMJ}Cvedz zFmQ|8?z3)o1bfYR11Oev1a*k>2$Vp(?@|hRr4(nAA%G>Nx~Zh_+Iggmu6sh^rry)n zEk}-(W%dd&Bo{C4gsRR=CTC_QacTt6V=v<7*EU>pwrKHeq)wPI$dwUyh(8pIglb{; zI)H->h)jFLhrCP@n znJE?L3HCLW@<3L22-}Fouy`p8#X&-efGyV>4Vr!O!J(nPHzkel zvuAK_rd!sRuYGVtN!$T$)0P)wDh)s7V3tZ;U2aTi_6d*?VZ54~*VorioBZgT*vR@V zuWCwf+{ZqBYx>w~R%M>=Pt2JRMR7XhdSgqok87{3J*tJ1nt~3YhulP-7`Q-MTPy=uoqX-XwKQ$nVg*5 zRby6jAH8d7 zrLC=%Ripo1Qc^Nc*MLl@y<$I;&12B`W+{d)*s~)QEhXvE4|q>Lldt*x2=S`ZW$A?* ztcs@Qhn!zmjqms=Q8+H>lNgte{92?Sx)Bz&`xXHkxB`<(@FoN%(t&{BW0xaRZP@6U zeV|u6>RFXkySCt`k4fjvTjyGBew413+5rCDA0NBxZ(u2;roPPu-p+Jz)g9peDEaZp zX*g2xZO7ZI#?r)JxL9KA$#1w0T}77P9%~JSD|};RtNS)emR(WMpw(VdP(XEz7!)?W zgj&Wyg?ko2k8H(ls(z6f_);<8BYa~hEXYkb=}S^pi+<@V;FuMVmHxK=?OuKTX2?N3 zY$%}r{Xn^Z$1X_4aNGqKvA!h|aD`bkx$u(${Z*%+!|U>jyt69Fu@;yXs-Jw)ur6&b zupBK6m!Ft0^2lT5hQJ(%mz1XpwOhC}Kw5a@z?O32w+vo=j~ja$RFz_~@cBwHcOVUo(@O znt+qQs)7p`9vO&g!NkG9^`7NYds%xq7%sn>rR6)59L9z0RO z{Lb)VS($#xHTPb8GMXej*15|^Orb9>RQgg~=}Y2+$B5d+6dhdq?d?r*=6g04_S2I~ zIH!GU>yb2G8#B2Qa7ogzOgFtNfJ?y{Wn1hng3GYTE~~ge#1-V|V*hh;d~9MWBK&i5 zW-H%ifJbMCkjG8<%c0=}MJZDv1{@PJaiUFEG5_ESM z?;@SgEG8z#g?K^Sy2V|>CBM&eqXq{0DW!xTlmzdJno6z977}sI;He|NS&|U*zT4Rp zHhm*UU&Cf*Q&uXVv$${JqP0s<^ds;GX|JiV^k=2({ahuY1H#exWA=0B^Kg{#miwDL zaMH*xU^i7|L&MzM_zSbbGyu95%Eg5ZiY~u*iR#c9u`@W$id$d(qzF?5gG#Q%UQZ|K z2A2*FE7aB4(0FjLPvnCvVOn~6$=A}n*_mB)%W1V~Y1X(CI)3)>!wr5uK5exR*9X(o zIgENc2Rjm-5pNYD6rOuOG-%EB$@KMVz6Uz&cFG8mEOXk<51u3UPE9PnM0zIc;qh@5 zyN?3_m|u{$VrQphS2JODl4xMrZ5yEwu5|3ij~#01;qI1$-KWW%TEr%pu;BPn>q|?d zt*%Y_#;ZZR-l$91kdm@&3mc2TWYnqR5+|F>SB*h7StY@Za3}ix%4VK9R26Bx$i(Dm z@3cj1iLZAyR@^qnH$$sV*V=RT2jA8HoMhA8VG2a*D=5}=V&$SH`dq;4KcGIK3>{%Ti zm0m?ME`N#oDJScBaL03>6>(!$c?Cr{*o>Zf{qzk|HxmO(R8*aM&)MD|6>{Z4s;a7Z z!|m6ju ze4o}IZ=M@u?WwJ*tG+F4b^r37#Ju|^#_8g$U#7+sY?*mw;r@#&apX5Oa9OSF2mCdu zhB}UB&|o-n#J61@9-i1nT>WDE}n1>qGkzhnzW5X{Rjkd6EV zFu!FstexUy9ai+?H)UMP1!dkPx^J2vp}$7o-uWfS(qjASW%uYv4_XA$YE!HgW|3)S zZ3;ln5Lj#uj=om#vs8=`yHLHHRI)ZN^_(Z+GHtr_>xS36NmFl@ZMt-MT@r>7BMW!C zl2^)5+Ab;@;?w5_8-hA~{qc5CUWvX5RgH!H&Cp!pe7}0~^TXFIRE(U8jxbH4BK9(} zK&i!`%|iOk>L-NC5O>Nol%uymw# z3V@VZEz|SWJIr|%T^k~Pm?}xKw!Kq$+?3 zXF8W7N*O)N*8>rSvkRsnWCtf~$ zE;Sfst&IT)J2^QuyX6(s7t2UkUa&{taCB-`5<7hL)`+S4DF61?v+zlgn=KeHL;~&_ zTWHy{NovJPUb^X(+3(%VUyC<})>Xi}sVhGVBBdbX3!M1jdsSCB>xBoz=p7ki<77re zL5(3SE-oIM7+d`H8<~bZuC1uJ91i@0@z_%g=?%F- zZs=0ZPfh&e?1~cSbeeiGnl$FhRus3c%wqyFE-vmcEX*iTx+?>iuAhEo0c#DbSFzq+ z@UyAW-p0!HqB|uvqHfv_arq(Ex@&IlRHDXf7HJJu10SJhyp)twzl_at71}WTIq52_ z%KXnQ%UZ0}dOx;BUCpFy*X2dv#CV&X;tio2;gkeezTVxLNKZ?UwgvLp-2nls1mL@u zgyt?@UVAjv9&Um9L+yiv2b-4%^QXTtPRL*)8}Y4d42pTd5M#8x#M#-|H^+OaB{o~h z`0cL=M*@O)wcA_R*4($0JopYUY}C|z^hiF!H01ry8nv{wdfg`HL40)&p|E>qKA999 zAKSj|Slpf_E!RA*5zVn!sE{?Ao?LQ05R|xTsF&h&eo73Y(s`-X5H6OMocsw!zZ*E$4o0ZIJ2ijZ{p=mx98wz0K8-X(ti2aCR@R3ccvSW55og@c1xGjMTrj>Bn_ zHu7}Z=bn*BeEx2nJC_(AmrOT$u9z)}Ji$VOpM`-VVibvN9DZ+iSC~^;$_{NL;QRGP z-bTeJ^GjVX8x#gV9$Fb%>L?#o-LBZQ*tF!ki^}!S=ujmkCAwf_pv|8h|DXa^ zfzwUbWdz|har9vjM{VtIglo|ja}GN?Im7`}-GZVg?AeF2F3O_I;3YpIC7n{11#$Kx!QDIE+#XPb&bFxs5IyyjXxI^+@{vn!3h zocFY!*$k_+m_@Mrs_$SR!cDWkbLcD8SY|FR_G0F;zMg0DW#2*1ZvULdYv$}`fo9b; zk)or>l${;l2X7rL26G34^`T1){5B@L4lTKA4C#07K$#g>bu@ns6`jHIV&6P!UP@kq z+u1bMZF+(|2a-dqDcIKglF5@RLhRS{?LeH{UvK(3TXPkz)Ajv%h%yGYL*yj}y-Qb47J^05dwH zotPyVhJlfR$n%n~j#=)JV;ffUcW9D3;AV@Hi(5r0oLEZ+Vs@X~4W1!Ba!c*#U?db2 zl;X7|IQ1e*1Aw}>D$vAFYMu!c`*@5O2JVv%JpbDFJiWq3>r%qtO5fO+LVy89MOFg1 zm;mH~4V*RE+FNF)f5hoMD{r>0OQ2}Zdz89;U6hsgKo^wcp(O)4?8n68kaN_41p zM=vUa?=ErEAux};Kwr1Nwbp@>hTnes=m>y`6MGuk7r}7quzIh2aO3;Zlp6bVSD^4| zSyPh7)oEP^b8v9*9OUYH%o`NYKB=zmv7eNb2=nq~x~I2~-m)L!Se^93?B_e_bw{LU zeV_D+3>rNL1vxk-Cl)6rXF2%!J*CEII!;fkLSr7jzNVWpbImEO#`ewLr9&#FNa`9i zx~}CnO1@vyD{EDuS-gUXBH9~d{c$1{&)L;AI4A&f5BtvRep_)^I-jyCu32KOFEIrl zzjN8?+qV#e5XH+#M#W(uF_61!YL3c3COePRMIi!Q7xgU{OE89TfB%HG`7Z9q_}W@m zZ|~GpBa#q(f4{(@z_~Lhgwh)Z=7or~{i~twsffefonNcwM=NXc)Ip(CR8-_|$tAm- zbTrk~zyA#SX=!EEXfJ5~#n}4+_*_jxOIDUK4$@B}o+1t;*Wg($vw=Tukly$5xgb(Z z(My8&f>La2S!w%d8|HYY!N9%U{eV8vQC^ol4F`jm>j^@5H_`9xRRQ>#?H<*u%$@<7&Zw7ncK|JJU%@^faZ0YUb5b8C4ZZ~j&*5b0~~Q6 z=-2Px?s86HT9NXnsa~K)^t7O$yw1*{pOVkF+{f_!VWS8}ip&iVRIa?Q5go!ficv^t$Ict)m%q0%yH|1r8wG>xk{3QP%{w&_#cg+=)_0Ho95q}owfs+xSs-WwA z5H8KnG(FHUG19}u&aGCZLd*|ugT37PI;*5U#KJ?nF_zy0)_#+8vY^QT_8V>faSq?n z)-DTpK(4fP*5~d^*SyWkDnSW^U8}v(#Qfj{lhN^U!#um0`Liocc85xb>2jdNM0vDU zFr)w3){7Xao8E{$t`+ixL0M5Or;2ZJcSt~pJLuE+ckFG3IQ)mh8;iSccMV!x8FgC)=Wu(Oc!Y>_LLCv3i2|Ns?a*bI!wC@P&RTK$WKNnXS6h( z2oAM)UKf3?uy?{@`@M4onZk0Hdy1uQg4Da&o%9=_ELy@p!M|3n)LbpiQElHmJs9$&gDp|JhHSKZ+)3s-c#2f#vC2##c z>nuh{v&HGNH%N8DXjF73a`^LTqelau^fM9EiGX8;(;0(h($)k${C=q~fU);uWO#mL zeZ8{2my-6u=+AgURFw%kKUjzRF)(7(iDJ=#pS5cT1dA5KmVGCUjHeB?Z{}}aBN<(E zIDsLpUcutZ!xe20_v1dglp0yMUiY48#N4YGz*Dlk=ZwZ^C>BLJMcTKq$gn-M1>w2k z0Rj=|y1M%C4U&vykwG5u3*AJl&%k*`^x|JY)Mh#gG9uUgOH+*WXNSY9SE6Y+ zx_#1nCSP=lC|ubw)i(%_6-L99lGeFIHSS>`p#NQfrERd!cU5aoh13jS0CaG$HL;)Y z7WlR9Q=_@p#k=i!4zX*Ns`&U>>+74r?{U>wmR2A=D+PjWm{5NDdyrcExwS!H^eT=u zpy0b|SZTaR;9{b@rJpT4i{)3G{p~<{y9faHD@5ngWl@UV(@iAb&^mH=eaPseHe^Ui zMM0x}YSoM~_KxW6OQz@c;0akde7{b|8(m0aVH#u$L}jU#(z zr1Cd)kKdBdz-j9AyCv`GlZn1r1*MQulj%9FS(=3%QH0tbB>~^v%nKC~asmJ+l@4z> zgCU)ul^p%==sbwyZ&#Pqi#@3?pl(sE>yn++bng4Yerf4Or}(ye{quA>Q{s-y+rgC7 z*2l2lA=K=8A+%(=jD;|46Koo|iTz1w14c+qmX_A+oa_XoM}o1zm_QCk?t*SAf7z=8 zc_q0zcJ1jA(&wT$GRuPEx>Ub4DA4G#JR#mPL?lF&xHwf+75$b&lg2y03hxn+^IbmR-i8?f#t4o zBIi2_>_}Gfcb>{)H>BtYzGgmpK04O>`MqKNb{hsyV_LLw@1`St|G+@i8^dpYrL#i3 z`;UbUoa6cZU!RTOt8}&v1FMi_3F>*|8Jve2=31i`By>Y!mq?VzWaXwV^e|S~?d^+? zM=NIWXUm6YXEjHEwExtpDt+DLH)JqP^9x5=T{FM9z=4I;QCwF1;^LwWw~e`d>*n`$ zY#CozL_NY9?Db7eozSb7e#>t)a3v@8x7iY1;P6p43mumij44;Ya)+t}JPV`kL{ zmNLA1UwuAvb$8cP920ULRDHU=vjZn!T~_Ka1aW{b5oa7*i5GFZ>?MY==`v>}1H{C{ zy5WM}EOm*6H59LKiC%SK+ZyJQQ3`hiF9a^WLgbG!L-pw?FOJEqh4HUn{r;GxUy+>w z@_zy3V>&c_R|FfnhUGJ0C)Ze2)n0B|a$16mj<9L6`6{hMv$W6S7b=c&dr6HVK+0@0 z&+B_-PQnTO<9;xWiUtM|5zz{cCLUtR*Z#gX2U`~`Z4`cM=_AK16VH~3ni=Qb{z1Qe z>Ra3;HnN8{LiD>9W)CTJEdv%7%8~#Q({~IEv$Lb)GMp|ewT?pyL$#}}JXgHj{*-

c*Cqm-#kO?? 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 ne(V<^ZD;4RHv1oG4 None: _assert_overlay_reproduces_after( before, after, recommendation.options[0].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 + ) From ed6cd9c11af9376e359280e8d657d57cac776e54 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:43:24 +0000 Subject: [PATCH 023/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20parser=20gate=20cleared,=20#1154/#1158/#1159=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records that the Elmhurst recommendation Summaries parse via the extractor chain (not parse_site_notes_pdf), so the "parser gate" never blocked the cascade pins. All four pins close at delta 0; loft 270→300 and the suspended-floor insulation-type field were the two gaps fixed. Remaining: #1157 (HITL schema review) + ProductJsonRepository. Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 46 ++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index 8f538e29..d960d1b8 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `4c104050`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `a0b6a952`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. ## Issue status @@ -8,15 +8,38 @@ | Issue | What | State | |---|---|---| | #1153 | Overlay Applicator + `EpcSimulation` | ✅ closed (`350f4c8e`) | -| #1154 | Package Scorer | code done (`7a478cff`); **Elmhurst cascade pin pending parser** | -| #1155 | wall Recommendation Generator | ✅ closed (`bb2c0068`) | +| #1154 | Package Scorer | ✅ closed — Elmhurst cascade pin landed (`4c0a907a`) | +| #1155 | wall Recommendation Generator | ✅ closed (`bb2c0068`); cascade-pinned (`4c0a907a`) | | #1156 | score Options + attribution | ✅ closed (`13dd5fe8`) | | #1157 | persist a Plan via `ModellingOrchestrator` | **not started — HITL (persistence-schema review)** | -| #1158 | roof (loft) generator | generator done (`3c87be8e`); end-to-end + pin pending (#1157 + parser) | -| #1159 | floor generator | generator done (`4c104050`); end-to-end + pin pending | -| #1160 | Optimiser (knapsack + greedy repair) | not started (blocked by #1157/#1158/#1159) | +| #1158 | roof (loft) generator | ✅ closed — 270→300 mm + cascade pin (`44d62c0c`) | +| #1159 | floor generator | ✅ closed — overlay insulation-type field + solid/suspended pins (`a0b6a952`) | +| #1160 | Optimiser (knapsack + greedy repair) | not started (blocked by #1157) | | #1161 | Measure Dependency (ventilation) | not started (blocked by #1160) | +## Parser gate — CLEARED (was the blocker; turned out not to be) + +The Elmhurst recommendation Summaries route cleanly through the **same chain the +worksheet e2e fixtures use**: `pdftotext -layout` → `ElmhurstSiteNotesExtractor` +→ `EpcPropertyDataMapper.from_elmhurst_site_notes`. Helper: +`tests/domain/modelling/_elmhurst_recommendation.py::parse_recommendation_summary`. +The `parse_site_notes_pdf` Textract path still throws `'Manufacturer'` on cert +001431 (`_extract_windows` multi-token bug) — but the Modelling pins never use +it, so it does **not** block this work. The before/after Summaries are mirrored +into `tests/domain/modelling/fixtures/` so the pins don't depend on the unstaged +`/workspaces/model` workspace. + +## Cascade pins (`test_elmhurst_cascade_pins.py`) — all 4 at delta 0 + +Each pin: parse Elmhurst `before` Summary → drive the matching generator → +score its Option's overlay through `PackageScorer` → assert `abs(diff) <= 1e-4` +on SAP/CO2/PE vs the calculator's score on the parsed `after` re-lodgement. +Two real gaps surfaced and were fixed: **loft** Elmhurst re-lodges at 300 mm +(generator was 270 → +0.17 SAP, now 300); **suspended floor** needed the overlay +to also set `floor_insulation_type_str='Retro-fitted'` (calculator's sealed/ +unsealed seal logic, `cert_to_inputs.py:4111` — was +1.40 SAP). Cavity wall and +solid floor closed at delta 0 with no generator change. + ## Design (already recorded — read these) - **CONTEXT.md** terms: Recommendation (a *target surface*; Recommendations **partition** the modifiable EPD surface so overlays never collide), Measure Option (bundle-capable; deduped by overlay), **Simulation Overlay** (`EpcSimulation`), Product, Cost, Contingency, Measure Dependency. Targeting: building parts by `BuildingPartIdentifier`; **windows by index**; systems direct. @@ -47,15 +70,10 @@ All in `domain/modelling/`, `domain/building_geometry.py`, `repositories/product - **Running tests:** `python -m pytest -q`. Do NOT pass `-p no:cov` (pytest.ini injects `--cov` args that then error). DB repo tests spin up ephemeral Postgres via the `db_engine` fixture (`tests/conftest.py`) — slower; SQLModel tables auto-register on import. - **Conventions:** commit per TDD slice; conventional-commit message ending `Co-Authored-By: Claude Opus 4.8 `; stay on `feature/bill-derivation` (user's choice). Tests use literal `# Arrange / # Act / # Assert`; assert with `abs(x - y) <= tol` (not `pytest.approx`); pyright strict, zero errors; annotate call-return locals. -## The two gates +## What's left -1. **Parser fix (in flight — `feature/per-cert-mapper-validation` agent → main).** Once cert **001431** parses, wire the **Elmhurst before/after cascade pins**: - - Files (main checkout): `/workspaces/model/sap worksheets/Recommendations Elmhurst Files//{before,after}/Summary_*.pdf` — `cavity_wall_insulation - main wall` (001431), `loft_insulation - main building`, `solid_floor`/`suspended_floor - main building`, etc. - - Pipeline: `from backend.documents_parser.parser import parse_site_notes_pdf; epd = parse_site_notes_pdf(path)`. - - Pin: parse `before` → apply the measure's overlay (the matching `recommend_*` Option's `overlay`) → `PackageScorer.score` → compare to `after` (either the `after` worksheet's SAP/kWh/carbon, or `Sap10Calculator().calculate(parse(after))`). `/tmp/spike_diff.py` diffs before/after EPDs to derive/validate the overlay empirically. - - The parser bug being fixed: `_extract_windows` reads `location`/`orientation`/`data_source` as single tokens, but 001431 lodges multi-token values ("External wall", "North West") with a blank Glazing Gap, so `'Manufacturer'` lands on the `u_value` float. (`ec9ef0e8` fixed a *different* symptom.) - - Closing these → #1154 done; #1158/#1159 end-to-end once #1157 exists. -2. **#1157 persist a Plan (HITL).** Design-review the Plan / Plan Phase / Recommendation persistence schema + `ScenarioRepository` method shapes, then build `ModellingOrchestrator.run(property_ids, scenario_ids)` per ADR-0011/0012 (one UoW, commit once, thread only IDs, read via repos). Template: `orchestration/property_baseline_orchestrator.py`. Then roof/floor end-to-end + #1160 optimiser + #1161 ventilation dependency. +1. **#1157 persist a Plan (HITL).** Design-review the Plan / Plan Phase / Recommendation persistence schema + `ScenarioRepository` method shapes **with Khalim**, then build `ModellingOrchestrator.run(property_ids, scenario_ids)` per ADR-0011/0012 (one UoW, commit once, thread only IDs, read via repos). Template: `orchestration/property_baseline_orchestrator.py`. Unblocks #1160 optimiser + #1161 ventilation dependency. +2. **`ProductJsonRepository`** behind the existing `ProductRepository` port (file source for ETL-gap costs) — the only parser-independent AFK task remaining besides #1157. ## Relevant memories (auto-loaded) From cc0bb8f9bbf220df7e15528159e4ac3230a969db Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:49:02 +0000 Subject: [PATCH 024/190] feat(modelling): ProductJsonRepository behind the ProductRepository port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the file-backed Product catalogue — the stopgap source for costs the ETL does not yet supply, behind the same ProductRepository port as ProductPostgresRepository. The JSON file maps each Measure Type to its fully-loaded unit cost; the per-Measure-Type contingency is joined from config (not stored in the file), so config stays the single source of truth for contingency — mirroring the Postgres repo's mapping. Strict-raises (ValueError) on an absent measure type, a non-object entry, or a missing/non-numeric unit_cost_per_m2, matching the repo-wide strict-no-silent-default convention. tmp_path-backed tests, no DB fixture needed. Co-Authored-By: Claude Opus 4.8 --- .../product/product_json_repository.py | 49 +++++++++++++++ .../product/test_product_json_repository.py | 61 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 repositories/product/product_json_repository.py create mode 100644 tests/repositories/product/test_product_json_repository.py 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/tests/repositories/product/test_product_json_repository.py b/tests/repositories/product/test_product_json_repository.py new file mode 100644 index 00000000..a991f2a6 --- /dev/null +++ b/tests/repositories/product/test_product_json_repository.py @@ -0,0 +1,61 @@ +"""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_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") From 772cdd4f5a53fd5ffa52c902c6c2021425d1ee39 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:12:54 +0000 Subject: [PATCH 025/190] docs(modelling): #1157 Plan-persistence design review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outcome of the /grill-with-docs session scoping #1157. - CONTEXT.md: add **Plan Measure** (the persisted selected Option + role-3 attribution + cost); Recommendation stays the candidate. Remove Scenario Phase / Plan Phase / Rolled-over Options — multi-phase is deferred. Reshape Scenario + Plan to single-phase; fix relationships, dialogue, and the "phase" ambiguity note. - ADR-0005: rewritten to Deferred (multi-phase was speculative prospective-client work; single-phase now; future plan_phase back-fill path preserved). Stray phase refs cleaned in ADR-0016 / ADR-0009. - ADR-0017 (new): Plan persistence — reuse the live plan/recommendation tables via SQLModel mirrors + a PlanRepository on the UoW; add recommendation.plan_id, retire the plan_recommendations m2m; flat post-retrofit on plan; idempotent replace; CO2 in tonnes. Unselected alternatives + bills noted as deferred directions. - docs/migrations/recommendation-plan-id.md: the FE-owned Drizzle change. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 33 ++++++----------- ...lti-phase-scenarios-per-phase-recompute.md | 35 ++++++++++++++----- docs/adr/0009-deterministic-sap-calculator.md | 2 +- ...ge-rescore-over-warm-start-optimisation.md | 6 ++-- ...017-plan-persistence-evolve-live-tables.md | 33 +++++++++++++++++ docs/migrations/recommendation-plan-id.md | 28 +++++++++++++++ 6 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0017-plan-persistence-evolve-live-tables.md create mode 100644 docs/migrations/recommendation-plan-id.md diff --git a/CONTEXT.md b/CONTEXT.md index baedf2f9..4634df4f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -114,7 +114,7 @@ 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**: @@ -142,7 +142,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**: @@ -184,32 +184,24 @@ _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**: 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) +_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. @@ -302,8 +294,7 @@ _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. - **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**. @@ -330,10 +321,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. @@ -345,5 +332,5 @@ _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. - **"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. 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 0d811847..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.~~ **Resolved by [ADR-0016](0016-package-rescore-over-warm-start-optimisation.md):** the answer is not package enumeration but warm-start MILP on independent-vs-baseline scores → deterministic-calculator package re-score → greedy repair, which sidesteps the cross-product. +- 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/0016-package-rescore-over-warm-start-optimisation.md b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md index 50095a03..6b1395ea 100644 --- a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md +++ b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md @@ -1,6 +1,6 @@ # 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 phase 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. +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. @@ -13,10 +13,10 @@ This resolves the open question deferred in **ADR-0005 §14**. ## Consequences -- Calculator calls per Property per **Scenario Phase** ≈ `(# 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. +- 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. Phase is the cascade unit; intra-phase ordering follows the same best-practice sequence. +- **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. 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..b3e51c1a --- /dev/null +++ b/docs/adr/0017-plan-persistence-evolve-live-tables.md @@ -0,0 +1,33 @@ +# 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. + +## 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/migrations/recommendation-plan-id.md b/docs/migrations/recommendation-plan-id.md new file mode 100644 index 00000000..a9a42bd2 --- /dev/null +++ b/docs/migrations/recommendation-plan-id.md @@ -0,0 +1,28 @@ +# `recommendation.plan_id` — FE-owned migration + +**Context:** #1157 of the Modelling-stage rebuild. The `ModellingOrchestrator` persists a **Plan** and its selected **Plan Measures** (rows of the live `recommendation` table). To link a measure to its Plan it adds **`recommendation.plan_id`**, replacing the `plan_recommendations` many-to-many join for new writes (the m2m's cascade delete is pathologically slow — see [ADR-0017](../adr/0017-plan-persistence-evolve-live-tables.md)). + +The SQLModel mirror is defined in `infrastructure/postgres/` so the ephemeral-Postgres tests build it via `SQLModel.metadata.create_all`. The **production migration is FE-owned (Drizzle ORM)**. + +## Change + +Add one column to the existing `recommendation` table: + +| Column | Type | Notes | +|---|---|---| +| `plan_id` | bigint, FK → `plan.id`, **`ON DELETE CASCADE`**, indexed | the Plan this measure belongs to. Nullable during transition (legacy rows predate it); new writes always set it. | + +- **Index `plan_id`** — the orchestrator'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`** is what makes "delete the Plan → its measures go too" a single statement, replacing the m2m cleanup. + +## Transition / sequencing + +1. **Add `plan_id` (nullable)** — this migration. New `ModellingOrchestrator` writes populate it; legacy writers and existing rows are unaffected. +2. **Cut legacy readers** off `plan_recommendations` onto `plan_id` (separate work, not in #1157). +3. **Drop `plan_recommendations`** once no reader remains (separate migration). + +Existing live `recommendation` rows keep `plan_id = NULL` until/unless re-modelled; they remain reachable via the legacy `plan_recommendations` join during the transition. + +## Not changed here + +No new columns for contingency (per-measure contingency stays summed into `plan.contingency_cost`, matching legacy), no `phase` column (multi-phase deferred, ADR-0005), and the energy/bill columns are populated by a later Bill Derivation slice (ADR-0017). From 62a968119cd48123910a53462513476e182bc461 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:19:52 +0000 Subject: [PATCH 026/190] feat(modelling): domain Scenario + ScenarioPostgresRepository (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the #1157 build. The FE creates a Scenario and passes only its id to the pipeline; the Modelling stage reads it back here. - domain/modelling/scenario.py: thin `Scenario(id, goal, goal_value, budget, is_default)` — the slice the stage uses today (goal/budget for the Optimiser later; is_default drives plan.is_default). No phases (ADR-0005); legacy file-path/aggregate columns not modelled. - infrastructure/postgres/scenario_table.py: `ScenarioRow` SQLModel mirror of the live `scenario` table (ADR-0017), declaring only the read columns; goal mapped as its string value. - ScenarioPostgresRepository.get_many(scenario_ids) -> list[Scenario]: bulk read, input-order-preserving, raises on a missing id. The method shape lives on the concrete repo for now; it is promoted to an @abstractmethod on the port when the real orchestrator is wired and the bare-stub instantiations retire (keeps the stubbed Modelling wiring composing meanwhile). 2 tests, pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/scenario.py | 27 ++++++ infrastructure/postgres/scenario_table.py | 41 ++++++++++ .../scenario/scenario_postgres_repository.py | 31 +++++++ repositories/scenario/scenario_repository.py | 17 ++-- tests/repositories/scenario/__init__.py | 0 .../test_scenario_postgres_repository.py | 82 +++++++++++++++++++ 6 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 domain/modelling/scenario.py create mode 100644 infrastructure/postgres/scenario_table.py create mode 100644 repositories/scenario/scenario_postgres_repository.py create mode 100644 tests/repositories/scenario/__init__.py create mode 100644 tests/repositories/scenario/test_scenario_postgres_repository.py 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/infrastructure/postgres/scenario_table.py b/infrastructure/postgres/scenario_table.py new file mode 100644 index 00000000..62756cfe --- /dev/null +++ b/infrastructure/postgres/scenario_table.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import ClassVar, Optional + +from sqlmodel import Field, SQLModel + +from domain.modelling.scenario import Scenario + + +class ScenarioRow(SQLModel, table=True): + """SQLModel mirror of the live ``scenario`` table (ADR-0017). + + Declares only the columns the Modelling stage reads — the legacy + file-path columns (`trigger_file_path`, `exclusions`, …) and the + portfolio-level aggregates are left to the legacy SQLAlchemy model + (`backend/app/db/models/recommendations.py::ScenarioModel`), which still + owns the live reads. The physical table is the shared contract; this + mirror is read-only from the rebuild's side. + + `goal` is a Postgres enum in production; mapped here as its string value + (the Modelling stage does not yet branch on it — #1160). + """ + + __tablename__: ClassVar[str] = "scenario" # pyright: ignore[reportIncompatibleVariableOverride] + + id: Optional[int] = Field(default=None, primary_key=True) + goal: str + goal_value: str + budget: Optional[float] = Field(default=None) + is_default: bool = Field(default=False) + + 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, + goal_value=self.goal_value, + budget=self.budget, + is_default=self.is_default, + ) diff --git a/repositories/scenario/scenario_postgres_repository.py b/repositories/scenario/scenario_postgres_repository.py new file mode 100644 index 00000000..64d31553 --- /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.scenario_table import ScenarioRow +from repositories.scenario.scenario_repository import ScenarioRepository + + +class ScenarioPostgresRepository(ScenarioRepository): + """Reads the live ``scenario`` table (via the ``ScenarioRow`` 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(ScenarioRow).where(col(ScenarioRow.id).in_(scenario_ids)) + ).all() + by_id: dict[int, ScenarioRow] = { + 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..f92d30d0 100644 --- a/repositories/scenario/scenario_repository.py +++ b/repositories/scenario/scenario_repository.py @@ -4,11 +4,16 @@ from abc import ABC 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. + + The concrete method shape is ``get_many(scenario_ids) -> list[Scenario]`` + (bulk read by id, load-whole per ADR-0012), implemented by + ``ScenarioPostgresRepository``. It is promoted to an ``@abstractmethod`` + here when the real ``ModellingOrchestrator`` is wired and the bare-stub + instantiations are retired (#1157 orchestrator slice) — until then the port + stays instantiable so the stubbed Modelling wiring composes. """ 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..eed38c66 --- /dev/null +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -0,0 +1,82 @@ +"""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.scenario import Scenario +from infrastructure.postgres.scenario_table import ScenarioRow +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( + ScenarioRow( + id=7, + goal="INCREASING_EPC", + goal_value="C", + budget=15000.0, + is_default=True, + ) + ) + session.add( + ScenarioRow( + id=9, + goal="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 + 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( + ScenarioRow( + id=7, goal="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]) From 0ebd9cc7fde7d565fde0ba5f0ec009e77d116d03 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:40:27 +0000 Subject: [PATCH 027/190] feat(modelling): domain Plan + PlanMeasure types (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of #1157. The per-Property output of one Scenario's modelling run, per ADR-0017. - PlanMeasure: a selected Measure Option frozen with its installed Cost and role-3 (final-package cascade) attributed MeasureImpact — the output counterpart of a Recommendation's candidate Option. - Plan: the selected Plan Measures + baseline/post-retrofit Scores. Single-phase (ADR-0005); derives the persisted headline figures — cost_of_works, contingency_cost, co2_savings_kg_per_yr (kg; the mapper converts to tonnes), post_sap_continuous, and post_epc_rating (band from the rounded SAP via Epc.from_sap_score). 1 unit test, pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/plan.py | 73 +++++++++++++++++++++++++++++ tests/domain/modelling/test_plan.py | 47 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 domain/modelling/plan.py create mode 100644 tests/domain/modelling/test_plan.py diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py new file mode 100644 index 00000000..405d6253 --- /dev/null +++ b/domain/modelling/plan.py @@ -0,0 +1,73 @@ +"""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 datatypes.epc.domain.epc import Epc +from domain.modelling.package_scorer import Score +from domain.modelling.recommendation import Cost +from domain.modelling.scoring import MeasureImpact + + +@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.""" + + measure_type: str + description: str + cost: Cost + impact: MeasureImpact + + +@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).""" + + measures: tuple[PlanMeasure, ...] + baseline: Score + post_retrofit: Score + + @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 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 diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py new file mode 100644 index 00000000..fe6828ba --- /dev/null +++ b/tests/domain/modelling/test_plan.py @@ -0,0 +1,47 @@ +"""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.modelling.package_scorer import Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring import MeasureImpact + + +def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure: + return PlanMeasure( + measure_type=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) From d66f7eed84c33bab6149680b95ae29fb64c3609f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:51:02 +0000 Subject: [PATCH 028/190] feat(modelling): plan/recommendation SQLModel mirrors + PlanRepository (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of #1157. Persists a Plan and its Plan Measures to the live plan / recommendation tables via SQLModel mirrors (ADR-0017). - infrastructure/postgres/plan_table.py: PlanRow (`plan`) + RecommendationRow (`recommendation`) mirrors. RecommendationRow adds the new `plan_id` FK (ON DELETE CASCADE) linking each Plan Measure to its Plan, replacing the plan_recommendations m2m for new writes. from_domain mappers convert CO2 kg → tonnes to match the live column contract and derive post_epc_rating from the rounded SAP. Only the impact + cost + identity columns the tracer fills are declared; energy/bill, U-value, valuation, labour, plan_type are left to later slices. - PlanRepository port + PlanPostgresRepository.save(plan, *, property_id, scenario_id, portfolio_id, is_default) -> plan id. Idempotent replace: deleting the Plan cascades to its recommendation rows via plan_id, so a re-run overwrites (ADR-0012). No commit — the UoW owns the transaction. 2 tests (persist + idempotent re-run); pyright strict clean; 73 pass across repositories/modelling/orchestration with no regressions. Co-Authored-By: Claude Opus 4.8 --- infrastructure/postgres/plan_table.py | 118 ++++++++++++++++ repositories/plan/__init__.py | 0 repositories/plan/plan_postgres_repository.py | 55 ++++++++ repositories/plan/plan_repository.py | 29 ++++ tests/repositories/plan/__init__.py | 0 .../plan/test_plan_postgres_repository.py | 131 ++++++++++++++++++ 6 files changed, 333 insertions(+) create mode 100644 infrastructure/postgres/plan_table.py create mode 100644 repositories/plan/__init__.py create mode 100644 repositories/plan/plan_postgres_repository.py create mode 100644 repositories/plan/plan_repository.py create mode 100644 tests/repositories/plan/__init__.py create mode 100644 tests/repositories/plan/test_plan_postgres_repository.py diff --git a/infrastructure/postgres/plan_table.py b/infrastructure/postgres/plan_table.py new file mode 100644 index 00000000..0b7f670a --- /dev/null +++ b/infrastructure/postgres/plan_table.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import ClassVar, Optional + +from sqlalchemy import BigInteger, Column, ForeignKey +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + +from datatypes.epc.domain.epc import Epc +from domain.modelling.plan import Plan, PlanMeasure + +# Calculator metrics are in kg CO₂/yr; the live `plan` / `recommendation` +# columns are tonnes (legacy `emissions_kg / 1000`). Convert on the way in. +_KG_PER_TONNE = 1000.0 + + +class PlanRow(SQLModel, table=True): + """SQLModel mirror of the live ``plan`` table (ADR-0017). + + Declares only the columns the rebuild writes — identity, the flat + post-retrofit headline figures, and the cost aggregates. The legacy + SQLAlchemy model owns the live reads and the columns left for later + slices (valuation, plan_type, the energy/bill cluster). The physical + table is the shared contract. + """ + + __tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride] + + id: Optional[int] = Field(default=None, primary_key=True) + portfolio_id: int + property_id: int = Field(index=True) + scenario_id: Optional[int] = Field(default=None) + is_default: bool = False + + 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 + 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, + ) -> "PlanRow": + 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, + ) + + +class RecommendationRow(SQLModel, table=True): + """SQLModel mirror of the live ``recommendation`` table — one row per + persisted Plan Measure (ADR-0017). Adds the new ``plan_id`` FK linking the + measure to its Plan (ON DELETE CASCADE), replacing the ``plan_recommendations`` + m2m for new writes. Only the impact + cost columns the tracer fills are + declared; the energy/bill, U-value, valuation and labour columns are left + to later slices. + """ + + __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, + ), + ) + + type: str + measure_type: Optional[str] = Field(default=None) + description: str + estimated_cost: Optional[float] = Field(default=None) + sap_points: Optional[float] = Field(default=None) + co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr + default: bool = True + already_installed: bool = False + + @classmethod + def from_domain( + cls, measure: PlanMeasure, *, property_id: int, plan_id: int + ) -> "RecommendationRow": + return cls( + property_id=property_id, + plan_id=plan_id, + type=measure.measure_type, + measure_type=measure.measure_type, + description=measure.description, + estimated_cost=measure.cost.total, + sap_points=measure.impact.sap_points, + co2_equivalent_savings=( + measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE + ), + default=True, + already_installed=False, + ) 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..401ec087 --- /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.plan_table import PlanRow, RecommendationRow +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(PlanRow).where( + col(PlanRow.property_id) == property_id, + col(PlanRow.scenario_id) == scenario_id, + ) + ) + + plan_row = PlanRow.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( + RecommendationRow.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/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..d698a470 --- /dev/null +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -0,0 +1,131 @@ +"""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.package_scorer import Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring import MeasureImpact +from infrastructure.postgres.plan_table import PlanRow, RecommendationRow +from repositories.plan.plan_postgres_repository import PlanPostgresRepository + + +def _plan() -> Plan: + measures: tuple[PlanMeasure, ...] = ( + PlanMeasure( + measure_type="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, + ), + ), + ) + 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(PlanRow, plan_id) + rec_rows = session.exec( + select(RecommendationRow).where( + col(RecommendationRow.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.default is True + assert rec.already_installed is False + + +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(PlanRow).where(col(PlanRow.property_id) == 10) + ).all() + rec_rows = session.exec( + select(RecommendationRow).where(col(RecommendationRow.property_id) == 10) + ).all() + + assert len(plan_rows) == 1 + assert len(rec_rows) == 1 From e778d1fb973d56236b880e37ab95632dc08d3614 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:53:34 +0000 Subject: [PATCH 029/190] feat(modelling): expose scenario/product/plan repos on the UnitOfWork (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4a. The Modelling stage reads the Scenario + Product catalogue and writes the Plan + its Plan Measures on one session, committed once (ADR-0012/0017). Adds uow.scenario / uow.product / uow.plan to the UnitOfWork port and constructs them in PostgresUnitOfWork.__enter__. Additive — existing stages and the bare-stub Modelling wiring are unaffected. Wiring test asserts the unit exposes the three ports. Co-Authored-By: Claude Opus 4.8 --- repositories/postgres_unit_of_work.py | 10 ++++++++++ repositories/unit_of_work.py | 8 ++++++++ tests/repositories/test_unit_of_work.py | 15 +++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/repositories/postgres_unit_of_work.py b/repositories/postgres_unit_of_work.py index da91604b..3a10b087 100644 --- a/repositories/postgres_unit_of_work.py +++ b/repositories/postgres_unit_of_work.py @@ -10,9 +10,16 @@ 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.unit_of_work import UnitOfWork @@ -36,6 +43,9 @@ class PostgresUnitOfWork(UnitOfWork): self.epc = epc_repo self.solar = SolarPostgresRepository(self._session) 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/unit_of_work.py b/repositories/unit_of_work.py index cb1cc1d8..a8a27cdb 100644 --- a/repositories/unit_of_work.py +++ b/repositories/unit_of_work.py @@ -6,7 +6,10 @@ 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 @@ -26,6 +29,11 @@ class UnitOfWork(ABC): epc: EpcRepository solar: SolarRepository 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/tests/repositories/test_unit_of_work.py b/tests/repositories/test_unit_of_work.py index 03018562..e3ee2f73 100644 --- a/tests/repositories/test_unit_of_work.py +++ b/tests/repositories/test_unit_of_work.py @@ -9,7 +9,10 @@ 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 def _session_factory(db_engine: Engine) -> Callable[[], Session]: @@ -60,6 +63,18 @@ 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_leaving_the_block_without_commit_persists_nothing(db_engine: Engine) -> None: # Arrange new_unit = lambda: PostgresUnitOfWork(_session_factory(db_engine)) From c7e2aa37550ca350e2b68f99ef44a998cdb8aa30 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 12:08:32 +0000 Subject: [PATCH 030/190] feat(modelling): ModellingOrchestrator persists a Plan end-to-end (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4b — closes the #1157 tracer. ModellingOrchestrator.run(property_ids, scenario_ids, portfolio_id) now does real work in one Unit of Work, committed once (ADR-0011/0012/0016/0017): read Property (effective EPC) + Scenario via repos → recommend_cavity_wall → select its Option → PackageScorer.score (role-2 package total) + marginal_impacts (role-3 attribution) → build Plan/PlanMeasure → uow.plan.save → commit. - AraFirstRunPipeline / ModellingStage thread portfolio_id from the trigger body (one source of truth); handler builds the real orchestrator (unit_of_work + Sap10Calculator), dropping the Scenario/Materials stubs. - ScenarioRepository.get_many promoted to @abstractmethod now the bare-stub instantiations are gone. - New ara_first_run-style integration test: a property with an uninsulated cavity wall yields a persisted Plan + one cavity_wall_insulation Plan Measure (priced from the Product, figures present, linked by plan_id). Numeric SAP correctness is pinned separately in test_elmhurst_cascade_pins. - Existing pipeline integration test updated: seeds scenario 7 and runs the real Modelling stage (its already-insulated sample wall yields an empty package — no crash). 121 pass across repositories/modelling/orchestration/app; pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- applications/ara_first_run/handler.py | 9 +- orchestration/ara_first_run_pipeline.py | 8 +- orchestration/modelling_orchestrator.py | 108 +++++++++++++--- repositories/scenario/scenario_repository.py | 19 +-- .../test_ara_first_run_pipeline.py | 8 +- ...test_ara_first_run_pipeline_integration.py | 119 +++++++++++++++++- 6 files changed, 230 insertions(+), 41 deletions(-) diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index a546d0f4..837730b6 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -27,9 +27,7 @@ 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 @@ -72,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( @@ -91,8 +88,8 @@ def build_first_run_pipeline( fuel_rates=FuelRatesStaticFileRepository(), ), modelling=ModellingOrchestrator( - scenario_repo=ScenarioRepository(), - materials_repo=MaterialsRepository(), + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), ), ) 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/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 48f70b19..97c2bbe9 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -1,29 +1,105 @@ from __future__ import annotations -from repositories.materials.materials_repository import MaterialsRepository -from repositories.scenario.scenario_repository import ScenarioRepository +from collections.abc import Callable + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.package_scorer import PackageScorer, Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.scenario import Scenario +from domain.modelling.scoring import MeasureImpact, marginal_impacts +from domain.modelling.simulation import EpcSimulation +from domain.modelling.wall_recommendation import recommend_cavity_wall +from domain.sap10_calculator.calculator import SapCalculator +from repositories.product.product_repository import ProductRepository +from repositories.unit_of_work import UnitOfWork 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 Recommendation, selects its Option + into a trivial Optimised Package, scores the package (role 2) and attributes + each measure (role-3 marginal cascade), and persists a **Plan** with its + **Plan Measures**. The optimiser, exclusions, and multi-measure generators + land in later slices; this is the single-measure tracer. + + 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, ) -> None: - self._scenario_repo = scenario_repo - self._materials_repo = materials_repo + self._unit_of_work = unit_of_work + self._calculator = calculator - def run(self, property_ids: list[int], scenario_ids: list[int]) -> None: - return None + def run( + self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int + ) -> None: + scorer = PackageScorer(self._calculator) + 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 + for scenario in scenarios: + plan = self._plan_for(scorer, effective_epc, uow.product) + 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, + effective_epc: EpcPropertyData, + products: ProductRepository, + ) -> Plan: + """Generate → select → score → attribute the single-measure package for + one Property + Scenario, and assemble its Plan.""" + recommendation: Recommendation | None = recommend_cavity_wall( + effective_epc, products + ) + selected: list[MeasureOption] = ( + [recommendation.options[0]] if recommendation is not None else [] + ) + overlays: list[EpcSimulation] = [option.overlay for option in selected] + + baseline: Score = scorer.score(effective_epc, []) + post_retrofit: Score = scorer.score(effective_epc, overlays) + impacts: list[MeasureImpact] = marginal_impacts( + scorer, effective_epc, overlays + ) + measures: tuple[PlanMeasure, ...] = tuple( + _plan_measure(option, impact) + for option, impact in zip(selected, impacts, strict=True) + ) + return Plan( + measures=measures, baseline=baseline, post_retrofit=post_retrofit + ) + + +def _plan_measure(option: MeasureOption, impact: MeasureImpact) -> PlanMeasure: + 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, + ) diff --git a/repositories/scenario/scenario_repository.py b/repositories/scenario/scenario_repository.py index f92d30d0..f5d0c252 100644 --- a/repositories/scenario/scenario_repository.py +++ b/repositories/scenario/scenario_repository.py @@ -1,6 +1,8 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod + +from domain.modelling.scenario import Scenario class ScenarioRepository(ABC): @@ -8,12 +10,11 @@ class ScenarioRepository(ABC): 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. - - The concrete method shape is ``get_many(scenario_ids) -> list[Scenario]`` - (bulk read by id, load-whole per ADR-0012), implemented by - ``ScenarioPostgresRepository``. It is promoted to an ``@abstractmethod`` - here when the real ``ModellingOrchestrator`` is wired and the bare-stub - instantiations are retired (#1157 orchestrator slice) — until then the port - stays instantiable so the stubbed Modelling wiring composes. + 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/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 3d6aeb4a..1fe4dc2d 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -13,17 +13,21 @@ 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 infrastructure.postgres.scenario_table import ScenarioRow from domain.geospatial.coordinates import Coordinates from infrastructure.postgres.property_baseline_performance_table import ( PropertyBaselinePerformanceModel, ) from infrastructure.postgres.epc_property_table import EpcPropertyModel +from infrastructure.postgres.plan_table import PlanRow, RecommendationRow +from infrastructure.postgres.product_table import MaterialRow from infrastructure.postgres.property_table import PropertyRow from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator from orchestration.ara_first_run_pipeline import AraFirstRunPipeline @@ -36,9 +40,7 @@ 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" @@ -101,6 +103,13 @@ 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( + ScenarioRow( + id=7, goal="INCREASING_EPC", goal_value="C", is_default=True + ) + ) session.commit() def unit_of_work() -> PostgresUnitOfWork: @@ -119,8 +128,8 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( fuel_rates=FuelRatesStaticFileRepository(), ), modelling=ModellingOrchestrator( - scenario_repo=ScenarioRepository(), - materials_repo=MaterialsRepository(), + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), ), ) command = _FakeCommand(portfolio_id=1, property_ids=[10], scenario_ids=[7]) @@ -148,3 +157,103 @@ 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 _uninsulated_cavity_epc() -> EpcPropertyData: + """The sample EPC with its MAIN wall flipped to an uninsulated cavity, so + the wall Recommendation Generator fires.""" + epc = _lodged_epc() + main = epc.sap_building_parts[0] + uninsulated_main = dataclasses.replace(main, wall_insulation_type=4) + return dataclasses.replace(epc, sap_building_parts=[uninsulated_main]) + + +def test_first_run_persists_a_plan_with_a_cavity_wall_measure( + db_engine: Engine, +) -> None: + # Arrange — a property to ingest, the Scenario the FE created, and a + # cavity-wall Product so the measure can be priced. (The SAP-numeric + # correctness of the cascade is pinned in test_elmhurst_cascade_pins; here + # we prove the Plan is generated, priced and persisted end-to-end.) + with Session(db_engine) as session: + session.add( + PropertyRow( + id=20, + portfolio_id=1, + postcode="A0 0AA", + address="2 Some Street", + uprn=22222, + ) + ) + session.add( + ScenarioRow( + id=7, goal="INCREASING_EPC", goal_value="C", is_default=True + ) + ) + session.add( + MaterialRow( + id=1, + type="cavity_wall_insulation", + total_cost=18.5, + cost_unit="gbp_per_m2", + is_active=True, + description="Cavity wall insulation", + ) + ) + session.commit() + + def unit_of_work() -> PostgresUnitOfWork: + return PostgresUnitOfWork(lambda: Session(db_engine)) + + pipeline = AraFirstRunPipeline( + ingestion=IngestionOrchestrator( + unit_of_work=unit_of_work, + epc_fetcher=_FetcherReturning(_uninsulated_cavity_epc()), + geospatial_repo=_NoCoordinates(), + solar_fetcher=_UnusedSolarFetcher(), + ), + baseline=PropertyBaselineOrchestrator( + unit_of_work=unit_of_work, + rebaseliner=StubRebaseliner(), + fuel_rates=FuelRatesStaticFileRepository(), + ), + modelling=ModellingOrchestrator( + unit_of_work=unit_of_work, + calculator=Sap10Calculator(), + ), + ) + command = _FakeCommand(portfolio_id=1, property_ids=[20], scenario_ids=[7]) + + # Act + pipeline.run(command) + + # Assert — one Plan for (property 20, scenario 7) with a single cavity-wall + # Plan Measure linked by plan_id, priced from the Product, figures present. + with Session(db_engine) as session: + plan = session.exec( + select(PlanRow).where(col(PlanRow.property_id) == 20) + ).first() + assert plan is not None + rec_rows = session.exec( + select(RecommendationRow).where( + col(RecommendationRow.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 + + assert len(rec_rows) == 1 + rec = rec_rows[0] + assert rec.type == "cavity_wall_insulation" + assert rec.default is True + assert rec.already_installed is False + assert rec.sap_points is not None + assert rec.co2_equivalent_savings is not None + assert rec.estimated_cost is not None + assert rec.estimated_cost > 0.0 From 77983caed8302484ace5679d227113fbf12bd044 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 12:39:47 +0000 Subject: [PATCH 031/190] =?UTF-8?q?feat(modelling):=20Optimiser=20core=20?= =?UTF-8?q?=E2=80=94=20exact=20grouped=20knapsack=20(#1160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of #1160. Recycles the GainOptimiser/CostOptimiser formulation (≤1 Option per Recommendation, maximise SAP gain subject to budget) as a clean typed DDD function — but as an exact pure-Python multiple-choice knapsack rather than the legacy `mip` MILP, since mip's CBC backend does not load on aarch64 (so the legacy solver path can't run / be tested here). At retrofit scale the candidate space Π(|group|+1) is tiny, so exhaustive enumeration is exact and instant; ADR-0016 only needs the knapsack as a warm-start signal anyway (the truthful figure comes from the whole-package re-score + repair, next slice). `optimise(groups, budget) -> list[ScoredOption]`: maximise total gain, tie-break toward lower cost, skip-per-group covers "select none". 6 tests (budget-bound selection, ≤1/group, unconstrained, budget-too-small, empty groups, partial-affordability); pyright strict clean. Multi-phase remains descoped (ADR-0005) — single-phase optimiser. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/optimiser.py | 74 ++++++++++++++ tests/domain/modelling/test_optimiser.py | 123 +++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 domain/modelling/optimiser.py create mode 100644 tests/domain/modelling/test_optimiser.py diff --git a/domain/modelling/optimiser.py b/domain/modelling/optimiser.py new file mode 100644 index 00000000..a7ac28ec --- /dev/null +++ b/domain/modelling/optimiser.py @@ -0,0 +1,74 @@ +"""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 + +from domain.modelling.recommendation import MeasureOption + + +@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 + + +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] +) -> 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).""" + # Each group offers: skip it (None) or take exactly one of its Options. + 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: float = sum(_option_cost(s.option) for s in selected) + if budget is not None and total_cost > budget: + continue + total_gain: float = sum(s.sap_gain for s in selected) + # 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 diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py new file mode 100644 index 00000000..bbbaebb3 --- /dev/null +++ b/tests/domain/modelling/test_optimiser.py @@ -0,0 +1,123 @@ +"""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 domain.modelling.optimiser import ScoredOption, optimise +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.simulation import EpcSimulation + + +def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: + return ScoredOption( + option=MeasureOption( + measure_type=measure_type, + description=measure_type, + overlay=EpcSimulation(), + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=gain, + ) + + +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"} From 49e86344d20991d11a9df79fa74fb23237fee213 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 12:45:05 +0000 Subject: [PATCH 032/190] feat(modelling): whole-package re-score + greedy repair (#1160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of #1160 — the ADR-0016 truth step on top of the warm-start knapsack. optimise_package(groups, scorer, baseline_epc, budget, target_sap) -> OptimisedPackage: warm-start optimise() (role-1 signal) → re-score the chosen package on the real scorer (role-2 truth) → while the true SAP undershoots target_sap and budget remains, greedy-add the untreated-group Option with the best *marginal* SAP-per-£ (re-scored, not the role-1 signal), re-score, repeat until the target is met, nothing positive-marginal is affordable, or the budget is spent. `Scorer` is a structural Protocol (PackageScorer satisfies it) so the repair loop is tested with a stub scorer — no calculator, runs on ARM. The key case: role-1 under-counts roof so the warm-start skips it, the re-score undershoots, and repair adds roof back to hit the target. 3 repair tests + the 6 core tests; pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/optimiser.py | 108 +++++++++++++++- tests/domain/modelling/test_optimiser.py | 158 ++++++++++++++++++++++- 2 files changed, 263 insertions(+), 3 deletions(-) diff --git a/domain/modelling/optimiser.py b/domain/modelling/optimiser.py index a7ac28ec..beeb630e 100644 --- a/domain/modelling/optimiser.py +++ b/domain/modelling/optimiser.py @@ -21,9 +21,12 @@ from __future__ import annotations import itertools from dataclasses import dataclass -from typing import Optional +from typing import Optional, Protocol, Sequence +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.package_scorer import Score from domain.modelling.recommendation import MeasureOption +from domain.modelling.simulation import EpcSimulation @dataclass(frozen=True) @@ -72,3 +75,106 @@ def optimise( if (total_gain, -total_cost) > (best_gain, -best_cost): best, best_gain, best_cost = selected, total_gain, total_cost 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], +) -> OptimisedPackage: + """Warm-start with the grouped knapsack (role-1 signal), re-score the chosen + package on the real scorer (role-2 truth), then — while the true SAP + undershoots ``target_sap`` and budget remains — greedy-add the untreated- + group Option with the best marginal SAP-per-£ and re-score, until the target + is met, no positive-marginal Option is affordable, or the budget is spent + (ADR-0016). ``target_sap``/``budget`` of None mean unconstrained.""" + selected: list[ScoredOption] = optimise(groups, budget) + score: Score = _score(scorer, baseline_epc, selected) + if target_sap is None: + return OptimisedPackage(selected=selected, score=score) + + spent: float = sum(_option_cost(s.option) for s in selected) + while score.sap_continuous < target_sap: + remaining: Optional[float] = None if budget is None else budget - spent + candidate = _best_repair_candidate( + groups, selected, scorer, baseline_epc, score, remaining + ) + if candidate is None: + break + selected = [*selected, candidate] + spent += _option_cost(candidate.option) + score = _score(scorer, baseline_epc, selected) + return OptimisedPackage(selected=selected, score=score) + + +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]], + selected: list[ScoredOption], + scorer: Scorer, + baseline_epc: EpcPropertyData, + current: Score, + remaining_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), affordable + within ``remaining_budget`` and strictly improving. None if there is none.""" + used: set[int] = _used_group_indices(groups, selected) + best: Optional[ScoredOption] = None + best_ratio: float = 0.0 + for index, group in enumerate(groups): + if index in used: + continue + for option in group: + cost: float = _option_cost(option.option) + if remaining_budget is not None and cost > remaining_budget: + continue + trial: Score = _score(scorer, baseline_epc, [*selected, option]) + marginal: float = trial.sap_continuous - current.sap_continuous + if marginal <= 0.0: + continue + ratio: float = float("inf") if cost == 0.0 else marginal / cost + if ratio > best_ratio: + best, best_ratio = option, ratio + return best diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index bbbaebb3..77e83680 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -8,9 +8,24 @@ selection with synthetic scores and no calculator. from __future__ import annotations -from domain.modelling.optimiser import ScoredOption, optimise +from typing import Sequence + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.optimiser import ( + OptimisedPackage, + ScoredOption, + optimise, + optimise_package, +) +from domain.modelling.package_scorer import Score from domain.modelling.recommendation import Cost, MeasureOption -from domain.modelling.simulation import EpcSimulation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: @@ -25,6 +40,66 @@ def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: ) +# 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=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} @@ -121,3 +196,82 @@ def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> Non # Assert — EWI is unaffordable; loft alone is the best within £2000. assert _selected_types(selection) == {"loft_insulation"} + + +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 From 504f592a27991d9a057cdc72b6aa8a17f4be7769 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 12:50:40 +0000 Subject: [PATCH 033/190] =?UTF-8?q?feat(modelling):=20Epc.sap=5Flower=5Fbo?= =?UTF-8?q?und()=20=E2=80=94=20band=20=E2=86=92=20minimum=20SAP=20(#1160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3a. The inverse of Epc.from_sap_score: the minimum SAP rating in a band (C → 69, B → 81, …), used as the Optimiser's repair target for an INCREASING_EPC goal (goal_value "C" → target SAP 69). Keeps the band-target derivation in the domain rather than re-coupling to backend.app.utils.epc_to_sap_lower_bound. 8 tests incl. round-trip through from_sap_score; pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc.py | 15 +++++++++++++++ tests/datatypes/__init__.py | 0 tests/datatypes/epc/__init__.py | 0 tests/datatypes/epc/domain/__init__.py | 0 tests/datatypes/epc/domain/test_epc.py | 26 ++++++++++++++++++++++++++ 5 files changed, 41 insertions(+) create mode 100644 tests/datatypes/__init__.py create mode 100644 tests/datatypes/epc/__init__.py create mode 100644 tests/datatypes/epc/domain/__init__.py create mode 100644 tests/datatypes/epc/domain/test_epc.py 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/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 From 34d4748a3a432f085a835346c2a90630f9b4cc71 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:07:14 +0000 Subject: [PATCH 034/190] feat(modelling): wire the Optimiser into the orchestrator (#1160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3b — closes #1160. ModellingOrchestrator._plan_for now runs the full ADR-0016 flow instead of a single cavity measure: generate wall + roof + floor Recommendations → score each Option independently (role 1) into grouped ScoredOptions → optimise_package (grouped knapsack within budget + whole-package re-score + greedy repair toward the Scenario's SAP target) → attribute the selected set via the best-practice marginal cascade (role 3) → persist the Plan with its Plan Measures. The repair target comes from the goal: INCREASING_EPC → the goal_value band floor via Epc.sap_lower_bound(); other goals carry no SAP target yet (later slice). Best-practice order walls → roof → floor. Integration test: an uninsulated cavity wall + suspended floor (000490) driven directly through the Modelling stage off a repo-seeded EPC (the calculator fixture has no lodged recorded-performance fields, so Baseline can't run it) persists a Plan with two attributed, priced Plan Measures. The existing first-run test keeps full-pipeline coverage and now exercises real modelling (its sample EPC's uninsulated solid floor yields a floor measure). Replaces the single-measure cavity integration test (subsumed). 138 pass; pyright strict clean. Multi-phase remains descoped (ADR-0005); single-phase optimiser. Co-Authored-By: Claude Opus 4.8 --- orchestration/modelling_orchestrator.py | 136 +++++++++++++++--- ...test_ara_first_run_pipeline_integration.py | 133 +++++++++-------- 2 files changed, 186 insertions(+), 83 deletions(-) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 97c2bbe9..da10f744 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -1,36 +1,66 @@ from __future__ import annotations 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.modelling.floor_recommendation import recommend_floor_insulation +from domain.modelling.optimiser import ( + OptimisedPackage, + ScoredOption, + optimise_package, +) from domain.modelling.package_scorer import PackageScorer, Score from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.modelling.roof_recommendation import recommend_loft_insulation from domain.modelling.scenario import Scenario -from domain.modelling.scoring import MeasureImpact, marginal_impacts -from domain.modelling.simulation import EpcSimulation +from domain.modelling.scoring import ( + MeasureImpact, + independent_option_impacts, + marginal_impacts, +) from domain.modelling.wall_recommendation import recommend_cavity_wall from domain.sap10_calculator.calculator import SapCalculator from repositories.product.product_repository import ProductRepository 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): +# fabric in walls → roof → floor order, per the legacy `Recommendations` class. +_BEST_PRACTICE_ORDER: Final[tuple[str, ...]] = ( + "cavity_wall_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "loft_insulation", + "suspended_floor_insulation", + "solid_floor_insulation", +) + class ModellingOrchestrator: """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). - Runs the whole batch in **one** Unit of Work and commits once: for each + 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 Recommendation, selects its Option - into a trivial Optimised Package, scores the package (role 2) and attributes - each measure (role-3 marginal cascade), and persists a **Plan** with its - **Plan Measures**. The optimiser, exclusions, and multi-measure generators - land in later slices; this is the single-measure tracer. + 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. + (ADR-0011). The injected `SapCalculator` is the scoring-engine seam. """ def __init__( @@ -52,7 +82,9 @@ class ModellingOrchestrator: for property_id, prop in zip(property_ids, properties, strict=True): effective_epc: EpcPropertyData = prop.effective_epc for scenario in scenarios: - plan = self._plan_for(scorer, effective_epc, uow.product) + plan = self._plan_for( + scorer, effective_epc, uow.product, scenario + ) uow.plan.save( plan, property_id=property_id, @@ -67,31 +99,89 @@ class ModellingOrchestrator: scorer: PackageScorer, effective_epc: EpcPropertyData, products: ProductRepository, + scenario: Scenario, ) -> Plan: - """Generate → select → score → attribute the single-measure package for - one Property + Scenario, and assemble its Plan.""" - recommendation: Recommendation | None = recommend_cavity_wall( - effective_epc, products + """Generate → score → optimise → re-score/repair → attribute → assemble + the Plan for one Property + Scenario.""" + groups: list[list[ScoredOption]] = _scored_candidate_groups( + scorer, effective_epc, products ) - selected: list[MeasureOption] = ( - [recommendation.options[0]] if recommendation is not None else [] + package: OptimisedPackage = optimise_package( + groups=groups, + scorer=scorer, + baseline_epc=effective_epc, + budget=scenario.budget, + target_sap=_target_sap(scenario), ) - overlays: list[EpcSimulation] = [option.overlay for option in selected] - baseline: Score = scorer.score(effective_epc, []) - post_retrofit: Score = scorer.score(effective_epc, overlays) - impacts: list[MeasureImpact] = marginal_impacts( - scorer, effective_epc, overlays + # 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 ) + impacts: list[MeasureImpact] = marginal_impacts( + scorer, effective_epc, [option.overlay for option in ordered] + ) + baseline: Score = scorer.score(effective_epc, []) measures: tuple[PlanMeasure, ...] = tuple( _plan_measure(option, impact) - for option, impact in zip(selected, impacts, strict=True) + for option, impact in zip(ordered, impacts, strict=True) ) return Plan( - measures=measures, baseline=baseline, post_retrofit=post_retrofit + measures=measures, baseline=baseline, post_retrofit=package.score ) +def _candidate_recommendations( + effective_epc: EpcPropertyData, products: ProductRepository +) -> list[Recommendation]: + """Run every fabric Recommendation Generator; keep the ones that apply.""" + generators = ( + recommend_cavity_wall, + recommend_loft_insulation, + recommend_floor_insulation, + ) + found = (generator(effective_epc, products) for generator in generators) + return [recommendation for recommendation in found if recommendation is not None] + + +def _scored_candidate_groups( + scorer: PackageScorer, + effective_epc: EpcPropertyData, + products: ProductRepository, +) -> 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): + 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) -> PlanMeasure: if option.cost is None: raise ValueError( diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 1fe4dc2d..66791c1d 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -29,10 +29,14 @@ from infrastructure.postgres.epc_property_table import EpcPropertyModel from infrastructure.postgres.plan_table import PlanRow, RecommendationRow 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, ) @@ -107,7 +111,19 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( # scenario_ids) through the repo, so the row must exist. session.add( ScenarioRow( - id=7, goal="INCREASING_EPC", goal_value="C", is_default=True + id=7, goal="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. + session.add( + MaterialRow( + id=1, + type="solid_floor_insulation", + total_cost=25.0, + cost_unit="gbp_per_m2", + is_active=True, + description="Solid floor insulation", ) ) session.commit() @@ -159,79 +175,74 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( assert len(baseline_rows) == 1 -def _uninsulated_cavity_epc() -> EpcPropertyData: - """The sample EPC with its MAIN wall flipped to an uninsulated cavity, so - the wall Recommendation Generator fires.""" - epc = _lodged_epc() - main = epc.sap_building_parts[0] - uninsulated_main = dataclasses.replace(main, wall_insulation_type=4) - return dataclasses.replace(epc, sap_building_parts=[uninsulated_main]) - - -def test_first_run_persists_a_plan_with_a_cavity_wall_measure( +def test_modelling_optimises_and_persists_a_multi_measure_plan( db_engine: Engine, ) -> None: - # Arrange — a property to ingest, the Scenario the FE created, and a - # cavity-wall Product so the measure can be priced. (The SAP-numeric - # correctness of the cascade is pinned in test_elmhurst_cascade_pins; here - # we prove the Plan is generated, priced and persisted end-to-end.) + # 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. with Session(db_engine) as session: session.add( PropertyRow( - id=20, + id=30, portfolio_id=1, postcode="A0 0AA", - address="2 Some Street", - uprn=22222, + address="3 Some Street", + uprn=33333, ) ) session.add( ScenarioRow( - id=7, goal="INCREASING_EPC", goal_value="C", is_default=True + id=7, goal="Increasing EPC", goal_value="C", is_default=True ) ) - session.add( - MaterialRow( - id=1, - type="cavity_wall_insulation", - total_cost=18.5, - cost_unit="gbp_per_m2", - is_active=True, - description="Cavity wall insulation", - ) + session.add_all( + [ + 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", + ), + ] + ) + 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)) - pipeline = AraFirstRunPipeline( - ingestion=IngestionOrchestrator( - unit_of_work=unit_of_work, - epc_fetcher=_FetcherReturning(_uninsulated_cavity_epc()), - geospatial_repo=_NoCoordinates(), - solar_fetcher=_UnusedSolarFetcher(), - ), - baseline=PropertyBaselineOrchestrator( - unit_of_work=unit_of_work, - rebaseliner=StubRebaseliner(), - fuel_rates=FuelRatesStaticFileRepository(), - ), - modelling=ModellingOrchestrator( - unit_of_work=unit_of_work, - calculator=Sap10Calculator(), - ), - ) - command = _FakeCommand(portfolio_id=1, property_ids=[20], scenario_ids=[7]) - # Act - pipeline.run(command) + ModellingOrchestrator( + unit_of_work=unit_of_work, calculator=Sap10Calculator() + ).run(property_ids=[30], scenario_ids=[7], portfolio_id=1) - # Assert — one Plan for (property 20, scenario 7) with a single cavity-wall - # Plan Measure linked by plan_id, priced from the Product, figures present. + # Assert — one Plan with two Plan Measures (wall + floor), each priced and + # attributed, linked by plan_id. with Session(db_engine) as session: plan = session.exec( - select(PlanRow).where(col(PlanRow.property_id) == 20) + select(PlanRow).where(col(PlanRow.property_id) == 30) ).first() assert plan is not None rec_rows = session.exec( @@ -248,12 +259,14 @@ def test_first_run_persists_a_plan_with_a_cavity_wall_measure( assert plan.cost_of_works is not None assert plan.cost_of_works > 0.0 - assert len(rec_rows) == 1 - rec = rec_rows[0] - assert rec.type == "cavity_wall_insulation" - assert rec.default is True - assert rec.already_installed is False - assert rec.sap_points is not None - assert rec.co2_equivalent_savings is not None - assert rec.estimated_cost is not None - assert rec.estimated_cost > 0.0 + measure_types = {rec.type for rec in rec_rows} + assert measure_types == { + "cavity_wall_insulation", + "suspended_floor_insulation", + } + 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 + assert rec.estimated_cost > 0.0 From 42d941195462a3c9f4dc149b61bc4a038dfb358e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:11:43 +0000 Subject: [PATCH 035/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20#1157=20+=20#1160=20closed,=20#1161=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings HANDOVER_MODELLING.md fully current: #1157 (Plan persistence) and #1160 (Optimiser) closed this session; records the locked design decisions (multi-phase deferred, Plan Measure term, reuse-live-tables via SQLModel mirrors, pure-Python knapsack not mip), the gotchas (mip/CBC broken on aarch64, moto missing, drive-Modelling-directly for fixtures without lodged perf, seed materials per fired measure type), and the remaining work (#1161 ventilation Measure Dependency + deferred fronts). Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 106 +++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 58 deletions(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index d960d1b8..e4ad963d 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,81 +1,71 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `a0b6a952`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `34d4748a`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. ## Issue status | Issue | What | State | |---|---|---| -| #1153 | Overlay Applicator + `EpcSimulation` | ✅ closed (`350f4c8e`) | -| #1154 | Package Scorer | ✅ closed — Elmhurst cascade pin landed (`4c0a907a`) | -| #1155 | wall Recommendation Generator | ✅ closed (`bb2c0068`); cascade-pinned (`4c0a907a`) | -| #1156 | score Options + attribution | ✅ closed (`13dd5fe8`) | -| #1157 | persist a Plan via `ModellingOrchestrator` | **not started — HITL (persistence-schema review)** | -| #1158 | roof (loft) generator | ✅ closed — 270→300 mm + cascade pin (`44d62c0c`) | -| #1159 | floor generator | ✅ closed — overlay insulation-type field + solid/suspended pins (`a0b6a952`) | -| #1160 | Optimiser (knapsack + greedy repair) | not started (blocked by #1157) | -| #1161 | Measure Dependency (ventilation) | not started (blocked by #1160) | +| #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) | **NOT STARTED — next** | -## Parser gate — CLEARED (was the blocker; turned out not to be) +## What this session did -The Elmhurst recommendation Summaries route cleanly through the **same chain the -worksheet e2e fixtures use**: `pdftotext -layout` → `ElmhurstSiteNotesExtractor` -→ `EpcPropertyDataMapper.from_elmhurst_site_notes`. Helper: -`tests/domain/modelling/_elmhurst_recommendation.py::parse_recommendation_summary`. -The `parse_site_notes_pdf` Textract path still throws `'Manufacturer'` on cert -001431 (`_extract_windows` multi-token bug) — but the Modelling pins never use -it, so it does **not** block this work. The before/after Summaries are mirrored -into `tests/domain/modelling/fixtures/` so the pins don't depend on the unstaged -`/workspaces/model` workspace. +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". -## Cascade pins (`test_elmhurst_cascade_pins.py`) — all 4 at delta 0 +## Design decisions locked this session (READ THESE) -Each pin: parse Elmhurst `before` Summary → drive the matching generator → -score its Option's overlay through `PackageScorer` → assert `abs(diff) <= 1e-4` -on SAP/CO2/PE vs the calculator's score on the parsed `after` re-lodgement. -Two real gaps surfaced and were fixed: **loft** Elmhurst re-lodges at 300 mm -(generator was 270 → +0.17 SAP, now 300); **suspended floor** needed the overlay -to also set `floor_insulation_type_str='Retro-fitted'` (calculator's sealed/ -unsealed seal logic, `cert_to_inputs.py:4111` — was +1.40 SAP). Cavity wall and -solid floor closed at delta 0 with no generator change. +- **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. -## Design (already recorded — read these) +## What's built (all in `domain/modelling/`, `infrastructure/postgres/`, `repositories/`, `orchestration/`) -- **CONTEXT.md** terms: Recommendation (a *target surface*; Recommendations **partition** the modifiable EPD surface so overlays never collide), Measure Option (bundle-capable; deduped by overlay), **Simulation Overlay** (`EpcSimulation`), Product, Cost, Contingency, Measure Dependency. Targeting: building parts by `BuildingPartIdentifier`; **windows by index**; systems direct. -- **ADR-0016**: the three scoring roles (per-Option signal → whole-package re-score → final-package marginal cascade attribution) + warm-start MILP → dependency injection → package re-score → greedy repair. Resolves ADR-0005 §14. -- Governing: **ADR-0005** (multi-phase scenarios, per-phase recompute vs rolling Effective EPC), **ADR-0011** (composable stage orchestrators), **ADR-0012** (one Unit of Work per stage, commit once). +- Generators: `recommend_cavity_wall` / `recommend_loft_insulation` (300 mm) / `recommend_floor_insulation` (sets `floor_insulation_type_str`). +- `simulation.py` overlay + `overlay_applicator.apply_simulations` (generic field-fold) + `package_scorer.PackageScorer.score` (role 2) + `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). +- `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). -## What's built +## Gotchas (will bite a fresh agent) -All in `domain/modelling/`, `domain/building_geometry.py`, `repositories/product/`, `infrastructure/postgres/product_table.py`. **25 tests green, pyright strict clean, purely additive.** +- **`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). -- `simulation.py` — `EpcSimulation(building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay])`; `BuildingPartOverlay` (all-optional: `wall_insulation_type`, `roof_insulation_thickness`, `floor_insulation_thickness`). -- `overlay_applicator.py` — `apply_simulations(baseline, simulations) -> EpcPropertyData`. **Generic field-fold** (adding overlay fields needs NO change here — proven by roof/floor), sequential (later overlay wins), deep-copies (baseline never mutated), targets parts by identifier, writes the `sap_*` fields. Returns a throwaway EPD. -- `recommendation.py` — `Recommendation(surface, options)`, `MeasureOption(measure_type, description, overlay, cost)`, `Cost(total, contingency_rate)`. -- `product.py` / `contingencies.py` — `Product(measure_type, unit_cost_per_m2, contingency_rate)`; per-type contingency (cavity 0.10, loft 0.10, suspended floor 0.20, solid floor 0.26). -- `package_scorer.py` — `PackageScorer(calculator: SapCalculator).score(baseline, simulations) -> Score(sap_continuous, co2_kg_per_yr, primary_energy_kwh_per_yr)`. The reusable scoring primitive (role 2). -- `scoring.py` — `marginal_impacts(scorer, baseline, overlays) -> list[MeasureImpact]` (telescoping cascade, role 3); `independent_option_impacts(scorer, baseline, options)` (role 1, scores each *distinct* overlay once). `MeasureImpact(sap_points, co2_savings_kg_per_yr, energy_savings_kwh_per_yr)`. -- `wall_recommendation.py` — `recommend_cavity_wall(epc, products)`: detect cavity (`wall_construction==4`) + uninsulated (`wall_insulation_type==4`) → overlay sets `wall_insulation_type=2` (Table 6 "Filled cavity"). -- `roof_recommendation.py` — `recommend_loft_insulation(epc, products)`: detect `roof_insulation_thickness==0` → overlay `roof_insulation_thickness=270`. -- `floor_recommendation.py` — `recommend_floor_insulation(epc, products)`: detect uninsulated ground floor + construction (`floor_construction_type` "Suspended"/"Solid") → overlay `floor_insulation_thickness=100`. -- `building_geometry.py` — `gross_heat_loss_wall_area`, `roof_area`, `ground_floor_area` (per part, by identifier; party walls excluded; areas are heat-loss/§3.8 quantities, not totals). -- `repositories/product/` — `ProductRepository` (ABC port, `get(measure_type)->Product`); `ProductPostgresRepository` reads the externally-owned `material` table (defensive SQLModel view `MaterialRow`; `total_cost → unit_cost_per_m2`; joins contingency). A `ProductJsonRepository` (file source, for ETL-gap costs) is intended behind the same port — **the one remaining parser-independent AFK task**. +## Conventions -## Key facts / gotchas - -- **Hand-built baseline fixture** (no PDF): `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.build_epc()`. Its MAIN is an uninsulated cavity wall + uninsulated suspended ground floor + 300 mm (insulated) loft. Used as the baseline in every generator/scorer test. MAIN gross heat-loss wall area = **45.93 m²**, roof area = **14.85 m²**, ground floor = **14.85 m²**. -- **Calculator entry:** `Sap10Calculator().calculate(epc) -> SapResult` (`sap_score_continuous`, `co2_kg_per_yr`, `primary_energy_kwh_per_yr`). Depend on the **`SapCalculator`** abstraction. Filled-cavity wall code = **2** (`domain/sap10_ml/rdsap_uvalues.py::u_wall`). Calculator reads wall/roof/floor from `SapBuildingPart` structured fields, NOT `EnergyElement` descriptions (those are detection-only). -- **Worktree vs main import trap:** `python /tmp/foo.py` imports the repo from `/workspaces/model` (editable install), NOT this worktree. Run with `PYTHONPATH=` or via `pytest` (rootdir handles it). `pytest` already uses worktree code. -- **Running tests:** `python -m pytest -q`. Do NOT pass `-p no:cov` (pytest.ini injects `--cov` args that then error). DB repo tests spin up ephemeral Postgres via the `db_engine` fixture (`tests/conftest.py`) — slower; SQLModel tables auto-register on import. -- **Conventions:** commit per TDD slice; conventional-commit message ending `Co-Authored-By: Claude Opus 4.8 `; stay on `feature/bill-derivation` (user's choice). Tests use literal `# Arrange / # Act / # Assert`; assert with `abs(x - y) <= tol` (not `pytest.approx`); pyright strict, zero errors; annotate call-return locals. +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. ## What's left -1. **#1157 persist a Plan (HITL).** Design-review the Plan / Plan Phase / Recommendation persistence schema + `ScenarioRepository` method shapes **with Khalim**, then build `ModellingOrchestrator.run(property_ids, scenario_ids)` per ADR-0011/0012 (one UoW, commit once, thread only IDs, read via repos). Template: `orchestration/property_baseline_orchestrator.py`. Unblocks #1160 optimiser + #1161 ventilation dependency. -2. **`ProductJsonRepository`** behind the existing `ProductRepository` port (file source for ETL-gap costs) — the only parser-independent AFK task remaining besides #1157. +1. **#1161 — Measure Dependency (ventilation).** Per ADR-0016: a forced dependency (wall/roof insulation requires adequate ventilation) is **excluded from the optimiser's candidate pool** but **injected into the Optimised Package BEFORE the re-score**, so its (negative) SAP contribution lands in the truthful figure and the repair decision. Trigger set held as data (cf. legacy `assumptions.measures_needing_ventilation`). The injection point is `optimise_package` in `domain/modelling/optimiser.py` (inject after warm-start, before/within the re-score). +2. **Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); 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. -## Relevant memories (auto-loaded) +## Key references -- `project_openos_conservation_data_gap` — EWI eligibility needs listed/conservation status, not ingested; blocks the solid-wall EWI slice (later), NOT the fabric tracers. -- `project_calculator_geometry_extraction` — the calculator holds reusable geometry; `building_geometry.py` is the start; DRY the calculator onto it later (coordinate with the calculator branch); **don't edit `heat_transmission.py` now**. +- 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. From 7c59e9198ab0bff6304d372ea5a31378c38b8710 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:20:45 +0000 Subject: [PATCH 036/190] feat(modelling): Simulation Overlay grows a dwelling ventilation segment (#1161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VentilationOverlay (all-optional partial of SapVentilation) + EpcSimulation. ventilation; apply_simulations folds it onto sap_ventilation, creating one when the baseline lodged none. This is the surface a Measure Dependency (ventilation) writes — whole-dwelling, no building part. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/overlay_applicator.py | 29 ++++++-- domain/modelling/simulation.py | 20 +++++- .../modelling/test_overlay_applicator.py | 71 ++++++++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/domain/modelling/overlay_applicator.py b/domain/modelling/overlay_applicator.py index e4df587b..3ba44a9d 100644 --- a/domain/modelling/overlay_applicator.py +++ b/domain/modelling/overlay_applicator.py @@ -9,17 +9,19 @@ then discarded). See ADR-0016. import copy from dataclasses import fields -from typing import Sequence +from typing import Optional, Sequence -from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.simulation import EpcSimulation +from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapVentilation +from domain.modelling.simulation import EpcSimulation, VentilationOverlay 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.""" + 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} @@ -30,5 +32,24 @@ def apply_simulations( value = getattr(overlay, overlay_field.name) if value is not None: setattr(part, overlay_field.name, value) + if simulation.ventilation is not None: + result.sap_ventilation = _fold_ventilation( + result.sap_ventilation, simulation.ventilation + ) return result + + +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/simulation.py b/domain/modelling/simulation.py index 7f9b7469..c39d960e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -27,6 +27,22 @@ class BuildingPartOverlay: 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 + + def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: return {} @@ -34,8 +50,10 @@ def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: @dataclass(frozen=True) class EpcSimulation: """A Simulation Overlay: the per-building-part changes a Measure Option - makes, keyed by `BuildingPartIdentifier`.""" + makes, keyed by `BuildingPartIdentifier`, plus an optional whole-dwelling + `ventilation` change (the Measure Dependency surface — ADR-0016).""" building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field( default_factory=_no_building_parts ) + ventilation: Optional[VentilationOverlay] = None diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 4f208158..3a60bbcd 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -7,8 +7,13 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, SapBuildingPart, + SapVentilation, +) +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + VentilationOverlay, ) -from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.overlay_applicator import apply_simulations from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, @@ -74,6 +79,70 @@ def test_later_simulation_wins_on_a_shared_field() -> None: 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() From 6b11c90295e442056e8d241467c578cbb9c824c2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:25:40 +0000 Subject: [PATCH 037/190] feat(modelling): inject forced Measure Dependencies into the package (#1161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeasureDependency(triggers, required) is a data-declared 'A requires B' edge. optimise_package gains a dependencies param: after the warm-start it injects any dependency whose triggers intersect the selected measure-types, BEFORE the whole-package re-score, so the dependency's (negative) SAP lands in the truthful figure and the undershoot/repair decision (ADR-0016). Forced — injected regardless of budget — but its cost counts toward package spend, so repair sees less headroom. Repair candidates fold in any dependency they newly trigger, so their marginal SAP-per-£ and incremental cost are truthful. The dependency never competes in the optimiser pool. Returned selected includes the injected deps. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/optimiser.py | 90 ++++++++++--- tests/domain/modelling/test_optimiser.py | 161 ++++++++++++++++++++++- 2 files changed, 229 insertions(+), 22 deletions(-) diff --git a/domain/modelling/optimiser.py b/domain/modelling/optimiser.py index beeb630e..e3a32c26 100644 --- a/domain/modelling/optimiser.py +++ b/domain/modelling/optimiser.py @@ -39,6 +39,19 @@ class ScoredOption: 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[str] + required: ScoredOption + + def _option_cost(option: MeasureOption) -> float: if option.cost is None: raise ValueError( @@ -104,32 +117,57 @@ def optimise_package( baseline_epc: EpcPropertyData, budget: Optional[float], target_sap: Optional[float], + dependencies: Sequence[MeasureDependency] = (), ) -> OptimisedPackage: - """Warm-start with the grouped knapsack (role-1 signal), re-score the chosen - package on the real scorer (role-2 truth), then — while the true SAP - undershoots ``target_sap`` and budget remains — greedy-add the untreated- - group Option with the best marginal SAP-per-£ and re-score, until the target - is met, no positive-marginal Option is affordable, or the budget is spent - (ADR-0016). ``target_sap``/``budget`` of None mean unconstrained.""" - selected: list[ScoredOption] = optimise(groups, budget) + """Warm-start with the grouped knapsack (role-1 signal), inject any forced + Measure Dependencies the selection triggers, re-score the whole package on + the real scorer (role-2 truth), then — while the true SAP undershoots + ``target_sap`` — greedy-add the untreated-group Option with the best + marginal SAP-per-£ (its own ventilation dependency folded in) and re-score, + until the target is met or no affordable improving Option remains (ADR-0016). + A forced dependency is mandatory-when-triggered: it is injected regardless of + budget and its cost counts toward the package spend (so repair sees less + headroom). ``target_sap``/``budget`` of None mean unconstrained. The returned + `selected` includes the injected dependencies.""" + chosen: list[ScoredOption] = optimise(groups, budget) + selected: list[ScoredOption] = _inject(chosen, dependencies) score: Score = _score(scorer, baseline_epc, selected) if target_sap is None: return OptimisedPackage(selected=selected, score=score) - spent: float = sum(_option_cost(s.option) for s in selected) while score.sap_continuous < target_sap: - remaining: Optional[float] = None if budget is None else budget - spent candidate = _best_repair_candidate( - groups, selected, scorer, baseline_epc, score, remaining + groups, chosen, dependencies, scorer, baseline_epc, score, budget ) if candidate is None: break - selected = [*selected, candidate] - spent += _option_cost(candidate.option) + 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[str] = {s.option.measure_type for s in chosen} + injected: list[ScoredOption] = list(chosen) + present: set[str] = set(chosen_types) + for dependency in dependencies: + required_type: str = 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: @@ -151,30 +189,40 @@ def _used_group_indices( def _best_repair_candidate( groups: list[list[ScoredOption]], - selected: list[ScoredOption], + chosen: list[ScoredOption], + dependencies: Sequence[MeasureDependency], scorer: Scorer, baseline_epc: EpcPropertyData, current: Score, - remaining_budget: Optional[float], + 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), affordable - within ``remaining_budget`` and strictly improving. None if there is none.""" - used: set[int] = _used_group_indices(groups, selected) + 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: - cost: float = _option_cost(option.option) - if remaining_budget is not None and cost > remaining_budget: + 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, [*selected, option]) + trial: Score = _score(scorer, baseline_epc, trial_selected) marginal: float = trial.sap_continuous - current.sap_continuous if marginal <= 0.0: continue - ratio: float = float("inf") if cost == 0.0 else marginal / cost + 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/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 77e83680..1631b69a 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, ) from domain.modelling.optimiser import ( + MeasureDependency, OptimisedPackage, ScoredOption, optimise, @@ -22,7 +23,11 @@ from domain.modelling.optimiser import ( ) from domain.modelling.package_scorer import Score from domain.modelling.recommendation import Cost, MeasureOption -from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from domain.modelling.simulation import ( + BuildingPartOverlay, + EpcSimulation, + VentilationOverlay, +) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) @@ -275,3 +280,157 @@ def test_repair_stops_when_no_affordable_improving_option_remains() -> None: "cavity_wall_insulation" } assert abs(package.score.sap_continuous - 45.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({"cavity_wall_insulation", "external_wall_insulation"}), + required=ScoredOption( + option=MeasureOption( + measure_type="mechanical_ventilation", + description="mechanical_ventilation", + overlay=_VENT_OVERLAY, + cost=Cost(total=cost, contingency_rate=0.0), + ), + sap_gain=0.0, + ), + ) + + +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_dependency_is_forced_even_when_it_pushes_over_budget() -> None: + # Arrange — the budget covers the wall but not the forced ventilation; + # ventilation is mandatory-when-triggered, so it is injected regardless. + 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 — budget exactly covers the wall; ventilation (£900) overspends. + 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 — forced in despite the overspend. + assert "mechanical_ventilation" in _selected_types(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 From 1bf5b4102d9ca9f4f4f54b39fde9cd60e38eb99d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:27:56 +0000 Subject: [PATCH 038/190] feat(modelling): ventilation Measure Dependency builder + has_ventilation guard (#1161) ventilation_dependency(epc, products) returns the forced 'fabric requires ventilation' edge: triggers = MEASURES_NEEDING_VENTILATION (cavity/internal/ external wall, mirroring legacy assumptions.measures_needing_ventilation), and a required Option installing decentralised MEV (mechanical_ventilation_kind= EXTRACT_OR_PIV_OUTSIDE), priced at two fully-loaded units. Returns None when the dwelling already lodges a mechanical ventilation kind (legacy has_ventilation guard), so MEV is never forced onto an already-ventilated dwelling. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/measure_dependency.py | 82 +++++++++++++ .../modelling/test_measure_dependency.py | 109 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 domain/modelling/measure_dependency.py create mode 100644 tests/domain/modelling/test_measure_dependency.py diff --git a/domain/modelling/measure_dependency.py b/domain/modelling/measure_dependency.py new file mode 100644 index 00000000..758ae789 --- /dev/null +++ b/domain/modelling/measure_dependency.py @@ -0,0 +1,82 @@ +"""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. + +The intervention is decentralised mechanical extract ventilation (MEV), the +legacy "mechanical, extract only" recommendation; it is only forced when the +dwelling is not already mechanically ventilated (legacy `has_ventilation`). +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.optimiser import MeasureDependency, ScoredOption +from domain.modelling.recommendation import Cost, MeasureOption +from domain.modelling.simulation import EpcSimulation, VentilationOverlay +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[str] = frozenset( + { + "cavity_wall_insulation", + "internal_wall_insulation", + "external_wall_insulation", + } +) + +_VENTILATION_MEASURE_TYPE = "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 ventilation_dependency( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[MeasureDependency]: + """The ventilation Measure Dependency for a dwelling, or None when it is + already mechanically ventilated (so MEV must not be forced on). The required + 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, + ) + return MeasureDependency( + triggers=MEASURES_NEEDING_VENTILATION, + required=ScoredOption(option=option, sap_gain=0.0), + ) + + +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/tests/domain/modelling/test_measure_dependency.py b/tests/domain/modelling/test_measure_dependency.py new file mode 100644 index 00000000..3c6cdd4c --- /dev/null +++ b/tests/domain/modelling/test_measure_dependency.py @@ -0,0 +1,109 @@ +"""Behaviour of the ventilation Measure Dependency builder: the data-declared +"fabric insulation requires adequate ventilation" edge, guarded by the +dwelling's existing ventilation. See CONTEXT.md (Measure Dependency) / ADR-0016. +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.measure_dependency import ( + MEASURES_NEEDING_VENTILATION, + ventilation_dependency, +) +from domain.modelling.optimiser import MeasureDependency +from domain.modelling.product import Product +from domain.modelling.simulation import EpcSimulation, 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.10 + ) + + +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_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() -> None: + # Arrange — 000490 lodges no mechanical ventilation kind. + baseline: EpcPropertyData = build_epc() + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert — a forced edge whose required Option installs MEV. + assert dependency is not None + assert dependency.triggers == MEASURES_NEEDING_VENTILATION + option = dependency.required.option + assert option.measure_type == "mechanical_ventilation" + assert isinstance(option.overlay, EpcSimulation) + assert option.overlay.ventilation == VentilationOverlay( + mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" + ) + + +def test_dependency_costs_two_installed_units_with_contingency() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert — two MEV units at £450 each, carrying the product's contingency. + assert dependency is not None + cost = dependency.required.option.cost + assert cost is not None + assert abs(cost.total - 900.0) <= 1e-9 + assert abs(cost.contingency_rate - 0.10) <= 1e-9 + + +def test_no_dependency_when_already_mechanically_ventilated() -> None: + # Arrange — the dwelling already has a mechanical ventilation kind, so MEV + # must not be forced on (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 + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert + assert dependency is None + + +def test_builds_a_dependency_when_the_dwelling_lodged_no_ventilation() -> None: + # Arrange — no SapVentilation at all counts as not mechanically ventilated. + baseline: EpcPropertyData = build_epc() + baseline.sap_ventilation = None + + # Act + dependency: Optional[MeasureDependency] = ventilation_dependency( + baseline, _StubProducts() + ) + + # Assert + assert dependency is not None From 0fec069988295ae377646c1a5670c41be797cdf5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:34:40 +0000 Subject: [PATCH 039/190] feat(modelling): wire the ventilation Measure Dependency into the orchestrator (#1161) ModellingOrchestrator builds the ventilation dependency per Property (suppressed when already mechanically ventilated) and passes it to optimise_package, so a selected wall measure forces MEV into the package before the re-score. Ventilation joins the role-3 cascade in best-practice order (walls -> roof -> ventilation -> floor) and persists as a Plan Measure carrying its real negative marginal and its cost. Added the mechanical_ventilation contingency rate (0.26, per legacy Costs.CONTINGENCIES). Integration test now seeds the ventilation Product and asserts the forced measure persists with <=0 SAP and 2x900 cost; the full-pipeline test seeds the Product too (the dependency is built for every not-yet-ventilated dwelling). On 000490 the real calculator scores MEV at -1.275 SAP. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/contingencies.py | 1 + orchestration/modelling_orchestrator.py | 25 +++++++- ...test_ara_first_run_pipeline_integration.py | 58 ++++++++++++++----- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 8d0230ff..d1e21357 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -10,6 +10,7 @@ _CONTINGENCY_RATES: dict[str, float] = { "loft_insulation": 0.10, "suspended_floor_insulation": 0.20, "solid_floor_insulation": 0.26, + "mechanical_ventilation": 0.26, } diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index da10f744..86939839 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -6,7 +6,9 @@ from typing import Final, Optional from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.floor_recommendation import recommend_floor_insulation +from domain.modelling.measure_dependency import ventilation_dependency from domain.modelling.optimiser import ( + MeasureDependency, OptimisedPackage, ScoredOption, optimise_package, @@ -33,12 +35,15 @@ from repositories.unit_of_work import UnitOfWork _INCREASING_EPC_GOAL: Final[str] = "Increasing EPC" # Best-practice install sequence for the role-3 attribution cascade (ADR-0016): -# fabric in walls → roof → floor order, per the legacy `Recommendations` class. +# 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", ) @@ -106,12 +111,18 @@ class ModellingOrchestrator: groups: list[list[ScoredOption]] = _scored_candidate_groups( scorer, effective_epc, products ) + # 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 + ) 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 @@ -145,6 +156,18 @@ def _candidate_recommendations( return [recommendation for recommendation in found if recommendation is not None] +def _measure_dependencies( + effective_epc: EpcPropertyData, products: ProductRepository +) -> list[MeasureDependency]: + """The forced Measure Dependencies for this Property — currently just + ventilation, suppressed when the dwelling is already mechanically + ventilated (ADR-0016).""" + dependency: Optional[MeasureDependency] = ventilation_dependency( + effective_epc, products + ) + return [dependency] if dependency is not None else [] + + def _scored_candidate_groups( scorer: PackageScorer, effective_epc: EpcPropertyData, diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 66791c1d..f4a0cf60 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -115,16 +115,28 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( ) ) # The sample EPC's solid floor is uninsulated, so the floor generator - # fires during candidate generation and prices against this Product. - session.add( - MaterialRow( - id=1, - type="solid_floor_insulation", - total_cost=25.0, - cost_unit="gbp_per_m2", - is_active=True, - description="Solid floor insulation", - ) + # 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). + session.add_all( + [ + 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", + ), + ] ) session.commit() @@ -220,6 +232,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( 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", + ), ] ) session.commit() @@ -238,8 +258,9 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( unit_of_work=unit_of_work, calculator=Sap10Calculator() ).run(property_ids=[30], scenario_ids=[7], portfolio_id=1) - # Assert — one Plan with two Plan Measures (wall + floor), each priced and - # attributed, linked by plan_id. + # 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(PlanRow).where(col(PlanRow.property_id) == 30) @@ -259,14 +280,21 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert plan.cost_of_works is not None assert plan.cost_of_works > 0.0 - measure_types = {rec.type for rec in rec_rows} - assert measure_types == { + by_type = {rec.type: rec for rec in rec_rows} + assert set(by_type) == { "cavity_wall_insulation", "suspended_floor_insulation", + "mechanical_ventilation", } 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 - assert rec.estimated_cost > 0.0 + # The forced ventilation costs two £450 units and is priced even though it + # was never a free choice in the pool. + assert abs(by_type["mechanical_ventilation"].estimated_cost - 900.0) <= 1e-6 + # The insulation measures earn positive SAP; ventilation's contribution is + # not positive (it only ever costs SAP — ADR-0016). + assert by_type["cavity_wall_insulation"].sap_points > 0.0 + assert by_type["mechanical_ventilation"].sap_points <= 0.0 From 90387c4a3647f5e8a4b16e1da56348641f417996 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:37:03 +0000 Subject: [PATCH 040/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20#1161=20(ventilation=20Measure=20Dependency)=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index e4ad963d..72f7cc5d 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,7 +1,7 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `34d4748a`. -**PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `0fec0699`. +**PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.** ## Issue status @@ -15,7 +15,7 @@ | #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) | **NOT STARTED — next** | +| #1161 | Measure Dependency (ventilation) | ✅ **closed this session** (`7c59e919`→`0fec0699`) | ## What this session did @@ -59,10 +59,20 @@ 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 `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/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**. + +Gotchas for the next agent: the ventilation Product/contingency must exist for any not-yet-ventilated dwelling (build-time fetch, not inject-time); the stub scorer in `test_optimiser.py` indexes `building_parts[MAIN]`, so vent-only overlays need the separate `_VentStubScorer`. + ## What's left -1. **#1161 — Measure Dependency (ventilation).** Per ADR-0016: a forced dependency (wall/roof insulation requires adequate ventilation) is **excluded from the optimiser's candidate pool** but **injected into the Optimised Package BEFORE the re-score**, so its (negative) SAP contribution lands in the truthful figure and the repair decision. Trigger set held as data (cf. legacy `assumptions.measures_needing_ventilation`). The injection point is `optimise_package` in `domain/modelling/optimiser.py` (inject after warm-start, before/within the re-score). -2. **Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); 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. +**Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); 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 From 84ec6da032a9d3850e460005f4384fd272f54f02 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:48:36 +0000 Subject: [PATCH 041/190] refactor(modelling): group domain/modelling into generators/scoring/optimisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domain/modelling/ had grown to 15 flat modules. Group the behavioural ones into subpackages — generators/ (wall/roof/floor Recommendation Generators), scoring/ (overlay applicator, package scorer, role-1/3 scoring), optimisation/ (optimiser + measure dependency) — and leave the shared value-object vocabulary (recommendation, plan, scenario, product, contingencies, simulation) flat at the top, since it is imported everywhere. Pure move + import-path rewrite across 89 import sites; no behaviour change. 136 pass, pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/generators/__init__.py | 0 .../{ => generators}/floor_recommendation.py | 0 .../{ => generators}/roof_recommendation.py | 0 .../{ => generators}/wall_recommendation.py | 0 domain/modelling/optimisation/__init__.py | 0 .../{ => optimisation}/measure_dependency.py | 2 +- domain/modelling/{ => optimisation}/optimiser.py | 2 +- domain/modelling/plan.py | 4 ++-- domain/modelling/scoring/__init__.py | 0 .../modelling/{ => scoring}/overlay_applicator.py | 0 domain/modelling/{ => scoring}/package_scorer.py | 2 +- domain/modelling/{ => scoring}/scoring.py | 2 +- orchestration/modelling_orchestrator.py | 14 +++++++------- .../domain/modelling/test_elmhurst_cascade_pins.py | 8 ++++---- .../domain/modelling/test_floor_recommendation.py | 4 ++-- tests/domain/modelling/test_measure_dependency.py | 4 ++-- tests/domain/modelling/test_optimiser.py | 4 ++-- tests/domain/modelling/test_overlay_applicator.py | 2 +- tests/domain/modelling/test_package_scorer.py | 2 +- tests/domain/modelling/test_plan.py | 4 ++-- tests/domain/modelling/test_roof_recommendation.py | 4 ++-- tests/domain/modelling/test_scoring.py | 4 ++-- tests/domain/modelling/test_wall_recommendation.py | 4 ++-- .../plan/test_plan_postgres_repository.py | 4 ++-- 24 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 domain/modelling/generators/__init__.py rename domain/modelling/{ => generators}/floor_recommendation.py (100%) rename domain/modelling/{ => generators}/roof_recommendation.py (100%) rename domain/modelling/{ => generators}/wall_recommendation.py (100%) create mode 100644 domain/modelling/optimisation/__init__.py rename domain/modelling/{ => optimisation}/measure_dependency.py (97%) rename domain/modelling/{ => optimisation}/optimiser.py (99%) create mode 100644 domain/modelling/scoring/__init__.py rename domain/modelling/{ => scoring}/overlay_applicator.py (100%) rename domain/modelling/{ => scoring}/package_scorer.py (95%) rename domain/modelling/{ => scoring}/scoring.py (98%) 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/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py similarity index 100% rename from domain/modelling/floor_recommendation.py rename to domain/modelling/generators/floor_recommendation.py diff --git a/domain/modelling/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py similarity index 100% rename from domain/modelling/roof_recommendation.py rename to domain/modelling/generators/roof_recommendation.py diff --git a/domain/modelling/wall_recommendation.py b/domain/modelling/generators/wall_recommendation.py similarity index 100% rename from domain/modelling/wall_recommendation.py rename to domain/modelling/generators/wall_recommendation.py 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/measure_dependency.py b/domain/modelling/optimisation/measure_dependency.py similarity index 97% rename from domain/modelling/measure_dependency.py rename to domain/modelling/optimisation/measure_dependency.py index 758ae789..5cdd68a4 100644 --- a/domain/modelling/measure_dependency.py +++ b/domain/modelling/optimisation/measure_dependency.py @@ -18,7 +18,7 @@ dwelling is not already mechanically ventilated (legacy `has_ventilation`). from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.optimiser import MeasureDependency, ScoredOption +from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption from domain.modelling.recommendation import Cost, MeasureOption from domain.modelling.simulation import EpcSimulation, VentilationOverlay from repositories.product.product_repository import ProductRepository diff --git a/domain/modelling/optimiser.py b/domain/modelling/optimisation/optimiser.py similarity index 99% rename from domain/modelling/optimiser.py rename to domain/modelling/optimisation/optimiser.py index e3a32c26..4c838629 100644 --- a/domain/modelling/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -24,7 +24,7 @@ from dataclasses import dataclass from typing import Optional, Protocol, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.package_scorer import Score +from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import MeasureOption from domain.modelling.simulation import EpcSimulation diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 405d6253..86063ebd 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -13,9 +13,9 @@ and its final-package (role-3) attributed **impact**. See CONTEXT.md. from dataclasses import dataclass from datatypes.epc.domain.epc import Epc -from domain.modelling.package_scorer import Score +from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import Cost -from domain.modelling.scoring import MeasureImpact +from domain.modelling.scoring.scoring import MeasureImpact @dataclass(frozen=True) 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/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py similarity index 100% rename from domain/modelling/overlay_applicator.py rename to domain/modelling/scoring/overlay_applicator.py diff --git a/domain/modelling/package_scorer.py b/domain/modelling/scoring/package_scorer.py similarity index 95% rename from domain/modelling/package_scorer.py rename to domain/modelling/scoring/package_scorer.py index bacf9e18..d9c88cf6 100644 --- a/domain/modelling/package_scorer.py +++ b/domain/modelling/scoring/package_scorer.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from typing import Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.simulation import EpcSimulation from domain.sap10_calculator.calculator import SapCalculator, SapResult diff --git a/domain/modelling/scoring.py b/domain/modelling/scoring/scoring.py similarity index 98% rename from domain/modelling/scoring.py rename to domain/modelling/scoring/scoring.py index c0558d8a..19fc2016 100644 --- a/domain/modelling/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from typing import Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.package_scorer import PackageScorer, Score +from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.recommendation import MeasureOption from domain.modelling.simulation import EpcSimulation diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 86939839..64617607 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -5,25 +5,25 @@ from typing import Final, Optional from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.floor_recommendation import recommend_floor_insulation -from domain.modelling.measure_dependency import ventilation_dependency -from domain.modelling.optimiser import ( +from domain.modelling.generators.floor_recommendation import recommend_floor_insulation +from domain.modelling.optimisation.measure_dependency import ventilation_dependency +from domain.modelling.optimisation.optimiser import ( MeasureDependency, OptimisedPackage, ScoredOption, optimise_package, ) -from domain.modelling.package_scorer import PackageScorer, Score +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.roof_recommendation import recommend_loft_insulation +from domain.modelling.generators.roof_recommendation import recommend_loft_insulation from domain.modelling.scenario import Scenario -from domain.modelling.scoring import ( +from domain.modelling.scoring.scoring import ( MeasureImpact, independent_option_impacts, marginal_impacts, ) -from domain.modelling.wall_recommendation import recommend_cavity_wall +from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from domain.sap10_calculator.calculator import SapCalculator from repositories.product.product_repository import ProductRepository from repositories.unit_of_work import UnitOfWork diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 16202022..dd53df22 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -17,13 +17,13 @@ from __future__ import annotations from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.package_scorer import PackageScorer, Score +from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation -from domain.modelling.floor_recommendation import recommend_floor_insulation -from domain.modelling.roof_recommendation import recommend_loft_insulation +from domain.modelling.generators.floor_recommendation import recommend_floor_insulation +from domain.modelling.generators.roof_recommendation import recommend_loft_insulation from domain.modelling.simulation import EpcSimulation -from domain.modelling.wall_recommendation import recommend_cavity_wall +from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from domain.sap10_calculator.calculator import Sap10Calculator, SapResult from repositories.product.product_repository import ProductRepository from tests.domain.modelling._elmhurst_recommendation import ( diff --git a/tests/domain/modelling/test_floor_recommendation.py b/tests/domain/modelling/test_floor_recommendation.py index 8ed2871f..1ce5bd7f 100644 --- a/tests/domain/modelling/test_floor_recommendation.py +++ b/tests/domain/modelling/test_floor_recommendation.py @@ -9,10 +9,10 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, SapBuildingPart, ) -from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation -from domain.modelling.floor_recommendation import recommend_floor_insulation +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, diff --git a/tests/domain/modelling/test_measure_dependency.py b/tests/domain/modelling/test_measure_dependency.py index 3c6cdd4c..f1271cf9 100644 --- a/tests/domain/modelling/test_measure_dependency.py +++ b/tests/domain/modelling/test_measure_dependency.py @@ -6,11 +6,11 @@ dwelling's existing ventilation. See CONTEXT.md (Measure Dependency) / ADR-0016. from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.modelling.measure_dependency import ( +from domain.modelling.optimisation.measure_dependency import ( MEASURES_NEEDING_VENTILATION, ventilation_dependency, ) -from domain.modelling.optimiser import MeasureDependency +from domain.modelling.optimisation.optimiser import MeasureDependency from domain.modelling.product import Product from domain.modelling.simulation import EpcSimulation, VentilationOverlay from repositories.product.product_repository import ProductRepository diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 1631b69a..61d0fbcc 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -14,14 +14,14 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) -from domain.modelling.optimiser import ( +from domain.modelling.optimisation.optimiser import ( MeasureDependency, OptimisedPackage, ScoredOption, optimise, optimise_package, ) -from domain.modelling.package_scorer import Score +from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import Cost, MeasureOption from domain.modelling.simulation import ( BuildingPartOverlay, diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 3a60bbcd..27a79cc2 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -14,7 +14,7 @@ from domain.modelling.simulation import ( EpcSimulation, VentilationOverlay, ) -from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.scoring.overlay_applicator import apply_simulations from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) diff --git a/tests/domain/modelling/test_package_scorer.py b/tests/domain/modelling/test_package_scorer.py index ffe50cd5..9310e0e6 100644 --- a/tests/domain/modelling/test_package_scorer.py +++ b/tests/domain/modelling/test_package_scorer.py @@ -9,7 +9,7 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) -from domain.modelling.package_scorer import PackageScorer, Score +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 ( diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py index fe6828ba..d2e3a68b 100644 --- a/tests/domain/modelling/test_plan.py +++ b/tests/domain/modelling/test_plan.py @@ -8,10 +8,10 @@ band). Single-phase, flat post-retrofit figures (ADR-0005 / ADR-0017). from __future__ import annotations from datatypes.epc.domain.epc import Epc -from domain.modelling.package_scorer import Score +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 import MeasureImpact +from domain.modelling.scoring.scoring import MeasureImpact def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure: diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py index f801ee7d..baa2845b 100644 --- a/tests/domain/modelling/test_roof_recommendation.py +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -8,10 +8,10 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, SapBuildingPart, ) -from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation -from domain.modelling.roof_recommendation import recommend_loft_insulation +from domain.modelling.generators.roof_recommendation import recommend_loft_insulation from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py index af286018..97169667 100644 --- a/tests/domain/modelling/test_scoring.py +++ b/tests/domain/modelling/test_scoring.py @@ -10,9 +10,9 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) -from domain.modelling.package_scorer import PackageScorer, Score +from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.recommendation import MeasureOption -from domain.modelling.scoring import ( +from domain.modelling.scoring.scoring import ( MeasureImpact, independent_option_impacts, marginal_impacts, diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py index 2f850a8a..182ce2a3 100644 --- a/tests/domain/modelling/test_wall_recommendation.py +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -8,10 +8,10 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, SapBuildingPart, ) -from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation -from domain.modelling.wall_recommendation import recommend_cavity_wall +from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index d698a470..975a7e38 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -14,10 +14,10 @@ from sqlalchemy import Engine from sqlmodel import Session, col, select from datatypes.epc.domain.epc import Epc -from domain.modelling.package_scorer import Score +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 import MeasureImpact +from domain.modelling.scoring.scoring import MeasureImpact from infrastructure.postgres.plan_table import PlanRow, RecommendationRow from repositories.plan.plan_postgres_repository import PlanPostgresRepository From 143f8b08051f0136cd4344f4f05e107bb4aff01f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:50:21 +0000 Subject: [PATCH 042/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20reflect=20generators/scoring/optimisation=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index 72f7cc5d..dce720d6 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `0fec0699`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `84ec6da0`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.** ## Issue status @@ -33,12 +33,19 @@ - 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: `recommend_cavity_wall` / `recommend_loft_insulation` (300 mm) / `recommend_floor_insulation` (sets `floor_insulation_type_str`). -- `simulation.py` overlay + `overlay_applicator.apply_simulations` (generic field-fold) + `package_scorer.PackageScorer.score` (role 2) + `scoring.py` (`marginal_impacts` role 3, `independent_option_impacts` role 1). +- 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). -- `optimiser.py` — `optimise(groups, budget)` (exact knapsack) + `optimise_package(...)` (re-score + greedy repair, `Scorer` Protocol, `OptimisedPackage`). +- `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`. @@ -64,8 +71,8 @@ Commit per TDD slice; conventional-commit message ending `Co-Authored-By: Claude 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 `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/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. +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**. Gotchas for the next agent: the ventilation Product/contingency must exist for any not-yet-ventilated dwelling (build-time fetch, not inject-time); the stub scorer in `test_optimiser.py` indexes `building_parts[MAIN]`, so vent-only overlays need the separate `_VentStubScorer`. From 631df921defd19c74bd57bfa1371c5bffacacf24 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:01:14 +0000 Subject: [PATCH 043/190] feat(modelling): ventilation Recommendation Generator (detect + price) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_ventilation(epc, products) does the same two jobs as wall/roof/floor — detect applicability (the has_ventilation guard) and price the work (2 MEV units + contingency) — and returns a Recommendation. Ventilation is a Recommendation like the others; what makes it special (forced when fabric is selected, excluded from the free pool) stays in the Measure Dependency layer. Detect + price now live in generators/, not inline in measure_dependency.py. Note it is NOT run by the candidate-pool runner — it is consumed only by the dependency path. Co-Authored-By: Claude Opus 4.8 --- .../generators/ventilation_recommendation.py | 66 +++++++++++++ .../test_ventilation_recommendation.py | 96 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 domain/modelling/generators/ventilation_recommendation.py create mode 100644 tests/domain/modelling/test_ventilation_recommendation.py diff --git a/domain/modelling/generators/ventilation_recommendation.py b/domain/modelling/generators/ventilation_recommendation.py new file mode 100644 index 00000000..f3eaebec --- /dev/null +++ b/domain/modelling/generators/ventilation_recommendation.py @@ -0,0 +1,66 @@ +"""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.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, VentilationOverlay +from repositories.product.product_repository import ProductRepository + +_VENTILATION_MEASURE_TYPE = "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, + ) + 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/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 From 02afc04ce237126f152ef0dde83e9a7f74058145 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:04:17 +0000 Subject: [PATCH 044/190] refactor(modelling): ventilation_dependency delegates to the generator + wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit measure_dependency.py now owns only the selection semantics: the trigger set and the forced-edge wrapping. It delegates production (detection + pricing) to recommend_ventilation and wraps the returned Recommendation into the MeasureDependency, picking the cheapest Option (one MEV today; readies the seam for MEV-c / MVHR). The orchestrator's _measure_dependencies call is unchanged. Trimmed the now-redundant option-detail assertions — those live in test_ventilation_recommendation. 138 pass, behaviour-preserving. Co-Authored-By: Claude Opus 4.8 --- .../optimisation/measure_dependency.py | 74 ++++++++----------- .../modelling/test_measure_dependency.py | 64 +++++----------- 2 files changed, 49 insertions(+), 89 deletions(-) diff --git a/domain/modelling/optimisation/measure_dependency.py b/domain/modelling/optimisation/measure_dependency.py index 5cdd68a4..9df7d468 100644 --- a/domain/modelling/optimisation/measure_dependency.py +++ b/domain/modelling/optimisation/measure_dependency.py @@ -10,17 +10,22 @@ 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. -The intervention is decentralised mechanical extract ventilation (MEV), the -legacy "mechanical, extract only" recommendation; it is only forced when the -dwelling is not already mechanically ventilated (legacy `has_ventilation`). +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.optimisation.optimiser import MeasureDependency, ScoredOption -from domain.modelling.recommendation import Cost, MeasureOption -from domain.modelling.simulation import EpcSimulation, VentilationOverlay +from domain.modelling.recommendation import MeasureOption, Recommendation from repositories.product.product_repository import ProductRepository # The measure types that force a ventilation dependency (cf. legacy @@ -33,50 +38,35 @@ MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset( } ) -_VENTILATION_MEASURE_TYPE = "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 ventilation_dependency( epc: EpcPropertyData, products: ProductRepository ) -> Optional[MeasureDependency]: - """The ventilation Measure Dependency for a dwelling, or None when it is - already mechanically ventilated (so MEV must not be forced on). The required - Option installs MEV and is priced at two fully-loaded units.""" - if _already_mechanically_ventilated(epc): + """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 - - 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, - ) return MeasureDependency( triggers=MEASURES_NEEDING_VENTILATION, - required=ScoredOption(option=option, sap_gain=0.0), + # Forced, never freely scored — the role-1 signal is irrelevant (0.0). + required=ScoredOption(option=_required_option(recommendation), sap_gain=0.0), ) -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 - ) +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/tests/domain/modelling/test_measure_dependency.py b/tests/domain/modelling/test_measure_dependency.py index f1271cf9..d4914deb 100644 --- a/tests/domain/modelling/test_measure_dependency.py +++ b/tests/domain/modelling/test_measure_dependency.py @@ -1,6 +1,8 @@ -"""Behaviour of the ventilation Measure Dependency builder: the data-declared -"fabric insulation requires adequate ventilation" edge, guarded by the -dwelling's existing ventilation. See CONTEXT.md (Measure Dependency) / ADR-0016. +"""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 @@ -12,7 +14,6 @@ from domain.modelling.optimisation.measure_dependency import ( ) from domain.modelling.optimisation.optimiser import MeasureDependency from domain.modelling.product import Product -from domain.modelling.simulation import EpcSimulation, VentilationOverlay from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, @@ -23,10 +24,8 @@ 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.10 + measure_type=measure_type, unit_cost_per_m2=450.0, contingency_rate=0.26 ) @@ -42,8 +41,9 @@ def test_triggers_are_the_fabric_wall_measures() -> None: ) -def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() -> None: - # Arrange — 000490 lodges no mechanical ventilation kind. +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 @@ -51,37 +51,21 @@ def test_builds_a_ventilation_dependency_for_a_naturally_ventilated_dwelling() - baseline, _StubProducts() ) - # Assert — a forced edge whose required Option installs MEV. + # 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 - option = dependency.required.option - assert option.measure_type == "mechanical_ventilation" - assert isinstance(option.overlay, EpcSimulation) - assert option.overlay.ventilation == VentilationOverlay( - mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE" - ) - - -def test_dependency_costs_two_installed_units_with_contingency() -> None: - # Arrange - baseline: EpcPropertyData = build_epc() - - # Act - dependency: Optional[MeasureDependency] = ventilation_dependency( - baseline, _StubProducts() - ) - - # Assert — two MEV units at £450 each, carrying the product's contingency. - assert dependency is not None + 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 - assert abs(cost.contingency_rate - 0.10) <= 1e-9 -def test_no_dependency_when_already_mechanically_ventilated() -> None: - # Arrange — the dwelling already has a mechanical ventilation kind, so MEV - # must not be forced on (legacy has_ventilation guard). +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" @@ -93,17 +77,3 @@ def test_no_dependency_when_already_mechanically_ventilated() -> None: # Assert assert dependency is None - - -def test_builds_a_dependency_when_the_dwelling_lodged_no_ventilation() -> None: - # Arrange — no SapVentilation at all counts as not mechanically ventilated. - baseline: EpcPropertyData = build_epc() - baseline.sap_ventilation = None - - # Act - dependency: Optional[MeasureDependency] = ventilation_dependency( - baseline, _StubProducts() - ) - - # Assert - assert dependency is not None From d1f8d516f6f1dae9cd2b995a4a424daa5d668aaf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:09:32 +0000 Subject: [PATCH 045/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20ventilation=20now=20a=20generator=20+=20dependency=20delegat?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index dce720d6..0152a22f 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `84ec6da0`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `02afc04c`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.** ## Issue status @@ -75,7 +75,9 @@ Forks resolved with the user (AskUserQuestion): **guard now** (skip when already 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**. -Gotchas for the next agent: the ventilation Product/contingency must exist for any not-yet-ventilated dwelling (build-time fetch, not inject-time); the stub scorer in `test_optimiser.py` indexes `building_parts[MAIN]`, so vent-only overlays need the separate `_VentStubScorer`. +**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`. ## What's left From 5620f49f189ec195dbfe087b10b8e54bbe0d2c65 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:26:02 +0000 Subject: [PATCH 046/190] =?UTF-8?q?docs(modelling):=20ADR-0016=20amendment?= =?UTF-8?q?=20=E2=80=94=20optimiser=20objective=20is=20least-cost-to-targe?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original ADR-0016 mis-specified the warm-start objective as maximise-gain- subject-to-budget (with the target a repair floor); the rebuild faithfully implemented that wrong objective. The intended behaviour is the legacy StrategicOptimiser Case 1: minimise cost subject to (true) SAP gain >= target and cost <= budget, falling back to max-gain-within-budget only when the target is unreachable. For Increasing EPC this is least-cost-to-target: cheapest package reaching the band, stops at the target (no overshoot into a higher band), surplus budget unspent. Also records: target predicate sap_continuous >= band floor (conservative, no legacy slack — re-score+repair supersede it); ventilation-aware selection (the forced dependency, -1 to -5 SAP, is folded into candidate evaluation with a real negative role-1 signal, not just injected afterwards); presence-vs-awareness enforcement; warm-start+re-score+repair structure and scalability rationale kept. Sharpened the CONTEXT.md Optimised Package definition to match. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- ...ge-rescore-over-warm-start-optimisation.md | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4634df4f..9cf31602 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -227,7 +227,7 @@ A "selecting A requires B" edge between **Recommendations**, for couplings that _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**: diff --git a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md index 6b1395ea..2c87af1e 100644 --- a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md +++ b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md @@ -20,3 +20,24 @@ This resolves the open question deferred in **ADR-0005 §14**. - 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. + +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. From 05a4f5f84a4ba05ca7e9c429ac3332b1f75b4bb8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:31:26 +0000 Subject: [PATCH 047/190] =?UTF-8?q?feat(modelling):=20optimise=5Fmin=5Fcos?= =?UTF-8?q?t=20=E2=80=94=20least-cost-to-target=20selector=20(#1152=20foll?= =?UTF-8?q?ow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact-enumeration sibling to optimise(): pick <=1 option per group to minimise total cost subject to total gain >= target_gain and cost <= budget (None = unconstrained). Ties broken toward higher gain ('recommend more'). Returns None when no package within budget reaches the target (caller falls back to max-gain); a non-positive target is met by the empty package. This is the warm-start objective for an Increasing EPC goal per the ADR-0016 amendment (least-cost-to-target, not max-gain). Dependency-blind for now; ventilation-aware selection lands in a later slice. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/optimisation/optimiser.py | 36 +++++++ tests/domain/modelling/test_optimiser.py | 120 +++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 4c838629..2b77cbdb 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -90,6 +90,42 @@ def optimise( return best +def optimise_min_cost( + groups: list[list[ScoredOption]], + budget: Optional[float], + target_gain: float, +) -> 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.""" + 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: float = sum(_option_cost(s.option) for s in selected) + if budget is not None and total_cost > budget: + continue + total_gain: float = sum(s.sap_gain for s in selected) + 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.""" diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 61d0fbcc..fc6f3fb9 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -19,6 +19,7 @@ from domain.modelling.optimisation.optimiser import ( OptimisedPackage, ScoredOption, optimise, + optimise_min_cost, optimise_package, ) from domain.modelling.scoring.package_scorer import Score @@ -203,6 +204,125 @@ def test_within_budget_partial_selection_prefers_the_higher_gain_option() -> Non 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. From 2bf42d046e84ae189f72b3e71cdf4340b5982f76 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:43:06 +0000 Subject: [PATCH 048/190] feat(modelling): optimise_package targets least-cost, falls back to max-gain Rewire the objective per the ADR-0016 amendment. With a target_sap (Increasing EPC): warm-start optimise_min_cost (cheapest package reaching target_gain = target_sap - baseline within budget) -> inject dependencies -> re-score -> repair toward target; if the warm-start is infeasible or the repaired package still falls short on the true score, fall back to max-gain-within-budget (best effort). Without a target_sap: max-gain (unchanged). The min-cost objective stops at the target without overshooting into a higher band; surplus budget is left unspent. Extracted _max_gain_package (no-target path + fallback) and _repair_to_target (inject + re-score + greedy repair). Dependency injection and the repair loop are preserved; all prior optimiser + dependency tests pass unchanged. Ventilation-aware *selection* is the next slice; injection is still post-warm-start here. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/optimisation/optimiser.py | 78 ++++++++++++++++---- tests/domain/modelling/test_optimiser.py | 83 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 13 deletions(-) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 2b77cbdb..29bd5a42 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -155,22 +155,74 @@ def optimise_package( target_sap: Optional[float], dependencies: Sequence[MeasureDependency] = (), ) -> OptimisedPackage: - """Warm-start with the grouped knapsack (role-1 signal), inject any forced - Measure Dependencies the selection triggers, re-score the whole package on - the real scorer (role-2 truth), then — while the true SAP undershoots - ``target_sap`` — greedy-add the untreated-group Option with the best - marginal SAP-per-£ (its own ventilation dependency folded in) and re-score, - until the target is met or no affordable improving Option remains (ADR-0016). - A forced dependency is mandatory-when-triggered: it is injected regardless of - budget and its cost counts toward the package spend (so repair sees less - headroom). ``target_sap``/``budget`` of None mean unconstrained. The returned - `selected` includes the injected dependencies.""" + """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.""" + if target_sap is None: + return _max_gain_package(groups, scorer, baseline_epc, budget, dependencies) + + baseline_sap: float = _score(scorer, baseline_epc, []).sap_continuous + target_gain: float = target_sap - baseline_sap + chosen: Optional[list[ScoredOption]] = optimise_min_cost( + groups, budget, target_gain + ) + if chosen is not None: + package: OptimisedPackage = _repair_to_target( + chosen, groups, dependencies, 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, dependencies) + + +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 injected and re-scored — the + no-target objective and the unreachable-target fallback.""" chosen: list[ScoredOption] = optimise(groups, budget) selected: list[ScoredOption] = _inject(chosen, dependencies) - score: Score = _score(scorer, baseline_epc, selected) - if target_sap is None: - return OptimisedPackage(selected=selected, score=score) + 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 diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index fc6f3fb9..dfacf51a 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -402,6 +402,89 @@ def test_repair_stops_when_no_affordable_improving_option_remains() -> None: 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( From af501fce0e1816732ee86f866fc85c2baf8e0146 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:16:26 +0000 Subject: [PATCH 049/190] =?UTF-8?q?feat(modelling):=20ventilation-aware=20?= =?UTF-8?q?selection=20=E2=80=94=20price=20the=20forced=20dependency=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warm-start (and max-gain fallback) now price each forced Measure Dependency the candidate triggers, not just inject it afterwards: optimise/optimise_min_cost fold dependencies into each candidate's cost+gain via _augmented_cost_gain, and optimise_package scores each dependency's true role-1 signal (_with_role1_signals) instead of the 0.0 placeholder. This stops the min-cost objective (i) ignoring the ~£900 a wall drags in (a wall-free package reaching target can be cheaper) and (ii) picking a small-gain wall whose mandatory ventilation (down to -5 SAP) makes it net-negative, which repair cannot un-pick. Budget is now a hard envelope: the constraint applies to the augmented (measure + its ventilation) cost, so a wall that fits alone but whose ventilation would bust the budget is DROPPED rather than forced over budget. This reverses the earlier 'forced regardless of budget' call (which made sense when selection was ventilation-blind). Safety invariant intact — presence still injected on every path; we just never recommend a wall we can't afford to ventilate. ADR-0016 amendment updated. 94 modelling+orchestration tests pass. Co-Authored-By: Claude Opus 4.8 --- ...ge-rescore-over-warm-start-optimisation.md | 2 + domain/modelling/optimisation/optimiser.py | 93 +++++++++++++++---- tests/domain/modelling/test_optimiser.py | 55 +++++++++-- 3 files changed, 127 insertions(+), 23 deletions(-) diff --git a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md index 2c87af1e..121b91ef 100644 --- a/docs/adr/0016-package-rescore-over-warm-start-optimisation.md +++ b/docs/adr/0016-package-rescore-over-warm-start-optimisation.md @@ -40,4 +40,6 @@ For an **Increasing EPC** goal the objective is therefore **least-cost-to-target **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/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index 29bd5a42..de5e0225 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -61,14 +61,21 @@ def _option_cost(option: MeasureOption) -> float: def optimise( - groups: list[list[ScoredOption]], budget: Optional[float] + 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).""" - # Each group offers: skip it (None) or take exactly one of its Options. + 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 ] @@ -80,20 +87,33 @@ def optimise( selected: list[ScoredOption] = [ choice for choice in combo if choice is not None ] - total_cost: float = sum(_option_cost(s.option) for s in selected) + total_cost, total_gain = _augmented_cost_gain(selected, dependencies) if budget is not None and total_cost > budget: continue - total_gain: float = sum(s.sap_gain for s in selected) # 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`` @@ -102,7 +122,12 @@ def optimise_min_cost( 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.""" + ``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 ] @@ -114,10 +139,9 @@ def optimise_min_cost( selected: list[ScoredOption] = [ choice for choice in combo if choice is not None ] - total_cost: float = sum(_option_cost(s.option) for s in selected) + total_cost, total_gain = _augmented_cost_gain(selected, dependencies) if budget is not None and total_cost > budget: continue - total_gain: float = sum(s.sap_gain for s in selected) if total_gain < target_gain: continue # Minimise cost; on a tie prefer the higher-gain package. @@ -173,23 +197,57 @@ def optimise_package( 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.""" - if target_sap is None: - return _max_gain_package(groups, scorer, baseline_epc, budget, dependencies) - 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 + groups, budget, target_gain, deps ) if chosen is not None: package: OptimisedPackage = _repair_to_target( - chosen, groups, dependencies, scorer, baseline_epc, budget, target_sap + 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, dependencies) + 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( @@ -199,9 +257,10 @@ def _max_gain_package( budget: Optional[float], dependencies: Sequence[MeasureDependency], ) -> OptimisedPackage: - """Max-gain-within-budget, dependencies injected and re-scored — the - no-target objective and the unreachable-target fallback.""" - chosen: list[ScoredOption] = optimise(groups, budget) + """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) diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index dfacf51a..333909d0 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -537,6 +537,46 @@ def _ventilation_dependency(*, cost: float) -> MeasureDependency: ) +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({"cavity_wall_insulation"}), + required=ScoredOption( + option=MeasureOption( + measure_type="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. @@ -587,15 +627,17 @@ def test_dependency_not_injected_without_a_trigger_measure() -> None: assert abs(package.score.sap_continuous - 44.0) <= 1e-9 -def test_dependency_is_forced_even_when_it_pushes_over_budget() -> None: - # Arrange — the budget covers the wall but not the forced ventilation; - # ventilation is mandatory-when-triggered, so it is injected regardless. +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 — budget exactly covers the wall; ventilation (£900) overspends. + # Act — tight budget; ventilation-aware selection prices the £900 in. package: OptimisedPackage = optimise_package( groups=groups, scorer=scorer, @@ -605,8 +647,9 @@ def test_dependency_is_forced_even_when_it_pushes_over_budget() -> None: dependencies=[_ventilation_dependency(cost=900.0)], ) - # Assert — forced in despite the overspend. - assert "mechanical_ventilation" in _selected_types(package.selected) + # 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: From 641c1bd7f6f96041391cfad5fe28b8810faf39f7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:20:45 +0000 Subject: [PATCH 050/190] test(modelling): pin least-cost-to-target end-to-end through the orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator already threads budget/target_sap/dependencies into optimise_package, so no orchestrator change was needed. Add an integration test proving the new objective end-to-end on the real calculator: a band-D property (~57.4) with a goal of band D — already met — yields a Plan with NO measures and zero cost (the old max-gain objective would have recommended wall+floor+vent, improving within the band it is already in). Clarified that the existing multi-measure test now exercises the max-gain fallback (goal C unreachable from D, tops out ~61). Narrowed Optional sap_points/estimated_cost through locals to keep pyright strict-clean. Co-Authored-By: Claude Opus 4.8 --- ...test_ara_first_run_pipeline_integration.py | 106 +++++++++++++++++- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index f4a0cf60..cca8473a 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -198,7 +198,10 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( # 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. + # 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( @@ -293,8 +296,103 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( 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. - assert abs(by_type["mechanical_ventilation"].estimated_cost - 900.0) <= 1e-6 + 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). - assert by_type["cavity_wall_insulation"].sap_points > 0.0 - assert by_type["mechanical_ventilation"].sap_points <= 0.0 + 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 + + +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( + ScenarioRow( + id=8, goal="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=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", + ), + ] + ) + 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() + ).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(PlanRow).where(col(PlanRow.property_id) == 31) + ).first() + assert plan is not None + rec_rows = session.exec( + select(RecommendationRow).where( + col(RecommendationRow.plan_id) == plan.id + ) + ).all() + + assert rec_rows == [] + assert plan.cost_of_works == 0.0 + assert plan.post_epc_rating is Epc.D From 660dc5424604aed05bc362fe7b3d5be3576b6f97 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:21:31 +0000 Subject: [PATCH 051/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20optimiser=20objective=20realigned=20to=20least-cost-to-targe?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index 0152a22f..a58f56c3 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `02afc04c`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `641c1bd7`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.** ## Issue status @@ -79,6 +79,17 @@ Forks resolved with the user (AskUserQuestion): **guard now** (skip when already 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. + ## What's left **Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); 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. From 75ba5dd7445ddfff852ec09c53f110921af9aa4b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:17:03 +0000 Subject: [PATCH 052/190] =?UTF-8?q?docs(modelling):=20ADR-0014=20amendment?= =?UTF-8?q?=20=E2=80=94=20cross-stage=20billing=20+=20Modelling=20post-pac?= =?UTF-8?q?kage=20bills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the /grill-with-docs design for the Modelling Bill-Derivation slice: Bill Derivation is cross-stage (relocate Bill/EnergyBreakdown/BillDerivation/ sap_fuel to a neutral domain/billing/); Modelling bills the fully-overlaid post-package SapResult (so fuel-switch measures price at the new fuel for free), diffing against the baseline at the same FuelRates snapshot; the post-package and baseline SapResults are captured from scores the optimiser/orchestrator already compute (Score.sap_result), so no second calculate; FuelRatesRepository is constructor-injected into ModellingOrchestrator mirroring Baseline; plan-level columns this slice, per-measure telescoping bill cascade next (energy_savings is vestigial, left NULL). Co-Authored-By: Claude Opus 4.8 --- .../0014-bill-derivation-from-real-fuel-rates.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 d33a7810..0d195f77 100644 --- a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md +++ b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md @@ -136,3 +136,19 @@ the fuel each end use burns come from?* Resolved in a `/grill-with-docs` session `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. From ced6287baac2033b09dd72894f36f76cc26007d2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:19:23 +0000 Subject: [PATCH 053/190] refactor(billing): relocate Bill Derivation to domain/billing/ (cross-stage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bill / EnergyBreakdown / BillDerivation / sap_fuel were under domain/property_baseline/ only because Baseline was built first. The Modelling stage now needs them too, so move them (and their tests) to a neutral domain/billing/ — Fuel/FuelRates already live in the shared domain/fuel_rates/. Avoids a modelling -> property_baseline cross-stage import and a package name that wrongly implies ownership (ADR-0011, ADR-0014 amendment). Pure git mv + import rewrite across 10 files; 40 billing/baseline/repo tests pass, pyright strict clean. CONTEXT.md Bill Derivation location updated. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- domain/billing/__init__.py | 0 domain/{property_baseline => billing}/bill.py | 2 +- domain/{property_baseline => billing}/bill_derivation.py | 2 +- domain/{property_baseline => billing}/sap_fuel.py | 0 domain/property_baseline/property_baseline_performance.py | 2 +- .../postgres/property_baseline_performance_table.py | 2 +- orchestration/property_baseline_orchestrator.py | 4 ++-- tests/domain/billing/__init__.py | 0 .../{property_baseline => billing}/test_bill_derivation.py | 4 ++-- .../{property_baseline => billing}/test_energy_breakdown.py | 2 +- tests/domain/{property_baseline => billing}/test_sap_fuel.py | 2 +- tests/orchestration/test_property_baseline_orchestrator.py | 2 +- .../test_property_baseline_postgres_repository.py | 2 +- 14 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 domain/billing/__init__.py rename domain/{property_baseline => billing}/bill.py (98%) rename domain/{property_baseline => billing}/bill_derivation.py (98%) rename domain/{property_baseline => billing}/sap_fuel.py (100%) create mode 100644 tests/domain/billing/__init__.py rename tests/domain/{property_baseline => billing}/test_bill_derivation.py (95%) rename tests/domain/{property_baseline => billing}/test_energy_breakdown.py (98%) rename tests/domain/{property_baseline => billing}/test_sap_fuel.py (96%) diff --git a/CONTEXT.md b/CONTEXT.md index 9cf31602..36ae6d4c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,7 +118,7 @@ The process that translates an Optimised Package into cert-field changes and pro _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**: diff --git a/domain/billing/__init__.py b/domain/billing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/property_baseline/bill.py b/domain/billing/bill.py similarity index 98% rename from domain/property_baseline/bill.py rename to domain/billing/bill.py index 110aa237..0e06cf27 100644 --- a/domain/property_baseline/bill.py +++ b/domain/billing/bill.py @@ -6,7 +6,7 @@ from enum import Enum from typing import Optional, TYPE_CHECKING 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 if TYPE_CHECKING: from domain.sap10_calculator.calculator import SapResult 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/property_baseline/property_baseline_performance.py b/domain/property_baseline/property_baseline_performance.py index 3951611d..6fee9858 100644 --- a/domain/property_baseline/property_baseline_performance.py +++ b/domain/property_baseline/property_baseline_performance.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Optional -from domain.property_baseline.bill import Bill +from domain.billing.bill import Bill from domain.property_baseline.performance import Performance from domain.property_baseline.rebaseliner import RebaselineReason diff --git a/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 908534c0..1327f63b 100644 --- a/infrastructure/postgres/property_baseline_performance_table.py +++ b/infrastructure/postgres/property_baseline_performance_table.py @@ -5,7 +5,7 @@ from typing import ClassVar, Optional, cast from sqlmodel import Field, SQLModel from datatypes.epc.domain.epc import Epc -from domain.property_baseline.bill import Bill, BillSection, BillSectionCost +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 diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index faeaad92..6c749e36 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -6,8 +6,8 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, RenewableHeatIncentive, ) -from domain.property_baseline.bill import EnergyBreakdown -from domain.property_baseline.bill_derivation import BillDerivation +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 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/property_baseline/test_energy_breakdown.py b/tests/domain/billing/test_energy_breakdown.py similarity index 98% rename from tests/domain/property_baseline/test_energy_breakdown.py rename to tests/domain/billing/test_energy_breakdown.py index ffe7ffb0..4c64da29 100644 --- a/tests/domain/property_baseline/test_energy_breakdown.py +++ b/tests/domain/billing/test_energy_breakdown.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest from domain.fuel_rates.fuel import Fuel -from domain.property_baseline.bill import BillSection, EnergyBreakdown +from domain.billing.bill import BillSection, EnergyBreakdown from domain.sap10_calculator.calculator import SapResult 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/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 1e0f5ec2..9183a8b3 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -10,7 +10,7 @@ from datatypes.epc.domain.epc_property_data import ( RenewableHeatIncentive, ) from domain.fuel_rates.fuel import Fuel -from domain.property_baseline.bill import BillSection +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 ( 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 a46a65f9..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,7 +4,7 @@ from sqlalchemy import Engine from sqlmodel import Session from datatypes.epc.domain.epc import Epc -from domain.property_baseline.bill import Bill, BillSection, BillSectionCost +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 ( From 2bbc401f0d0ef5545e02084c3be30eda68085443 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:20:45 +0000 Subject: [PATCH 054/190] feat(modelling): Score carries the scored SapResult for billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score gains sap_result: Optional[SapResult], populated by PackageScorer with the calculator output its headline figures came from. This lets the Modelling stage price the post-package (and baseline) end-state via Bill Derivation reusing a SapResult already computed by the optimiser's re-score / the orchestrator's baseline score — no second calculate (ADR-0014 amendment). The optimiser reads only sap_continuous, so it stays domain-agnostic and the stub scorers (which omit sap_result) are unaffected — all optimiser tests pass unchanged. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/scoring/package_scorer.py | 12 ++++++++++-- tests/domain/modelling/test_package_scorer.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/domain/modelling/scoring/package_scorer.py b/domain/modelling/scoring/package_scorer.py index d9c88cf6..23010572 100644 --- a/domain/modelling/scoring/package_scorer.py +++ b/domain/modelling/scoring/package_scorer.py @@ -8,7 +8,7 @@ user-assembled plan. """ from dataclasses import dataclass -from typing import Sequence +from typing import Optional, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.scoring.overlay_applicator import apply_simulations @@ -20,11 +20,18 @@ from domain.sap10_calculator.calculator import SapCalculator, SapResult 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.""" + 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: @@ -44,4 +51,5 @@ class PackageScorer: 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/tests/domain/modelling/test_package_scorer.py b/tests/domain/modelling/test_package_scorer.py index 9310e0e6..e0575ea6 100644 --- a/tests/domain/modelling/test_package_scorer.py +++ b/tests/domain/modelling/test_package_scorer.py @@ -52,3 +52,20 @@ def test_empty_package_scores_the_unmodified_baseline() -> None: 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 + ) From 26de28aae88ebf5c337094f5511b73198b4e0e25 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:23:20 +0000 Subject: [PATCH 055/190] feat(modelling): Plan carries baseline/post Bills and derives the energy figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan gains optional baseline_bill / post_bill (the Bills derived for the unmodified and post-package end-states at one Fuel Rates snapshot) and derives the four plan-level columns: post_energy_bill (post total), energy_bill_savings (baseline - post), post_energy_consumption (Σ post section kWh), and energy_consumption_savings (baseline - post delivered kWh). All return None until billing runs (persisted as NULL), so existing Plan construction and the not-yet-wired orchestrator stay green. Plan-level only; per-measure savings are a later slice (ADR-0014 amendment). Co-Authored-By: Claude Opus 4.8 --- domain/modelling/plan.py | 45 +++++++++++++++++++++++++- tests/domain/modelling/test_plan.py | 49 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 86063ebd..76a98ad2 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -11,13 +11,21 @@ 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.scoring.package_scorer import Score from domain.modelling.recommendation import Cost from domain.modelling.scoring.scoring import MeasureImpact +def _total_consumption_kwh(bill: Bill) -> float: + """A Bill's total delivered energy (kWh) — the sum of its section kWh + (standing charges and the SEG credit are £, not energy).""" + return sum((section.kwh for section in bill.sections.values()), 0.0) + + @dataclass(frozen=True) class PlanMeasure: """One selected Measure Option as it lands in a Plan: the measure, its @@ -33,11 +41,18 @@ class PlanMeasure: 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).""" + 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 @property def cost_of_works(self) -> float: @@ -71,3 +86,31 @@ class Plan: """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 _total_consumption_kwh(self.post_bill) + + @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 _total_consumption_kwh(self.baseline_bill) - _total_consumption_kwh( + self.post_bill + ) diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py index d2e3a68b..678d281f 100644 --- a/tests/domain/modelling/test_plan.py +++ b/tests/domain/modelling/test_plan.py @@ -8,12 +8,24 @@ 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.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=measure_type, @@ -45,3 +57,40 @@ def test_plan_aggregates_cost_and_savings_and_bands_the_post_sap() -> None: 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 From 198122d1454ad8f2952661d0d79d154c36909165 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:30:47 +0000 Subject: [PATCH 056/190] feat(modelling): derive + persist plan-level post-retrofit bills (#1152 follow-up) ModellingOrchestrator gains a constructor-injected FuelRatesRepository (mirrors Baseline): run() resolves get_current() once and reuses one BillDerivation across the batch. _plan_for prices the baseline and post-package end-states from the SapResults already on their Scores (no extra calculate) and passes the Bills to Plan. PlanRow mirror + from_domain gain the four live columns post_energy_bill / energy_bill_savings / post_energy_consumption / energy_consumption_savings. Pipeline/handler wire the fuel-rates repo. Integration tests assert the columns persist: the multi-measure (fallback) plan shows positive bill+consumption savings; the already-at-target zero-measure plan shows the current bill with exactly zero savings. Fuel-switch measures price at the new fuel for free (we bill the simulated end-state). 183 modelling/billing/orchestration/repo tests pass, pyright strict clean. Plan-level only; per-measure savings next. Co-Authored-By: Claude Opus 4.8 --- applications/ara_first_run/handler.py | 1 + infrastructure/postgres/plan_table.py | 8 +++++ orchestration/modelling_orchestrator.py | 35 ++++++++++++++++--- ...test_ara_first_run_pipeline_integration.py | 25 +++++++++++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index 837730b6..8f4f9afa 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -90,6 +90,7 @@ def build_first_run_pipeline( modelling=ModellingOrchestrator( unit_of_work=unit_of_work, calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), ), ) diff --git a/infrastructure/postgres/plan_table.py b/infrastructure/postgres/plan_table.py index 0b7f670a..da43f506 100644 --- a/infrastructure/postgres/plan_table.py +++ b/infrastructure/postgres/plan_table.py @@ -41,6 +41,10 @@ class PlanRow(SQLModel, table=True): co2_savings: Optional[float] = Field(default=None) # tonnes/yr cost_of_works: Optional[float] = Field(default=None) contingency_cost: Optional[float] = Field(default=None) + 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) # delivered kWh/yr + energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr @classmethod def from_domain( @@ -63,6 +67,10 @@ class PlanRow(SQLModel, table=True): 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, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 64617607..48395c6a 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -5,6 +5,8 @@ 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.generators.floor_recommendation import recommend_floor_insulation from domain.modelling.optimisation.measure_dependency import ventilation_dependency from domain.modelling.optimisation.optimiser import ( @@ -25,6 +27,7 @@ from domain.modelling.scoring.scoring import ( ) from domain.modelling.generators.wall_recommendation import recommend_cavity_wall 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.unit_of_work import UnitOfWork @@ -73,14 +76,19 @@ class ModellingOrchestrator: *, unit_of_work: Callable[[], UnitOfWork], calculator: SapCalculator, + fuel_rates: FuelRatesRepository, ) -> None: 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], portfolio_id: int ) -> None: 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) @@ -88,7 +96,7 @@ class ModellingOrchestrator: effective_epc: EpcPropertyData = prop.effective_epc for scenario in scenarios: plan = self._plan_for( - scorer, effective_epc, uow.product, scenario + scorer, bill_derivation, effective_epc, uow.product, scenario ) uow.plan.save( plan, @@ -102,12 +110,13 @@ class ModellingOrchestrator: def _plan_for( self, scorer: PackageScorer, + bill_derivation: BillDerivation, effective_epc: EpcPropertyData, products: ProductRepository, scenario: Scenario, ) -> Plan: - """Generate → score → optimise → re-score/repair → attribute → assemble - the Plan for one Property + Scenario.""" + """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 ) @@ -138,11 +147,29 @@ class ModellingOrchestrator: _plan_measure(option, impact) for option, impact in zip(ordered, impacts, strict=True) ) + # Price the unmodified and post-package end-states at the same Fuel + # Rates, reusing SapResults already scored — no extra calculate. return Plan( - measures=measures, baseline=baseline, post_retrofit=package.score + measures=measures, + baseline=baseline, + post_retrofit=package.score, + baseline_bill=_bill_for(bill_derivation, baseline), + post_bill=_bill_for(bill_derivation, package.score), ) +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 _candidate_recommendations( effective_epc: EpcPropertyData, products: ProductRepository ) -> list[Recommendation]: diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index cca8473a..bb96e332 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -158,6 +158,7 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( modelling=ModellingOrchestrator( unit_of_work=unit_of_work, calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), ), ) command = _FakeCommand(portfolio_id=1, property_ids=[10], scenario_ids=[7]) @@ -258,7 +259,9 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( # Act ModellingOrchestrator( - unit_of_work=unit_of_work, calculator=Sap10Calculator() + 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 @@ -282,6 +285,15 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( 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) == { @@ -377,7 +389,9 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( # Act ModellingOrchestrator( - unit_of_work=unit_of_work, calculator=Sap10Calculator() + 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 @@ -396,3 +410,10 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( 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 From d36e42b582ac7357fe452a9c4f5077ad3bd8eb64 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:31:48 +0000 Subject: [PATCH 057/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20plan-level=20Bill-Derivation=20landed;=20per-measure=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index a58f56c3..c0faba36 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `641c1bd7`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `198122d1`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.** ## Issue status @@ -90,8 +90,22 @@ A `/grill-with-docs` pass found the rebuild had the **wrong optimiser objective* 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). + ## What's left +**Per-measure bill savings (next slice — designed, not built):** fill `recommendation.kwh_savings` + `energy_cost_savings` via a **telescoping bill cascade** over the role-3 best-practice order (fabric → heating → renewables): re-bill each cumulative prefix (reusing the per-prefix `sap_result`s from the role-3 cascade — no extra calls) and diff, telescoping exactly to the plan totals. Per-measure savings can be **negative** (ventilation increases energy) and still telescope. `recommendation.energy_savings` is **vestigial** (legacy = 0) — leave NULL. Note: `MeasureImpact.energy_savings_kwh_per_yr` is *primary* energy, not delivered — it does **not** feed `kwh_savings`. + + **Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); 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 From e79ffabfc50fca68c7cce5c181c64fcee4242c4d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:54:54 +0000 Subject: [PATCH 058/190] refactor(modelling): expose cascade_scores for the role-3 + bill cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the cumulative-prefix scoring out of `marginal_impacts` into a reusable `cascade_scores(scorer, baseline, overlays) -> list[Score]` (index 0 the baseline, one calculator run per prefix) plus a pure `marginals_from_scores`. Each Score carries its SapResult, so the next slice's telescoping per-measure bill cascade can re-bill the same prefixes the role-3 attribution already scores — no extra `calculate` calls (ADR-0014 / ADR-0016). `marginal_impacts` now delegates; behaviour unchanged. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/scoring/scoring.py | 40 +++++++++++++++++++----- tests/domain/modelling/test_scoring.py | 42 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index 19fc2016..ea995380 100644 --- a/domain/modelling/scoring/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -34,17 +34,32 @@ class MeasureImpact: energy_savings_kwh_per_yr: float -def marginal_impacts( +def cascade_scores( 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.""" +) -> 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] = [] - previous: Score = scorer.score(baseline, []) - for index in range(len(overlays)): - current: Score = scorer.score(baseline, list(overlays[: index + 1])) + 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, @@ -55,10 +70,19 @@ def marginal_impacts( ), ) ) - previous = current 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, diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py index 97169667..ab55706a 100644 --- a/tests/domain/modelling/test_scoring.py +++ b/tests/domain/modelling/test_scoring.py @@ -14,8 +14,10 @@ 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 @@ -113,6 +115,46 @@ def test_single_overlay_marginal_is_its_improvement_over_baseline() -> None: ) +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() From 7e79c30af123cee54286e32ef28a4e1dec971053 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:58:06 +0000 Subject: [PATCH 059/190] feat(modelling): Plan Measure carries per-measure kwh/cost savings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlanMeasure` grows optional `kwh_savings` (delivered energy) and `energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed so positive is a saving and `None` until billing runs. `RecommendationRow` declares the matching live `recommendation.kwh_savings` / `energy_cost_savings` columns and maps them in `from_domain` (None → NULL). The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0). No FE migration — the columns already exist on the live table (ADR-0014 / 0017). Co-Authored-By: Claude Opus 4.8 --- domain/modelling/plan.py | 11 ++++- infrastructure/postgres/plan_table.py | 4 ++ .../plan/test_plan_postgres_repository.py | 45 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 76a98ad2..50db6ddf 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -29,12 +29,21 @@ def _total_consumption_kwh(bill: Bill) -> float: @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.""" + 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: str description: str cost: Cost impact: MeasureImpact + kwh_savings: Optional[float] = None + energy_cost_savings: Optional[float] = None @dataclass(frozen=True) diff --git a/infrastructure/postgres/plan_table.py b/infrastructure/postgres/plan_table.py index da43f506..b76c32d1 100644 --- a/infrastructure/postgres/plan_table.py +++ b/infrastructure/postgres/plan_table.py @@ -103,6 +103,8 @@ class RecommendationRow(SQLModel, table=True): estimated_cost: Optional[float] = Field(default=None) sap_points: Optional[float] = Field(default=None) co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr + kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr + energy_cost_savings: Optional[float] = Field(default=None) # £/yr default: bool = True already_installed: bool = False @@ -121,6 +123,8 @@ class RecommendationRow(SQLModel, table=True): 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, ) diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index 975a7e38..050c9b55 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -33,6 +33,8 @@ def _plan() -> Plan: 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( @@ -97,10 +99,53 @@ def test_save_persists_plan_and_its_measures_with_tonnes_and_band( 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="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(RecommendationRow).where(col(RecommendationRow.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: From b976c3abd298af53b20398bb8328fae15b5ceffb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 18:01:11 +0000 Subject: [PATCH 060/190] feat(modelling): attribute per-measure bill savings via a telescoping cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_plan_for` now scores the baseline + every cumulative prefix once (`cascade_scores`, best-practice order) and reuses those Scores for both the role-3 marginal attribution and a per-measure bill cascade: bill each prefix at one Fuel Rates snapshot and take consecutive Bill deltas as each measure's marginal delivered-kWh and £ saving. Saving is signed (ventilation is negative) and telescopes exactly to the Plan headline savings, because the Plan's baseline/post Bills are now the same cascade endpoints (`bills[0]` / `bills[-1]`) — which also drops the redundant standalone baseline `calculate`. `recommendation.kwh_savings` / `energy_cost_savings` are filled from these. Adds `Bill.total_consumption_kwh` (shared by Plan + the orchestrator). Pinned end-to-end on the real calculator: Σ per-measure savings == the Plan totals (ADR-0014 amendment). Co-Authored-By: Claude Opus 4.8 --- domain/billing/bill.py | 6 +++ domain/modelling/plan.py | 13 ++----- orchestration/modelling_orchestrator.py | 39 +++++++++++++------ ...test_ara_first_run_pipeline_integration.py | 15 +++++++ 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/domain/billing/bill.py b/domain/billing/bill.py index 0e06cf27..5aff24cf 100644 --- a/domain/billing/bill.py +++ b/domain/billing/bill.py @@ -127,3 +127,9 @@ class Bill: 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/modelling/plan.py b/domain/modelling/plan.py index 50db6ddf..cfaaf9ff 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -20,12 +20,6 @@ from domain.modelling.recommendation import Cost from domain.modelling.scoring.scoring import MeasureImpact -def _total_consumption_kwh(bill: Bill) -> float: - """A Bill's total delivered energy (kWh) — the sum of its section kWh - (standing charges and the SEG credit are £, not energy).""" - return sum((section.kwh for section in bill.sections.values()), 0.0) - - @dataclass(frozen=True) class PlanMeasure: """One selected Measure Option as it lands in a Plan: the measure, its @@ -112,7 +106,7 @@ class Plan: @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 _total_consumption_kwh(self.post_bill) + return None if self.post_bill is None else self.post_bill.total_consumption_kwh @property def energy_consumption_savings(self) -> Optional[float]: @@ -120,6 +114,7 @@ class Plan: both bills were derived.""" if self.baseline_bill is None or self.post_bill is None: return None - return _total_consumption_kwh(self.baseline_bill) - _total_consumption_kwh( - self.post_bill + return ( + self.baseline_bill.total_consumption_kwh + - self.post_bill.total_consumption_kwh ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 48395c6a..7fc9b491 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -22,8 +22,9 @@ from domain.modelling.generators.roof_recommendation import recommend_loft_insul from domain.modelling.scenario import Scenario from domain.modelling.scoring.scoring import ( MeasureImpact, + cascade_scores, independent_option_impacts, - marginal_impacts, + marginals_from_scores, ) from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from domain.sap10_calculator.calculator import SapCalculator @@ -139,22 +140,31 @@ class ModellingOrchestrator: ordered: list[MeasureOption] = sorted( (scored.option for scored in package.selected), key=_best_practice_key ) - impacts: list[MeasureImpact] = marginal_impacts( + # 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] ) - baseline: Score = scorer.score(effective_epc, []) + 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) - for option, impact in zip(ordered, impacts, strict=True) + _plan_measure(option, impact, before, after) + for option, impact, before, after in zip( + ordered, impacts, bills[:-1], bills[1:], strict=True + ) ) - # Price the unmodified and post-package end-states at the same Fuel - # Rates, reusing SapResults already scored — no extra calculate. return Plan( measures=measures, - baseline=baseline, + baseline=cascade[0], post_retrofit=package.score, - baseline_bill=_bill_for(bill_derivation, baseline), - post_bill=_bill_for(bill_derivation, package.score), + baseline_bill=bills[0], + post_bill=bills[-1], ) @@ -232,7 +242,12 @@ def _best_practice_key(option: MeasureOption) -> int: return len(_BEST_PRACTICE_ORDER) -def _plan_measure(option: MeasureOption, impact: MeasureImpact) -> PlanMeasure: +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" @@ -242,4 +257,6 @@ def _plan_measure(option: MeasureOption, impact: MeasureImpact) -> PlanMeasure: 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, ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index bb96e332..5cbc4fbb 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -318,6 +318,21 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( 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( From ae5bbd06461da196670ca18be7a014e7cee21d8e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 18:02:18 +0000 Subject: [PATCH 061/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20per-measure=20bill=20savings=20landed=20(telescoping=20casca?= =?UTF-8?q?de)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index c0faba36..f82af3eb 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `198122d1`. +**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `b976c3ab`. **PRD:** GitHub `Hestia-Homes/Model#1152`, sliced into #1153–#1161. **All slices #1153–#1161 closed.** ## Issue status @@ -101,12 +101,19 @@ A `/grill-with-docs` pass designed the Modelling Bill-Derivation slice (ADR-0014 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. + ## What's left -**Per-measure bill savings (next slice — designed, not built):** fill `recommendation.kwh_savings` + `energy_cost_savings` via a **telescoping bill cascade** over the role-3 best-practice order (fabric → heating → renewables): re-bill each cumulative prefix (reusing the per-prefix `sap_result`s from the role-3 cascade — no extra calls) and diff, telescoping exactly to the plan totals. Per-measure savings can be **negative** (ventilation increases energy) and still telescope. `recommendation.energy_savings` is **vestigial** (legacy = 0) — leave NULL. Note: `MeasureImpact.energy_savings_kwh_per_yr` is *primary* energy, not delivered — it does **not** feed `kwh_savings`. - - -**Deferred fronts** (open, post-#1161): exclusion-filtering of the candidate pool (deferred from #1160); a **Bill-Derivation slice** that re-runs bills on the post-package EPC to fill the deferred energy/bill columns (`plan.post_energy_consumption`/`post_energy_bill`, `recommendation.kwh_savings`/`energy_cost_savings`); 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. +**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 From b76d0f814b870b4c13386c6e9fb1017b15ae4858 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 20:24:30 +0000 Subject: [PATCH 062/190] docs(modelling): design the plan_recommendations retirement (ADR-0017 amendment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the migration spec into the full expand/contract sequence (add plan_id → backfill → dual-write → cut reads → drop) with the two load-bearing rules: backfill before any read cuts over, and dual-write the m2m until all reads are off it (the Drizzle FE reads the tables directly, so the repos can't deploy atomically). Amend ADR-0017 from "m2m retired for new writes" to "m2m dropped + one SQLModel definition per table under infrastructure/postgres/modelling/". Co-Authored-By: Claude Opus 4.8 --- ...017-plan-persistence-evolve-live-tables.md | 8 +++ docs/migrations/recommendation-plan-id.md | 62 ++++++++++++++----- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/docs/adr/0017-plan-persistence-evolve-live-tables.md b/docs/adr/0017-plan-persistence-evolve-live-tables.md index b3e51c1a..44fd6ef8 100644 --- a/docs/adr/0017-plan-persistence-evolve-live-tables.md +++ b/docs/adr/0017-plan-persistence-evolve-live-tables.md @@ -24,6 +24,14 @@ The rebuild's persistence convention is SQLModel `table=True` rows in `infrastru - **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. diff --git a/docs/migrations/recommendation-plan-id.md b/docs/migrations/recommendation-plan-id.md index a9a42bd2..887f788f 100644 --- a/docs/migrations/recommendation-plan-id.md +++ b/docs/migrations/recommendation-plan-id.md @@ -1,28 +1,62 @@ -# `recommendation.plan_id` — FE-owned migration +# Retire `plan_recommendations` — link measures by `recommendation.plan_id` -**Context:** #1157 of the Modelling-stage rebuild. The `ModellingOrchestrator` persists a **Plan** and its selected **Plan Measures** (rows of the live `recommendation` table). To link a measure to its Plan it adds **`recommendation.plan_id`**, replacing the `plan_recommendations` many-to-many join for new writes (the m2m's cascade delete is pathologically slow — see [ADR-0017](../adr/0017-plan-persistence-evolve-live-tables.md)). +**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 SQLModel mirror is defined in `infrastructure/postgres/` so the ephemeral-Postgres tests build it via `SQLModel.metadata.create_all`. The **production migration is FE-owned (Drizzle ORM)**. +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. -## Change +## Cardinality -Add one column to the existing `recommendation` table: +`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 | the Plan this measure belongs to. Nullable during transition (legacy rows predate it); new writes always set it. | +| `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 orchestrator'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`** is what makes "delete the Plan → its measures go too" a single statement, replacing the m2m cleanup. +- **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. -## Transition / sequencing +## This repo's part (all of steps 3–6, gated on 1+2 being live) -1. **Add `plan_id` (nullable)** — this migration. New `ModellingOrchestrator` writes populate it; legacy writers and existing rows are unaffected. -2. **Cut legacy readers** off `plan_recommendations` onto `plan_id` (separate work, not in #1157). -3. **Drop `plan_recommendations`** once no reader remains (separate migration). +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: -Existing live `recommendation` rows keep `plan_id = NULL` until/unless re-modelled; they remain reachable via the legacy `plan_recommendations` join during the transition. +- 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 columns for contingency (per-measure contingency stays summed into `plan.contingency_cost`, matching legacy), no `phase` column (multi-phase deferred, ADR-0005), and the energy/bill columns are populated by a later Bill Derivation slice (ADR-0017). +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. From c1c7b06f09dd98f4d27beb923b8df3841edfd45e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:00:14 +0000 Subject: [PATCH 063/190] refactor(modelling): consolidate plan/recommendation models into infrastructure Move the live plan, recommendation, recommendation_materials and (retiring) plan_recommendations tables into a new infrastructure/postgres/modelling/ subpackage as single SQLModel definitions (the epc_property pattern), absorbing the rebuild's partial PlanRow/RecommendationRow mirrors and carrying full legacy column parity plus recommendation.plan_id. Out-of-cluster references are plain indexed ints (mirror convention); the live FKs are owned by the Drizzle schema. backend/app/db/models/recommendations.py becomes a re-export shim (ScenarioModel/InstalledMeasure stay for a later slice). Fix the export conftest to create SQLModel-first (so Base funding_package's FK to the now-SQLModel plan resolves) and skip the redundant drop_all on its function-scoped throwaway DB (the epc enum type is now shared across both metadatas). Resolves the pre-existing dual-definition collision: the rebuild and legacy export suites are now co-runnable. No behaviour change. Co-Authored-By: Claude Opus 4.8 --- backend/app/db/models/recommendations.py | 178 +++--------------- backend/export/tests/conftest.py | 16 +- infrastructure/postgres/modelling/__init__.py | 24 +++ .../postgres/modelling/plan_table.py | 106 +++++++++++ .../modelling/recommendation_table.py | 139 ++++++++++++++ infrastructure/postgres/plan_table.py | 130 ------------- repositories/plan/plan_postgres_repository.py | 2 +- ...test_ara_first_run_pipeline_integration.py | 2 +- .../plan/test_plan_postgres_repository.py | 2 +- 9 files changed, 312 insertions(+), 287 deletions(-) create mode 100644 infrastructure/postgres/modelling/__init__.py create mode 100644 infrastructure/postgres/modelling/plan_table.py create mode 100644 infrastructure/postgres/modelling/recommendation_table.py delete mode 100644 infrastructure/postgres/plan_table.py diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 096cc1de..653e7051 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -1,13 +1,23 @@ +"""Re-export shim + remaining legacy models (ADR-0017 amendment). + +`plan`, `recommendation`, `recommendation_materials` and the retiring +`plan_recommendations` 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. `ScenarioModel` and +`InstalledMeasure` are not yet migrated and stay here for now. +""" + import enum from typing import Iterable, List, NamedTuple, Optional, Type from sqlalchemy import ( - Column, BigInteger, String, Float, Boolean, TIMESTAMP, ForeignKey, + Column, Enum, ) from sqlalchemy.orm import Mapped, mapped_column @@ -16,158 +26,28 @@ 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 + +from infrastructure.postgres.modelling import ( + PlanRow, + PlanType, + PlanRecommendationRow, + RecommendationMaterialRow, + RecommendationRow, +) + +# Legacy names → the single SQLModel definitions now in +# `infrastructure/postgres/modelling/`. +Recommendation = RecommendationRow +RecommendationMaterials = RecommendationMaterialRow +PlanModel = PlanRow +PlanRecommendations = PlanRecommendationRow +PlanTypeEnum = PlanType 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" @@ -282,10 +162,10 @@ class InstalledMeasure(Base): is_active = Column(Boolean, nullable=False, default=True) -def enum_values(e: Iterable[PlanTypeEnum]) -> list[str]: +def enum_values(e: Iterable[PlanType]) -> list[str]: return [m.value for m in e] class PlanPersistence(NamedTuple): - plan: PlanModel + plan: PlanRow scenario: ScenarioModel 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/infrastructure/postgres/modelling/__init__.py b/infrastructure/postgres/modelling/__init__.py new file mode 100644 index 00000000..c1c8cb8c --- /dev/null +++ b/infrastructure/postgres/modelling/__init__.py @@ -0,0 +1,24 @@ +"""SQLModel definitions of the Modelling stage's live persistence tables +(ADR-0017 amendment). + +One canonical SQLModel per physical table — `plan`, `recommendation`, +`recommendation_materials` — 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 PlanRow, PlanType +from infrastructure.postgres.modelling.recommendation_table import ( + PlanRecommendationRow, + RecommendationMaterialRow, + RecommendationRow, +) + +__all__ = [ + "PlanRow", + "PlanType", + "RecommendationRow", + "RecommendationMaterialRow", + "PlanRecommendationRow", +] diff --git a/infrastructure/postgres/modelling/plan_table.py b/infrastructure/postgres/modelling/plan_table.py new file mode 100644 index 00000000..7cbed104 --- /dev/null +++ b/infrastructure/postgres/modelling/plan_table.py @@ -0,0 +1,106 @@ +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 PlanRow(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, + ) -> "PlanRow": + 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, + ) diff --git a/infrastructure/postgres/modelling/recommendation_table.py b/infrastructure/postgres/modelling/recommendation_table.py new file mode 100644 index 00000000..c50a2947 --- /dev/null +++ b/infrastructure/postgres/modelling/recommendation_table.py @@ -0,0 +1,139 @@ +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 RecommendationRow(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 + 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 + ) -> "RecommendationRow": + return cls( + property_id=property_id, + plan_id=plan_id, + type=measure.measure_type, + measure_type=measure.measure_type, + description=measure.description, + 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 RecommendationMaterialRow(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) + + +class PlanRecommendationRow(SQLModel, table=True): + """The legacy ``plan_recommendations`` m2m — **being retired** (ADR-0017 + amendment). Kept as an intra-cluster SQLModel row only for the transition + window while readers/writers move onto ``recommendation.plan_id``; dropped + once no caller remains. Both FKs are intra-cluster.""" + + __tablename__: ClassVar[str] = "plan_recommendations" # pyright: ignore[reportIncompatibleVariableOverride] + + id: Optional[int] = Field(default=None, primary_key=True) + plan_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("plan.id"), nullable=False) + ) + recommendation_id: int = Field( + sa_column=Column( + BigInteger, ForeignKey("recommendation.id"), nullable=False + ) + ) diff --git a/infrastructure/postgres/plan_table.py b/infrastructure/postgres/plan_table.py deleted file mode 100644 index b76c32d1..00000000 --- a/infrastructure/postgres/plan_table.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar, Optional - -from sqlalchemy import BigInteger, Column, ForeignKey -from sqlalchemy import Enum as SAEnum -from sqlmodel import Field, SQLModel - -from datatypes.epc.domain.epc import Epc -from domain.modelling.plan import Plan, PlanMeasure - -# Calculator metrics are in kg CO₂/yr; the live `plan` / `recommendation` -# columns are tonnes (legacy `emissions_kg / 1000`). Convert on the way in. -_KG_PER_TONNE = 1000.0 - - -class PlanRow(SQLModel, table=True): - """SQLModel mirror of the live ``plan`` table (ADR-0017). - - Declares only the columns the rebuild writes — identity, the flat - post-retrofit headline figures, and the cost aggregates. The legacy - SQLAlchemy model owns the live reads and the columns left for later - slices (valuation, plan_type, the energy/bill cluster). The physical - table is the shared contract. - """ - - __tablename__: ClassVar[str] = "plan" # pyright: ignore[reportIncompatibleVariableOverride] - - id: Optional[int] = Field(default=None, primary_key=True) - portfolio_id: int - property_id: int = Field(index=True) - scenario_id: Optional[int] = Field(default=None) - is_default: bool = False - - 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 - cost_of_works: Optional[float] = Field(default=None) - contingency_cost: Optional[float] = Field(default=None) - 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) # delivered kWh/yr - energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr - - @classmethod - def from_domain( - cls, - plan: Plan, - *, - property_id: int, - scenario_id: int, - portfolio_id: int, - is_default: bool, - ) -> "PlanRow": - 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, - ) - - -class RecommendationRow(SQLModel, table=True): - """SQLModel mirror of the live ``recommendation`` table — one row per - persisted Plan Measure (ADR-0017). Adds the new ``plan_id`` FK linking the - measure to its Plan (ON DELETE CASCADE), replacing the ``plan_recommendations`` - m2m for new writes. Only the impact + cost columns the tracer fills are - declared; the energy/bill, U-value, valuation and labour columns are left - to later slices. - """ - - __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, - ), - ) - - type: str - measure_type: Optional[str] = Field(default=None) - description: str - estimated_cost: Optional[float] = Field(default=None) - sap_points: Optional[float] = Field(default=None) - co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr - kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr - energy_cost_savings: Optional[float] = Field(default=None) # £/yr - default: bool = True - already_installed: bool = False - - @classmethod - def from_domain( - cls, measure: PlanMeasure, *, property_id: int, plan_id: int - ) -> "RecommendationRow": - return cls( - property_id=property_id, - plan_id=plan_id, - type=measure.measure_type, - measure_type=measure.measure_type, - description=measure.description, - 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, - ) diff --git a/repositories/plan/plan_postgres_repository.py b/repositories/plan/plan_postgres_repository.py index 401ec087..75e9096c 100644 --- a/repositories/plan/plan_postgres_repository.py +++ b/repositories/plan/plan_postgres_repository.py @@ -3,7 +3,7 @@ from __future__ import annotations from sqlmodel import Session, col, delete from domain.modelling.plan import Plan -from infrastructure.postgres.plan_table import PlanRow, RecommendationRow +from infrastructure.postgres.modelling import PlanRow, RecommendationRow from repositories.plan.plan_repository import PlanRepository diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 5cbc4fbb..70f2087c 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -26,7 +26,7 @@ from infrastructure.postgres.property_baseline_performance_table import ( PropertyBaselinePerformanceModel, ) from infrastructure.postgres.epc_property_table import EpcPropertyModel -from infrastructure.postgres.plan_table import PlanRow, RecommendationRow +from infrastructure.postgres.modelling import PlanRow, RecommendationRow from infrastructure.postgres.product_table import MaterialRow from infrastructure.postgres.property_table import PropertyRow from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index 050c9b55..94033b00 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -18,7 +18,7 @@ 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.plan_table import PlanRow, RecommendationRow +from infrastructure.postgres.modelling import PlanRow, RecommendationRow from repositories.plan.plan_postgres_repository import PlanPostgresRepository From 27fcc5b18465f3f92fa36f17ea3688bb3917a753 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:01:56 +0000 Subject: [PATCH 064/190] feat(modelling): legacy writers set recommendation.plan_id (dual-write) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upload_recommendations and bulk_upload_recommendations_and_materials now set plan_id on each recommendation row (the plan id is already in scope), while still writing the plan_recommendations m2m — the dual-write that lets readers move onto plan_id with no breakage during the transition (ADR-0017 amendment / docs/migrations/recommendation-plan-id.md). The m2m write is removed in a later slice once no reader depends on it. Co-Authored-By: Claude Opus 4.8 --- backend/app/db/functions/recommendations_functions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index ed3fb435..72affd2a 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -261,6 +261,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"], @@ -353,6 +354,7 @@ def bulk_upload_recommendations_and_materials( recommendation_rows.append( { "property_id": rec["property_id"], + "plan_id": rec["plan_id"], "type": rec["type"], "measure_type": rec["measure_type"], "description": rec["description"], From af5dbe325d3843f72fd3063936342c8a0fb64917 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:09:43 +0000 Subject: [PATCH 065/190] =?UTF-8?q?feat(modelling):=20cut=20plan=E2=86=92r?= =?UTF-8?q?ecommendation=20readers=20onto=20plan=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the three structurally-identical m2m-join readers (portfolio_functions.aggregate_portfolio_recommendations, Outputs.get_recommendations_from_db, export get_recommendations) to join PlanModel directly via recommendation.plan_id, dropping the plan_recommendations join and its now-unused import. The writers set plan_id (prior slice), so the rows resolve. test_export pins the export reader through the cut (its fixtures now set recommendation.plan_id). A portfolio_functions DB characterization test lands with the scenario consolidation (which provides the full-parity scenario table the aggregation writes to). Co-Authored-By: Claude Opus 4.8 --- backend/Outputs.py | 12 +++--------- backend/app/db/functions/portfolio_functions.py | 7 +------ backend/export/property_scenarios/db_functions.py | 9 ++------- backend/export/tests/test_export.py | 10 ++++++++-- 4 files changed, 14 insertions(+), 24 deletions(-) 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/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/test_export.py b/backend/export/tests/test_export.py index 42177749..973364fd 100644 --- a/backend/export/tests/test_export.py +++ b/backend/export/tests/test_export.py @@ -171,13 +171,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() ] @@ -607,9 +611,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, From b97d06882ff96d88119fe1455aeb37ad27083d3a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:13:00 +0000 Subject: [PATCH 066/190] feat(modelling): drop the plan_recommendations m2m Stop writing the m2m (remove create_plan_recommendations + its call, the bulk link insert and the now-dead plan_ids_by_index, and the plan_recommendations delete in delete_property_batch) and remove the PlanRecommendationRow model + its shim alias and the test_export fixture inserts. Measures now link to their Plan solely via recommendation.plan_id (writers set it, readers join on it). The live drop of the plan_recommendations table is the FE-owned Drizzle migration documented in docs/migrations/recommendation-plan-id.md, sequenced after the read-cut + backfill. Co-Authored-By: Claude Opus 4.8 --- .../db/functions/recommendations_functions.py | 53 +------------------ backend/app/db/models/recommendations.py | 6 +-- backend/export/tests/test_export.py | 29 ++-------- infrastructure/postgres/modelling/__init__.py | 2 - .../modelling/recommendation_table.py | 19 ------- 5 files changed, 10 insertions(+), 99 deletions(-) diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index 72affd2a..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 ): @@ -320,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() @@ -348,7 +326,6 @@ def bulk_upload_recommendations_and_materials( # --------------------------------------------------------- recommendation_rows = [] parts_by_index = [] - plan_ids_by_index = [] for rec in recommendation_payload: recommendation_rows.append( @@ -375,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 @@ -407,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): @@ -457,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/models/recommendations.py b/backend/app/db/models/recommendations.py index 653e7051..8fad1ff3 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -30,17 +30,17 @@ from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyMo from infrastructure.postgres.modelling import ( PlanRow, PlanType, - PlanRecommendationRow, RecommendationMaterialRow, RecommendationRow, ) # Legacy names → the single SQLModel definitions now in -# `infrastructure/postgres/modelling/`. +# `infrastructure/postgres/modelling/`. The `plan_recommendations` m2m is +# retired (ADR-0017 amendment) — measures link to their Plan via +# `recommendation.plan_id`. Recommendation = RecommendationRow RecommendationMaterials = RecommendationMaterialRow PlanModel = PlanRow -PlanRecommendations = PlanRecommendationRow PlanTypeEnum = PlanType diff --git a/backend/export/tests/test_export.py b/backend/export/tests/test_export.py index 973364fd..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 @@ -189,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) @@ -628,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/infrastructure/postgres/modelling/__init__.py b/infrastructure/postgres/modelling/__init__.py index c1c8cb8c..6b882b25 100644 --- a/infrastructure/postgres/modelling/__init__.py +++ b/infrastructure/postgres/modelling/__init__.py @@ -10,7 +10,6 @@ One canonical SQLModel per physical table — `plan`, `recommendation`, from infrastructure.postgres.modelling.plan_table import PlanRow, PlanType from infrastructure.postgres.modelling.recommendation_table import ( - PlanRecommendationRow, RecommendationMaterialRow, RecommendationRow, ) @@ -20,5 +19,4 @@ __all__ = [ "PlanType", "RecommendationRow", "RecommendationMaterialRow", - "PlanRecommendationRow", ] diff --git a/infrastructure/postgres/modelling/recommendation_table.py b/infrastructure/postgres/modelling/recommendation_table.py index c50a2947..67b327a3 100644 --- a/infrastructure/postgres/modelling/recommendation_table.py +++ b/infrastructure/postgres/modelling/recommendation_table.py @@ -118,22 +118,3 @@ class RecommendationMaterialRow(SQLModel, table=True): ), ) estimated_cost: Optional[float] = Field(default=None) - - -class PlanRecommendationRow(SQLModel, table=True): - """The legacy ``plan_recommendations`` m2m — **being retired** (ADR-0017 - amendment). Kept as an intra-cluster SQLModel row only for the transition - window while readers/writers move onto ``recommendation.plan_id``; dropped - once no caller remains. Both FKs are intra-cluster.""" - - __tablename__: ClassVar[str] = "plan_recommendations" # pyright: ignore[reportIncompatibleVariableOverride] - - id: Optional[int] = Field(default=None, primary_key=True) - plan_id: int = Field( - sa_column=Column(BigInteger, ForeignKey("plan.id"), nullable=False) - ) - recommendation_id: int = Field( - sa_column=Column( - BigInteger, ForeignKey("recommendation.id"), nullable=False - ) - ) From 01c2c3910ef45e2189c70c390aa8d1bb5f562ddc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:42:21 +0000 Subject: [PATCH 067/190] =?UTF-8?q?refactor(modelling):=20rename=20the=20c?= =?UTF-8?q?luster=20SQLModel=20classes=20=E2=80=A6Row=20=E2=86=92=20?= =?UTF-8?q?=E2=80=A6Model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardise the modelling persistence classes on the …Model suffix (PlanModel, RecommendationModel, RecommendationMaterialModel) — matching the epc_property precedent and the legacy names the rest of backend/ already imports, so the shim's plan re-export becomes literal (no alias) and the eventual shim deletion needs zero renames. The …Row→…Model sweep for the non-cluster tables (Property/Task/Material/…) waits until their live legacy …Model counterparts are retired, to avoid reintroducing dual-definition collisions. No behaviour change. Co-Authored-By: Claude Opus 4.8 --- backend/app/db/models/recommendations.py | 13 ++++++------- infrastructure/postgres/modelling/__init__.py | 12 ++++++------ infrastructure/postgres/modelling/plan_table.py | 4 ++-- .../postgres/modelling/recommendation_table.py | 6 +++--- repositories/plan/plan_postgres_repository.py | 12 ++++++------ .../test_ara_first_run_pipeline_integration.py | 14 +++++++------- .../plan/test_plan_postgres_repository.py | 14 +++++++------- 7 files changed, 37 insertions(+), 38 deletions(-) diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 8fad1ff3..7d03bdba 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -28,19 +28,18 @@ from backend.app.db.base import Base from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel from infrastructure.postgres.modelling import ( - PlanRow, + PlanModel, PlanType, - RecommendationMaterialRow, - RecommendationRow, + RecommendationMaterialModel, + RecommendationModel, ) # Legacy names → the single SQLModel definitions now in # `infrastructure/postgres/modelling/`. The `plan_recommendations` m2m is # retired (ADR-0017 amendment) — measures link to their Plan via # `recommendation.plan_id`. -Recommendation = RecommendationRow -RecommendationMaterials = RecommendationMaterialRow -PlanModel = PlanRow +Recommendation = RecommendationModel +RecommendationMaterials = RecommendationMaterialModel PlanTypeEnum = PlanType @@ -167,5 +166,5 @@ def enum_values(e: Iterable[PlanType]) -> list[str]: class PlanPersistence(NamedTuple): - plan: PlanRow + plan: PlanModel scenario: ScenarioModel diff --git a/infrastructure/postgres/modelling/__init__.py b/infrastructure/postgres/modelling/__init__.py index 6b882b25..8236549b 100644 --- a/infrastructure/postgres/modelling/__init__.py +++ b/infrastructure/postgres/modelling/__init__.py @@ -8,15 +8,15 @@ One canonical SQLModel per physical table — `plan`, `recommendation`, `plan_recommendations` m2m is retired. """ -from infrastructure.postgres.modelling.plan_table import PlanRow, PlanType +from infrastructure.postgres.modelling.plan_table import PlanModel, PlanType from infrastructure.postgres.modelling.recommendation_table import ( - RecommendationMaterialRow, - RecommendationRow, + RecommendationMaterialModel, + RecommendationModel, ) __all__ = [ - "PlanRow", + "PlanModel", "PlanType", - "RecommendationRow", - "RecommendationMaterialRow", + "RecommendationModel", + "RecommendationMaterialModel", ] diff --git a/infrastructure/postgres/modelling/plan_table.py b/infrastructure/postgres/modelling/plan_table.py index 7cbed104..75485c9d 100644 --- a/infrastructure/postgres/modelling/plan_table.py +++ b/infrastructure/postgres/modelling/plan_table.py @@ -25,7 +25,7 @@ class PlanType(enum.Enum): EXTRACTION_ECO = "extraction_eco" -class PlanRow(SQLModel, table=True): +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 @@ -87,7 +87,7 @@ class PlanRow(SQLModel, table=True): scenario_id: int, portfolio_id: int, is_default: bool, - ) -> "PlanRow": + ) -> "PlanModel": return cls( portfolio_id=portfolio_id, property_id=property_id, diff --git a/infrastructure/postgres/modelling/recommendation_table.py b/infrastructure/postgres/modelling/recommendation_table.py index 67b327a3..77af71fc 100644 --- a/infrastructure/postgres/modelling/recommendation_table.py +++ b/infrastructure/postgres/modelling/recommendation_table.py @@ -16,7 +16,7 @@ from domain.modelling.plan import PlanMeasure _KG_PER_TONNE = 1000.0 -class RecommendationRow(SQLModel, table=True): +class RecommendationModel(SQLModel, table=True): """The single SQLModel definition of the live ``recommendation`` table (ADR-0017 amendment) — one row per persisted Plan Measure. @@ -68,7 +68,7 @@ class RecommendationRow(SQLModel, table=True): @classmethod def from_domain( cls, measure: PlanMeasure, *, property_id: int, plan_id: int - ) -> "RecommendationRow": + ) -> "RecommendationModel": return cls( property_id=property_id, plan_id=plan_id, @@ -87,7 +87,7 @@ class RecommendationRow(SQLModel, table=True): ) -class RecommendationMaterialRow(SQLModel, table=True): +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).""" diff --git a/repositories/plan/plan_postgres_repository.py b/repositories/plan/plan_postgres_repository.py index 75e9096c..376cf8b8 100644 --- a/repositories/plan/plan_postgres_repository.py +++ b/repositories/plan/plan_postgres_repository.py @@ -3,7 +3,7 @@ from __future__ import annotations from sqlmodel import Session, col, delete from domain.modelling.plan import Plan -from infrastructure.postgres.modelling import PlanRow, RecommendationRow +from infrastructure.postgres.modelling import PlanModel, RecommendationModel from repositories.plan.plan_repository import PlanRepository @@ -28,13 +28,13 @@ class PlanPostgresRepository(PlanRepository): # 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(PlanRow).where( - col(PlanRow.property_id) == property_id, - col(PlanRow.scenario_id) == scenario_id, + delete(PlanModel).where( + col(PlanModel.property_id) == property_id, + col(PlanModel.scenario_id) == scenario_id, ) ) - plan_row = PlanRow.from_domain( + plan_row = PlanModel.from_domain( plan, property_id=property_id, scenario_id=scenario_id, @@ -48,7 +48,7 @@ class PlanPostgresRepository(PlanRepository): for measure in plan.measures: self._session.add( - RecommendationRow.from_domain( + RecommendationModel.from_domain( measure, property_id=property_id, plan_id=plan_row.id ) ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 70f2087c..b042ca77 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -26,7 +26,7 @@ from infrastructure.postgres.property_baseline_performance_table import ( PropertyBaselinePerformanceModel, ) from infrastructure.postgres.epc_property_table import EpcPropertyModel -from infrastructure.postgres.modelling import PlanRow, RecommendationRow +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 ( @@ -269,12 +269,12 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( # (ADR-0016). Each is priced and attributed, linked by plan_id. with Session(db_engine) as session: plan = session.exec( - select(PlanRow).where(col(PlanRow.property_id) == 30) + select(PlanModel).where(col(PlanModel.property_id) == 30) ).first() assert plan is not None rec_rows = session.exec( - select(RecommendationRow).where( - col(RecommendationRow.plan_id) == plan.id + select(RecommendationModel).where( + col(RecommendationModel.plan_id) == plan.id ) ).all() @@ -413,12 +413,12 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( # post-retrofit figure is the unchanged baseline (still band D). with Session(db_engine) as session: plan = session.exec( - select(PlanRow).where(col(PlanRow.property_id) == 31) + select(PlanModel).where(col(PlanModel.property_id) == 31) ).first() assert plan is not None rec_rows = session.exec( - select(RecommendationRow).where( - col(RecommendationRow.plan_id) == plan.id + select(RecommendationModel).where( + col(RecommendationModel.plan_id) == plan.id ) ).all() diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index 94033b00..8ee0f0f8 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -18,7 +18,7 @@ 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 PlanRow, RecommendationRow +from infrastructure.postgres.modelling import PlanModel, RecommendationModel from repositories.plan.plan_postgres_repository import PlanPostgresRepository @@ -64,10 +64,10 @@ def test_save_persists_plan_and_its_measures_with_tonnes_and_band( # Assert with Session(db_engine) as session: - plan_row = session.get(PlanRow, plan_id) + plan_row = session.get(PlanModel, plan_id) rec_rows = session.exec( - select(RecommendationRow).where( - col(RecommendationRow.plan_id) == plan_id + select(RecommendationModel).where( + col(RecommendationModel.plan_id) == plan_id ) ).all() @@ -139,7 +139,7 @@ def test_save_persists_null_per_measure_savings_when_unbilled( # Assert — the savings columns persist as NULL (ADR-0014 amendment) with Session(db_engine) as session: rec_rows = session.exec( - select(RecommendationRow).where(col(RecommendationRow.plan_id) == plan_id) + select(RecommendationModel).where(col(RecommendationModel.plan_id) == plan_id) ).all() assert len(rec_rows) == 1 assert rec_rows[0].kwh_savings is None @@ -166,10 +166,10 @@ def test_save_is_idempotent_on_rerun_for_the_same_property_and_scenario( # Assert — replaced, not duplicated (cascade removed the old measures) with Session(db_engine) as session: plan_rows = session.exec( - select(PlanRow).where(col(PlanRow.property_id) == 10) + select(PlanModel).where(col(PlanModel.property_id) == 10) ).all() rec_rows = session.exec( - select(RecommendationRow).where(col(RecommendationRow.property_id) == 10) + select(RecommendationModel).where(col(RecommendationModel.property_id) == 10) ).all() assert len(plan_rows) == 1 From 2fbd7147b76c377122adfd6b4eb2495becf8aa63 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:44:48 +0000 Subject: [PATCH 068/190] refactor(modelling): move PortfolioGoal to domain/modelling/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PortfolioGoal is domain vocabulary (a Scenario's goal — legacy planning branches on PortfolioGoal.INCREASING_EPC), so it belongs in domain/ co-located with scenario.py, mirroring how domain/epc/wall_type.py holds an enum that infrastructure/ imports. This lets the consolidated ScenarioModel (next slice) source the goal enum from domain without an infra→backend dependency. portfolio.py keeps a re-export so every existing `from ...portfolio import PortfolioGoal` caller is unaffected. Co-Authored-By: Claude Opus 4.8 --- backend/app/db/models/portfolio.py | 13 +++++-------- domain/modelling/portfolio_goal.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 domain/modelling/portfolio_goal.py 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/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" From c18968ba3c012c0844d740a1de108b942cf0f183 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:52:35 +0000 Subject: [PATCH 069/190] refactor(modelling): consolidate scenario + installed_measure into the subpackage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the scenario and installed_measure tables into infrastructure/postgres/modelling/ as full-parity SQLModel definitions (ScenarioModel, InstalledMeasureModel + MeasureType), completing the cluster consolidation. backend/app/db/models/recommendations.py is now a pure re-export shim. ScenarioModel.goal is the PortfolioGoal enum (legacy planning branches on it), sourced from domain/modelling/portfolio_goal.py; the repo's to_domain maps it to its value string, so domain Scenario.goal is now the value ("Increasing EPC") consistent with the orchestrator's check — fixing the latent name-vs-value inconsistency the old str column masked (the scenario repo test stored the enum *name*). Parity columns are nullable (mirror convention; live NOT-NULLs owned by Drizzle). Co-Authored-By: Claude Opus 4.8 --- backend/app/db/models/recommendations.py | 171 +++--------------- infrastructure/postgres/modelling/__init__.py | 16 +- .../modelling/installed_measure_table.py | 73 ++++++++ .../postgres/modelling/scenario_table.py | 92 ++++++++++ infrastructure/postgres/scenario_table.py | 41 ----- .../scenario/scenario_postgres_repository.py | 8 +- ...test_ara_first_run_pipeline_integration.py | 15 +- .../test_scenario_postgres_repository.py | 24 ++- 8 files changed, 225 insertions(+), 215 deletions(-) create mode 100644 infrastructure/postgres/modelling/installed_measure_table.py create mode 100644 infrastructure/postgres/modelling/scenario_table.py delete mode 100644 infrastructure/postgres/scenario_table.py diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 7d03bdba..608fe346 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -1,168 +1,41 @@ -"""Re-export shim + remaining legacy models (ADR-0017 amendment). +"""Re-export shim (ADR-0017 amendment). -`plan`, `recommendation`, `recommendation_materials` and the retiring -`plan_recommendations` 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. `ScenarioModel` and -`InstalledMeasure` are not yet migrated and stay here for now. +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`. """ -import enum -from typing import Iterable, List, NamedTuple, Optional, Type -from sqlalchemy import ( - BigInteger, - String, - Float, - Boolean, - TIMESTAMP, - ForeignKey, - Column, - Enum, -) -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 typing import NamedTuple from infrastructure.postgres.modelling import ( + InstalledMeasureModel, PlanModel, PlanType, RecommendationMaterialModel, RecommendationModel, + ScenarioModel, ) # Legacy names → the single SQLModel definitions now in -# `infrastructure/postgres/modelling/`. The `plan_recommendations` m2m is -# retired (ADR-0017 amendment) — measures link to their Plan via -# `recommendation.plan_id`. +# `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 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[PlanType]) -> list[str]: - return [m.value for m in e] +__all__ = [ + "PlanModel", + "ScenarioModel", + "Recommendation", + "RecommendationMaterials", + "InstalledMeasure", + "PlanTypeEnum", + "PlanPersistence", +] class PlanPersistence(NamedTuple): diff --git a/infrastructure/postgres/modelling/__init__.py b/infrastructure/postgres/modelling/__init__.py index 8236549b..2e0eda4b 100644 --- a/infrastructure/postgres/modelling/__init__.py +++ b/infrastructure/postgres/modelling/__init__.py @@ -2,10 +2,10 @@ (ADR-0017 amendment). One canonical SQLModel per physical table — `plan`, `recommendation`, -`recommendation_materials` — 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. +`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 @@ -13,10 +13,18 @@ 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/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/scenario_table.py b/infrastructure/postgres/scenario_table.py deleted file mode 100644 index 62756cfe..00000000 --- a/infrastructure/postgres/scenario_table.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar, Optional - -from sqlmodel import Field, SQLModel - -from domain.modelling.scenario import Scenario - - -class ScenarioRow(SQLModel, table=True): - """SQLModel mirror of the live ``scenario`` table (ADR-0017). - - Declares only the columns the Modelling stage reads — the legacy - file-path columns (`trigger_file_path`, `exclusions`, …) and the - portfolio-level aggregates are left to the legacy SQLAlchemy model - (`backend/app/db/models/recommendations.py::ScenarioModel`), which still - owns the live reads. The physical table is the shared contract; this - mirror is read-only from the rebuild's side. - - `goal` is a Postgres enum in production; mapped here as its string value - (the Modelling stage does not yet branch on it — #1160). - """ - - __tablename__: ClassVar[str] = "scenario" # pyright: ignore[reportIncompatibleVariableOverride] - - id: Optional[int] = Field(default=None, primary_key=True) - goal: str - goal_value: str - budget: Optional[float] = Field(default=None) - is_default: bool = Field(default=False) - - 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, - goal_value=self.goal_value, - budget=self.budget, - is_default=self.is_default, - ) diff --git a/repositories/scenario/scenario_postgres_repository.py b/repositories/scenario/scenario_postgres_repository.py index 64d31553..2afa07a5 100644 --- a/repositories/scenario/scenario_postgres_repository.py +++ b/repositories/scenario/scenario_postgres_repository.py @@ -3,12 +3,12 @@ from __future__ import annotations from sqlmodel import Session, col, select from domain.modelling.scenario import Scenario -from infrastructure.postgres.scenario_table import ScenarioRow +from infrastructure.postgres.modelling import ScenarioModel from repositories.scenario.scenario_repository import ScenarioRepository class ScenarioPostgresRepository(ScenarioRepository): - """Reads the live ``scenario`` table (via the ``ScenarioRow`` mirror) and + """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.""" @@ -17,9 +17,9 @@ class ScenarioPostgresRepository(ScenarioRepository): def get_many(self, scenario_ids: list[int]) -> list[Scenario]: rows = self._session.exec( - select(ScenarioRow).where(col(ScenarioRow.id).in_(scenario_ids)) + select(ScenarioModel).where(col(ScenarioModel.id).in_(scenario_ids)) ).all() - by_id: dict[int, ScenarioRow] = { + by_id: dict[int, ScenarioModel] = { row.id: row for row in rows if row.id is not None } scenarios: list[Scenario] = [] diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index b042ca77..c830325c 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -20,7 +20,8 @@ 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 infrastructure.postgres.scenario_table import ScenarioRow +from domain.modelling.portfolio_goal import PortfolioGoal +from infrastructure.postgres.modelling import ScenarioModel from domain.geospatial.coordinates import Coordinates from infrastructure.postgres.property_baseline_performance_table import ( PropertyBaselinePerformanceModel, @@ -110,8 +111,8 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( # Modelling now runs for real: it reads scenario 7 (the command's # scenario_ids) through the repo, so the row must exist. session.add( - ScenarioRow( - id=7, goal="Increasing EPC", goal_value="C", is_default=True + 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 @@ -214,8 +215,8 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( ) ) session.add( - ScenarioRow( - id=7, goal="Increasing EPC", goal_value="C", is_default=True + ScenarioModel( + id=7, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", is_default=True ) ) session.add_all( @@ -356,8 +357,8 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( ) ) session.add( - ScenarioRow( - id=8, goal="Increasing EPC", goal_value="D", is_default=True + ScenarioModel( + id=8, goal=PortfolioGoal.INCREASING_EPC, goal_value="D", is_default=True ) ) # The fabric Generators + the ventilation dependency builder still run diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py index eed38c66..8e0df21c 100644 --- a/tests/repositories/scenario/test_scenario_postgres_repository.py +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -14,8 +14,9 @@ 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.scenario_table import ScenarioRow +from infrastructure.postgres.modelling import ScenarioModel from repositories.scenario.scenario_postgres_repository import ( ScenarioPostgresRepository, ) @@ -27,18 +28,18 @@ def test_get_many_maps_live_scenario_rows_to_domain_in_input_order( # Arrange with Session(db_engine) as session: session.add( - ScenarioRow( + ScenarioModel( id=7, - goal="INCREASING_EPC", + goal=PortfolioGoal.INCREASING_EPC, goal_value="C", budget=15000.0, is_default=True, ) ) session.add( - ScenarioRow( + ScenarioModel( id=9, - goal="INCREASING_EPC", + goal=PortfolioGoal.INCREASING_EPC, goal_value="B", budget=None, is_default=False, @@ -52,14 +53,14 @@ def test_get_many_maps_live_scenario_rows_to_domain_in_input_order( [9, 7] ) - # Assert + # 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 + id=9, goal="Increasing EPC", goal_value="B", budget=None, is_default=False ) assert scenarios[1] == Scenario( id=7, - goal="INCREASING_EPC", + goal="Increasing EPC", goal_value="C", budget=15000.0, is_default=True, @@ -70,8 +71,11 @@ def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> Non # Arrange with Session(db_engine) as session: session.add( - ScenarioRow( - id=7, goal="INCREASING_EPC", goal_value="C", is_default=True + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + is_default=True, ) ) session.commit() From 6f0dcc0455f013c0013fd6a56d5f48bec7c350ab Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:54:15 +0000 Subject: [PATCH 070/190] test(modelling): characterise the portfolio aggregation over plan_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the FE-facing aggregate_portfolio_recommendations (previously untested): it sums a Scenario's default Recommendations onto the Scenario row, joining Recommendation → Plan on recommendation.plan_id. Locks the m2m→plan_id read cut for the FE-critical path, now testable thanks to the full-parity ScenarioModel. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_portfolio_functions.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 backend/app/db/functions/tests/test_portfolio_functions.py 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 From a25495d770d2d145dc91d28f5a0e048b68e3b3a1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:57:08 +0000 Subject: [PATCH 071/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20plan=5Frecommendations=20retired=20+=20models=20consolidated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index f82af3eb..776de693 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -1,6 +1,6 @@ # HANDOVER — Modelling stage rebuild -**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `b976c3ab`. +**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 @@ -111,6 +111,22 @@ Filled `recommendation.kwh_savings` + `energy_cost_savings` via the **telescopin 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. + ## 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. From b8b7e02034b7da8c0375e5887a0d4c69b51c0fdb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:09:08 +0000 Subject: [PATCH 072/190] =?UTF-8?q?docs(modelling):=20next-phase=20handove?= =?UTF-8?q?r=20=E2=80=94=20depth=20+=20scale=20e2e=20+=20grilling=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the next phase (close persisted-field gaps + financial uplift, plus a large-scale e2e run of a SAP 10.2 EPC dump and console manual testing; measure coverage deferred) and a self-contained handover prompt for a fresh agent to pick up via a grilling session. Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_MODELLING.md | 11 +++++++ docs/HANDOVER_NEXT_PHASE_PROMPT.md | 53 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 docs/HANDOVER_NEXT_PHASE_PROMPT.md diff --git a/docs/HANDOVER_MODELLING.md b/docs/HANDOVER_MODELLING.md index 776de693..83fa2aa8 100644 --- a/docs/HANDOVER_MODELLING.md +++ b/docs/HANDOVER_MODELLING.md @@ -127,6 +127,17 @@ Designed in `/grill-with-docs` + `/grill-me`. The live `plan`/`recommendation` t **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. 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). From d5f1fc335b7dfa841e7e5efcdfb79daa7f076c79 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 07:51:44 +0000 Subject: [PATCH 073/190] test(modelling): run First Run with no database via in-memory fakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the DB-less inspection harness. Complete the in-memory FakeUnitOfWork so the ModellingOrchestrator runs with no Postgres: add FakeScenarioRepository + FakePlanRepository (idempotent, keyed by (property_id, scenario_id)), expose scenario/product/plan on the fake unit, and grow FakePropertyRepo to compose the effective EPC from the EPC repo at read time — mirroring PropertyPostgresRepository, so the EPC Ingestion persists is visible to Baseline + Modelling (the through-repos hand-off, in memory). The new integration test drives the full AraFirstRunPipeline (Ingestion -> Baseline -> Modelling) against the FakeUnitOfWork — no Session ever opened — on the uninsulated 000490 fixture with its lodged recorded-performance filled in (it already carries the RHI block, so Baseline can run) and asserts a multi-measure Plan is produced. The committed product catalogue prices the wall/floor/ventilation measures it fires. Co-Authored-By: Claude Opus 4.8 --- tests/orchestration/fakes.py | 84 +++++++++- .../fixtures/product_catalogue.json | 5 + .../test_first_run_without_database.py | 151 ++++++++++++++++++ 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 tests/orchestration/fixtures/product_catalogue.json create mode 100644 tests/orchestration/test_first_run_without_database.py diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 3e2feef0..06c22247 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -10,25 +10,51 @@ from types import TracebackType from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData +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 PropertyRepository +from repositories.scenario.scenario_repository import ScenarioRepository from repositories.solar.solar_repository import SolarRepository 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, + ) 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]) class FakeEpcRepo(EpcRepository): @@ -88,6 +114,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.""" @@ -98,11 +170,17 @@ class FakeUnitOfWork(UnitOfWork): epc: Optional[FakeEpcRepo] = None, solar: Optional[FakeSolarRepo] = 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.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/fixtures/product_catalogue.json b/tests/orchestration/fixtures/product_catalogue.json new file mode 100644 index 00000000..ab006317 --- /dev/null +++ b/tests/orchestration/fixtures/product_catalogue.json @@ -0,0 +1,5 @@ +{ + "cavity_wall_insulation": { "unit_cost_per_m2": 18.5 }, + "suspended_floor_insulation": { "unit_cost_per_m2": 25.0 }, + "mechanical_ventilation": { "unit_cost_per_m2": 450.0 } +} 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..ea7b5a81 --- /dev/null +++ b/tests/orchestration/test_first_run_without_database.py @@ -0,0 +1,151 @@ +"""First Run end-to-end with NO database — in-memory fakes only. + +The same `AraFirstRunPipeline` the Postgres integration test drives, but wired +against a `FakeUnitOfWork` instead of a `PostgresUnitOfWork`: Ingestion -> +Baseline -> Modelling run start-to-finish, hand off through in-memory repos, and +produce an inspectable multi-measure Plan without a `Session` ever being opened. +This is the harness the owner runs to sense-check recommendations interactively. +""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from datatypes.epc.domain.epc import Epc +from datatypes.epc.domain.epc_property_data import EpcPropertyData +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 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 tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc as _build_uninsulated_cavity_and_floor_epc, +) +from tests.orchestration.fakes import ( + FakeEpcRepo, + FakePlanRepository, + FakePropertyRepo, + FakeScenarioRepository, + FakeUnitOfWork, +) + +_CATALOGUE = Path(__file__).resolve().parent / "fixtures/product_catalogue.json" + + +@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): # type: ignore[no-untyped-def] + 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 _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 (as a real lodged EPC would) — 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 — an in-memory Property (no EPC yet; Ingestion supplies it), a + # default Increasing-EPC Scenario, and a file-backed product catalogue. + epc_repo = FakeEpcRepo() + plan_repo = FakePlanRepository() + property_repo = FakePropertyRepo( + { + 10: Property( + identity=PropertyIdentity( + portfolio_id=1, + postcode="A0 0AA", + address="1 Some Street", + uprn=12345, + ) + ) + }, + epc_repo=epc_repo, + ) + unit: FakeUnitOfWork = FakeUnitOfWork( + property=property_repo, + epc=epc_repo, + scenario=FakeScenarioRepository( + { + 7: Scenario( + id=7, + goal="Increasing EPC", + goal_value="C", + budget=None, + is_default=True, + ) + } + ), + product=ProductJsonRepository(_CATALOGUE), + plan=plan_repo, + ) + + pipeline = AraFirstRunPipeline( + ingestion=IngestionOrchestrator( + unit_of_work=lambda: unit, + epc_fetcher=_FetcherReturning(_uninsulated_lodged_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(), + ), + ) + + # Act — the whole First Run, no Session ever opened. + pipeline.run(_Command(portfolio_id=1, property_ids=[10], scenario_ids=[7])) + + # Assert — a Plan was persisted in memory for (property 10, scenario 7), + # with at least one Plan Measure and a post-retrofit SAP no worse than baseline. + plan = plan_repo.saved[(10, 7)] + assert len(plan.measures) >= 1 + assert plan.post_sap_continuous >= plan.baseline.sap_continuous From 26d7fc036e16484ac3c5c501a3a851ffe44d2b49 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 07:52:33 +0000 Subject: [PATCH 074/190] docs(modelling): record Valuation Uplift design (ADR-0018 + glossary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the grill-with-docs pass on the depth+scale phase. Splits the overloaded "valuation" into two glossary terms — Property Valuation (current market value, a Baseline attribute, mostly missing) and Valuation Uplift (plan-conditional, percentage-primary; absolute £ only when a Property Valuation exists, 2x ROI cap on the £ form). ADR-0018 records the percentage-primary decision and why (the EPC scale corpus has no market values, so a value-primary model produces nothing), plus the deferred sourcing / per-measure / rental-yield items. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 12 ++++++ ...018-valuation-uplift-percentage-primary.md | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/adr/0018-valuation-uplift-percentage-primary.md diff --git a/CONTEXT.md b/CONTEXT.md index 36ae6d4c..2e8c5d00 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -234,6 +234,16 @@ _Avoid_: selected measures, default measures, optimal solution, recommended bund 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 +### 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**: @@ -297,6 +307,7 @@ _Avoid_: API key, auth token, secret - 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 @@ -333,4 +344,5 @@ _Avoid_: API key, auth token, secret - **"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"** (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. 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. From 9329978374236ea856c7fb2fb883f11a15861c6a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:06:53 +0000 Subject: [PATCH 075/190] feat(modelling): sense-check table for a Plan in the DB-less harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2. `harness.plan_table.format_plan_table(plan)` renders a Plan as a plain-text table — one package summary line (baseline SAP/band -> post SAP/band, CO2 saved, cost of works + contingency, bill saved) and one line per Plan Measure (signed SAP points, cost, delivered kWh + £ savings). Pure presentation: reads the Plan, computes nothing. The DB-less First Run test now prints it (visible under `pytest -s`) so the modelled package can be eyeballed and debugged by hand. Co-Authored-By: Claude Opus 4.8 --- harness/__init__.py | 0 harness/plan_table.py | 61 +++++++++++++++++ tests/harness/__init__.py | 0 tests/harness/test_plan_table.py | 65 +++++++++++++++++++ .../test_first_run_without_database.py | 2 + 5 files changed, 128 insertions(+) create mode 100644 harness/__init__.py create mode 100644 harness/plan_table.py create mode 100644 tests/harness/__init__.py create mode 100644 tests/harness/test_plan_table.py diff --git a/harness/__init__.py b/harness/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/harness/plan_table.py b/harness/plan_table.py new file mode 100644 index 00000000..b654a7ee --- /dev/null +++ b/harness/plan_table.py @@ -0,0 +1,61 @@ +"""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" + ) + 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, columns, *rows]) 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_plan_table.py b/tests/harness/test_plan_table.py new file mode 100644 index 00000000..42dc1b1a --- /dev/null +++ b/tests/harness/test_plan_table.py @@ -0,0 +1,65 @@ +"""The sense-check table the DB-less harness prints for a Plan.""" + +from __future__ import annotations + +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="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="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_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/orchestration/test_first_run_without_database.py b/tests/orchestration/test_first_run_without_database.py index ea7b5a81..17f1a023 100644 --- a/tests/orchestration/test_first_run_without_database.py +++ b/tests/orchestration/test_first_run_without_database.py @@ -28,6 +28,7 @@ from repositories.fuel_rates.fuel_rates_static_file_repository import ( FuelRatesStaticFileRepository, ) from repositories.geospatial.geospatial_repository import GeospatialRepository +from harness.plan_table import format_plan_table from repositories.product.product_json_repository import ProductJsonRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc as _build_uninsulated_cavity_and_floor_epc, @@ -147,5 +148,6 @@ def test_first_run_produces_a_multi_measure_plan_without_a_database() -> None: # Assert — a Plan was persisted in memory for (property 10, scenario 7), # with at least one Plan Measure and a post-retrofit SAP no worse than baseline. plan = plan_repo.saved[(10, 7)] + print("\n" + format_plan_table(plan)) # visible under `pytest -s` for sense-checking assert len(plan.measures) >= 1 assert plan.post_sap_continuous >= plan.baseline.sap_continuous From c5520b82f9ace18d3a8d0d8fb86d3ad4a453f6cc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:14:14 +0000 Subject: [PATCH 076/190] feat(modelling): run_one console entrypoint for DB-less inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3. `harness.console.run_one(epc, goal_band=...)` wires the full AraFirstRunPipeline against in-memory fakes — no Postgres, no network — runs one property, prints the sense-check table, and returns the Plan for interactive poking from a REPL at the worktree root. Defaults to the committed harness sample catalogue. Refactors the slice-1 integration test to delegate to run_one (dropping ~70 lines of duplicated wiring + the now-unused test catalogue fixture), so it exercises the shipped entrypoint rather than a parallel copy. The new console test covers run_one's print/return contract. Co-Authored-By: Claude Opus 4.8 --- harness/console.py | 157 ++++++++++++++++++ .../sample_catalogue.json | 0 tests/harness/test_console.py | 42 +++++ .../test_first_run_without_database.py | 131 ++------------- 4 files changed, 211 insertions(+), 119 deletions(-) create mode 100644 harness/console.py rename tests/orchestration/fixtures/product_catalogue.json => harness/sample_catalogue.json (100%) create mode 100644 tests/harness/test_console.py diff --git a/harness/console.py b/harness/console.py new file mode 100644 index 00000000..26498591 --- /dev/null +++ b/harness/console.py @@ -0,0 +1,157 @@ +"""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.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 tests.orchestration.fakes import ( + FakeEpcRepo, + FakePlanRepository, + FakePropertyRepo, + FakeScenarioRepository, + 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, + 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. + + ``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, + ) + ) + }, + 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 diff --git a/tests/orchestration/fixtures/product_catalogue.json b/harness/sample_catalogue.json similarity index 100% rename from tests/orchestration/fixtures/product_catalogue.json rename to harness/sample_catalogue.json diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py new file mode 100644 index 00000000..f5ddc5ed --- /dev/null +++ b/tests/harness/test_console.py @@ -0,0 +1,42 @@ +"""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 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: + 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 diff --git a/tests/orchestration/test_first_run_without_database.py b/tests/orchestration/test_first_run_without_database.py index 17f1a023..08769c8e 100644 --- a/tests/orchestration/test_first_run_without_database.py +++ b/tests/orchestration/test_first_run_without_database.py @@ -1,82 +1,29 @@ -"""First Run end-to-end with NO database — in-memory fakes only. +"""First Run end-to-end with NO database, via the harness console entrypoint. -The same `AraFirstRunPipeline` the Postgres integration test drives, but wired -against a `FakeUnitOfWork` instead of a `PostgresUnitOfWork`: Ingestion -> -Baseline -> Modelling run start-to-finish, hand off through in-memory repos, and -produce an inspectable multi-measure Plan without a `Session` ever being opened. -This is the harness the owner runs to sense-check recommendations interactively. +`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 dataclasses import dataclass -from pathlib import Path -from typing import Any, Optional from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData -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 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 harness.plan_table import format_plan_table -from repositories.product.product_json_repository import ProductJsonRepository +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, ) -from tests.orchestration.fakes import ( - FakeEpcRepo, - FakePlanRepository, - FakePropertyRepo, - FakeScenarioRepository, - FakeUnitOfWork, -) - -_CATALOGUE = Path(__file__).resolve().parent / "fixtures/product_catalogue.json" - - -@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): # type: ignore[no-untyped-def] - 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 _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 (as a real lodged EPC would) — it already carries the RHI block — so the - # Baseline stage can run inside the full pipeline. + # 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, @@ -88,66 +35,12 @@ def _uninsulated_lodged_epc() -> EpcPropertyData: def test_first_run_produces_a_multi_measure_plan_without_a_database() -> None: - # Arrange — an in-memory Property (no EPC yet; Ingestion supplies it), a - # default Increasing-EPC Scenario, and a file-backed product catalogue. - epc_repo = FakeEpcRepo() - plan_repo = FakePlanRepository() - property_repo = FakePropertyRepo( - { - 10: Property( - identity=PropertyIdentity( - portfolio_id=1, - postcode="A0 0AA", - address="1 Some Street", - uprn=12345, - ) - ) - }, - epc_repo=epc_repo, - ) - unit: FakeUnitOfWork = FakeUnitOfWork( - property=property_repo, - epc=epc_repo, - scenario=FakeScenarioRepository( - { - 7: Scenario( - id=7, - goal="Increasing EPC", - goal_value="C", - budget=None, - is_default=True, - ) - } - ), - product=ProductJsonRepository(_CATALOGUE), - plan=plan_repo, - ) - - pipeline = AraFirstRunPipeline( - ingestion=IngestionOrchestrator( - unit_of_work=lambda: unit, - epc_fetcher=_FetcherReturning(_uninsulated_lodged_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(), - ), - ) + # Arrange + epc: EpcPropertyData = _uninsulated_lodged_epc() # Act — the whole First Run, no Session ever opened. - pipeline.run(_Command(portfolio_id=1, property_ids=[10], scenario_ids=[7])) + plan = run_one(epc, goal_band="C", print_table=False) - # Assert — a Plan was persisted in memory for (property 10, scenario 7), - # with at least one Plan Measure and a post-retrofit SAP no worse than baseline. - plan = plan_repo.saved[(10, 7)] - print("\n" + format_plan_table(plan)) # visible under `pytest -s` for sense-checking + # 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 From 31da90f5eb76e65ac145ed054b6354aaef5d8d75 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:26:58 +0000 Subject: [PATCH 077/190] feat(modelling): persist recommendation.material_id from the catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand half of the recommendation_materials retirement (ADR-0017). A Plan Measure installs a single Product, so thread its catalogue id end to end — Product.id -> MeasureOption.material_id -> PlanMeasure.material_id -> recommendation.material_id — replacing the per-material BOM child table with one nullable column on the row. ProductPostgresRepository reads the id from MaterialRow; the four fabric generators set it on their Option; the orchestrator carries it onto the Plan Measure; the mirror declares + maps the column. Optional throughout (the JSON stopgap catalogue carries no ids -> NULL). The multi-measure integration test now pins each persisted measure's material_id to its seeded MaterialRow id. Migration spec (live column must be added before this deploys; contraction is the owner's next step) in docs/migrations/recommendation-material-id.md. Co-Authored-By: Claude Opus 4.8 --- docs/migrations/recommendation-material-id.md | 45 +++++++++++++++++++ .../generators/floor_recommendation.py | 1 + .../generators/roof_recommendation.py | 1 + .../generators/ventilation_recommendation.py | 1 + .../generators/wall_recommendation.py | 1 + domain/modelling/plan.py | 4 ++ domain/modelling/product.py | 6 +++ domain/modelling/recommendation.py | 4 ++ .../modelling/recommendation_table.py | 5 +++ orchestration/modelling_orchestrator.py | 1 + .../product/product_postgres_repository.py | 1 + ...test_ara_first_run_pipeline_integration.py | 6 +++ 12 files changed, 76 insertions(+) create mode 100644 docs/migrations/recommendation-material-id.md 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/domain/modelling/generators/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py index f2360677..b078a230 100644 --- a/domain/modelling/generators/floor_recommendation.py +++ b/domain/modelling/generators/floor_recommendation.py @@ -87,5 +87,6 @@ def recommend_floor_insulation( } ), cost=cost, + material_id=product.id, ) return Recommendation(surface="Ground floor", options=(option,)) diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index aa09b620..6b689733 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -59,5 +59,6 @@ def recommend_loft_insulation( } ), cost=cost, + material_id=product.id, ) return Recommendation(surface="Roof", options=(option,)) diff --git a/domain/modelling/generators/ventilation_recommendation.py b/domain/modelling/generators/ventilation_recommendation.py index f3eaebec..0fed5c7b 100644 --- a/domain/modelling/generators/ventilation_recommendation.py +++ b/domain/modelling/generators/ventilation_recommendation.py @@ -53,6 +53,7 @@ def recommend_ventilation( ventilation=VentilationOverlay(mechanical_ventilation_kind=_MEV_KIND) ), cost=cost, + material_id=product.id, ) return Recommendation(surface="Ventilation", options=(option,)) diff --git a/domain/modelling/generators/wall_recommendation.py b/domain/modelling/generators/wall_recommendation.py index 6b263ba2..86c81f41 100644 --- a/domain/modelling/generators/wall_recommendation.py +++ b/domain/modelling/generators/wall_recommendation.py @@ -63,5 +63,6 @@ def recommend_cavity_wall( } ), cost=cost, + material_id=product.id, ) return Recommendation(surface="Main wall", options=(option,)) diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index cfaaf9ff..7016ecda 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -38,6 +38,10 @@ class PlanMeasure: 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) diff --git a/domain/modelling/product.py b/domain/modelling/product.py index fe5c78f3..afd46897 100644 --- a/domain/modelling/product.py +++ b/domain/modelling/product.py @@ -7,6 +7,7 @@ not "material". Read via a `ProductRepository`. """ from dataclasses import dataclass +from typing import Optional @dataclass(frozen=True) @@ -14,3 +15,8 @@ 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/recommendation.py b/domain/modelling/recommendation.py index 4a287ee9..96331c44 100644 --- a/domain/modelling/recommendation.py +++ b/domain/modelling/recommendation.py @@ -32,6 +32,10 @@ class MeasureOption: 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) diff --git a/infrastructure/postgres/modelling/recommendation_table.py b/infrastructure/postgres/modelling/recommendation_table.py index 77af71fc..62b3ff6f 100644 --- a/infrastructure/postgres/modelling/recommendation_table.py +++ b/infrastructure/postgres/modelling/recommendation_table.py @@ -49,6 +49,10 @@ class RecommendationModel(SQLModel, table=True): 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) @@ -75,6 +79,7 @@ class RecommendationModel(SQLModel, table=True): 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=( diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 7fc9b491..1a6ee852 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -259,4 +259,5 @@ def _plan_measure( 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/repositories/product/product_postgres_repository.py b/repositories/product/product_postgres_repository.py index 13926885..5a46348c 100644 --- a/repositories/product/product_postgres_repository.py +++ b/repositories/product/product_postgres_repository.py @@ -31,4 +31,5 @@ class ProductPostgresRepository(ProductRepository): measure_type=measure_type, unit_cost_per_m2=row.total_cost, contingency_rate=contingency_rate(measure_type), + id=row.id, ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index c830325c..9292ef09 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -302,6 +302,12 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( "suspended_floor_insulation", "mechanical_ventilation", } + # 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 for rec in rec_rows: assert rec.default is True assert rec.already_installed is False From e6f54df92bf47733c2a22612a08c76b385143f45 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:33:19 +0000 Subject: [PATCH 078/190] feat(modelling): ValuationUplift domain class (percentage-primary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The financial-uplift model per ADR-0018. `estimate_valuation_uplift( current_band, target_band, current_value=None, total_cost=None)` returns a `ValuationUplift`: band-transition uplift compounded from four broker tables (MoneySupermarket / Lloyds per-step, Knight Frank / Rightmove whole-jump), taking min/max/mean across the covering sources. Always a percentage; absolute £ forms (increase at each bound + post-retrofit value) only when a current market value is supplied; the 2x ROI cap rescales the percentages and can only bite once a value is known. A non-improving jump is a clean 0% no-op. Pure function, no external dependency. Persisting it (where the value lands) and sourcing the current market value stay deferred (ADR-0018). Co-Authored-By: Claude Opus 4.8 --- domain/modelling/valuation.py | 151 +++++++++++++++++++++++ tests/domain/modelling/test_valuation.py | 84 +++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 domain/modelling/valuation.py create mode 100644 tests/domain/modelling/test_valuation.py 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/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 From b3f4609c2db7d98f67c751da8a213dcc1e50a482 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:59:04 +0000 Subject: [PATCH 079/190] feat(modelling): wire Valuation Uplift onto the Plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post band jump and works+contingency cost, given one external input — the Property's current market value (a Property Valuation, mostly absent). `Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other headline figures; `PlanModel.from_domain` maps the £ forms to the live plan.valuation_* columns (NULL when no value — the percentage is not persisted on those columns). `Property.current_market_value` is the new optional source; the orchestrator threads it onto the Plan. `run_one` takes a `current_market_value` so the harness can value the uplift, and the sense-check table shows the average % (always) plus the £ forms when known. Sourcing the current market value (upload / default) remains deferred (ADR-0018); it is None throughout until that lands, so the columns stay NULL at scale. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/plan.py | 23 ++++++ domain/property/property.py | 3 + harness/console.py | 6 +- harness/plan_table.py | 9 ++- .../postgres/modelling/plan_table.py | 7 ++ orchestration/modelling_orchestrator.py | 10 ++- tests/domain/modelling/test_plan_valuation.py | 74 +++++++++++++++++++ tests/harness/test_console.py | 15 ++++ tests/harness/test_plan_table.py | 25 +++++++ tests/orchestration/fakes.py | 1 + 10 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/domain/modelling/test_plan_valuation.py diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 7016ecda..8483359f 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -18,6 +18,7 @@ from domain.billing.bill import Bill 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) @@ -60,6 +61,10 @@ class Plan: 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: @@ -88,6 +93,24 @@ class Plan: """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 diff --git a/domain/property/property.py b/domain/property/property.py index 856eb3e3..825a79a7 100644 --- a/domain/property/property.py +++ b/domain/property/property.py @@ -37,6 +37,9 @@ 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 @property def source_path(self) -> SourcePath: diff --git a/harness/console.py b/harness/console.py index 26498591..648ff739 100644 --- a/harness/console.py +++ b/harness/console.py @@ -84,12 +84,15 @@ def run_one( *, 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() @@ -102,7 +105,8 @@ def run_one( postcode="A0 0AA", address="1 Some Street", uprn=12345, - ) + ), + current_market_value=current_market_value, ) }, epc_repo=epc_repo, diff --git a/harness/plan_table.py b/harness/plan_table.py index b654a7ee..7c6e96f1 100644 --- a/harness/plan_table.py +++ b/harness/plan_table.py @@ -46,6 +46,13 @@ def format_plan_table(plan: Plan) -> str: 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}" @@ -58,4 +65,4 @@ def format_plan_table(plan: Plan) -> str: f"{_signed_gbp(measure.energy_cost_savings):>9}" for measure in plan.measures ] - return "\n".join([header, columns, *rows]) + return "\n".join([header, valuation_line, columns, *rows]) diff --git a/infrastructure/postgres/modelling/plan_table.py b/infrastructure/postgres/modelling/plan_table.py index 75485c9d..e1281f49 100644 --- a/infrastructure/postgres/modelling/plan_table.py +++ b/infrastructure/postgres/modelling/plan_table.py @@ -103,4 +103,11 @@ class PlanModel(SQLModel, table=True): 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/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 1a6ee852..e7a336e1 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -97,7 +97,12 @@ class ModellingOrchestrator: effective_epc: EpcPropertyData = prop.effective_epc for scenario in scenarios: plan = self._plan_for( - scorer, bill_derivation, effective_epc, uow.product, scenario + scorer, + bill_derivation, + effective_epc, + uow.product, + scenario, + current_market_value=prop.current_market_value, ) uow.plan.save( plan, @@ -115,6 +120,8 @@ class ModellingOrchestrator: effective_epc: EpcPropertyData, products: ProductRepository, scenario: Scenario, + *, + current_market_value: Optional[float], ) -> Plan: """Generate → score → optimise → re-score/repair → attribute → bill → assemble the Plan for one Property + Scenario.""" @@ -165,6 +172,7 @@ class ModellingOrchestrator: post_retrofit=package.score, baseline_bill=bills[0], post_bill=bills[-1], + current_market_value=current_market_value, ) 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/harness/test_console.py b/tests/harness/test_console.py index f5ddc5ed..d7b9675d 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -40,3 +40,18 @@ def test_run_one_returns_a_plan_and_prints_the_table( printed: str = capsys.readouterr().out assert "Plan SAP" in printed assert "cavity_wall_insulation" in printed + + +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_plan_table.py b/tests/harness/test_plan_table.py index 42dc1b1a..fa7ac6e6 100644 --- a/tests/harness/test_plan_table.py +++ b/tests/harness/test_plan_table.py @@ -45,6 +45,31 @@ def _plan() -> Plan: 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() diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 06c22247..f8ec1734 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -48,6 +48,7 @@ class FakePropertyRepo(PropertyRepository): 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: From 98f5ee4fca1a99f33f98ee2765e7c4ccdd160c74 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 09:19:18 +0000 Subject: [PATCH 080/190] feat(modelling): robust offline modelling inspection (run_modelling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that unblock offline, no-database inspection over an arbitrary EPC dump: - Complete the harness sample catalogue with loft_insulation and solid_floor_insulation — the four fabric generators can emit five Measure Types, but the catalogue priced only three, so an offline run on a property with an uninsulated loft or solid floor raised mid-run. A new test pins the catalogue to cover every generator Measure Type. - Add `run_modelling(epc, ...)` — runs ONLY the Modelling stage (no Ingestion / Baseline), so it needs no lodged recorded-performance / RHI and inspects recommendations on any calculator-scorable EPC. `run_one` (full pipeline) stays for when you want Baseline too. Co-Authored-By: Claude Opus 4.8 --- harness/console.py | 61 +++++++++++++++++++++++++++++++++++ harness/sample_catalogue.json | 2 ++ tests/harness/test_console.py | 35 +++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/harness/console.py b/harness/console.py index 648ff739..68285b94 100644 --- a/harness/console.py +++ b/harness/console.py @@ -159,3 +159,64 @@ def run_one( 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, + 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.""" + 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, + ) + }, + ) + unit = FakeUnitOfWork( + property=property_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, + ) + + ModellingOrchestrator( + unit_of_work=lambda: unit, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), + ).run( + property_ids=[_PROPERTY_ID], + scenario_ids=[_SCENARIO_ID], + portfolio_id=_PORTFOLIO_ID, + ) + + plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)] + if print_table: + print("\n" + format_plan_table(plan)) + return plan diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index ab006317..f3cb49c2 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -1,5 +1,7 @@ { "cavity_wall_insulation": { "unit_cost_per_m2": 18.5 }, + "loft_insulation": { "unit_cost_per_m2": 12.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 } } diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index d7b9675d..1de1a345 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -8,11 +8,22 @@ import pytest from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData -from harness.console import run_one +from harness.console import DEFAULT_CATALOGUE, run_modelling, run_one +from repositories.product.product_json_repository import ProductJsonRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc as _build_uninsulated_cavity_and_floor_epc, ) +# Every Measure Type the four 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", + "loft_insulation", + "suspended_floor_insulation", + "solid_floor_insulation", + "mechanical_ventilation", +) + def _uninsulated_lodged_epc() -> EpcPropertyData: epc = _build_uninsulated_cavity_and_floor_epc() @@ -42,6 +53,28 @@ def test_run_one_returns_a_plan_and_prints_the_table( 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_sample_catalogue_prices_every_generator_measure_type() -> None: + # Arrange — the default offline catalogue. + products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE) + + # Act / Assert — get() raises if a Measure Type is unpriced, so an offline + # run over arbitrary EPCs never dies on a missing catalogue entry. + for measure_type in _GENERATOR_MEASURE_TYPES: + products.get(measure_type) + + def test_run_one_threads_a_current_market_value_onto_the_plan() -> None: # Arrange epc: EpcPropertyData = _uninsulated_lodged_epc() From d8ef40c74577636811dd995d7d373708c143611f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 09:23:32 +0000 Subject: [PATCH 081/190] feat(modelling): offline cohort runner over an EPC-JSON dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `harness.cohort.run_cohort(paths)` parses each API-shaped EPC JSON with from_api_response and models it via run_modelling — no database, no network — capturing per-cert errors instead of aborting the sweep, plus `format_cohort_summary`. A thin `scripts/run_modelling_cohort.py` CLI points it at a directory. Proven over the 57 golden API certs: 56 ran offline, 15 produced measures, 1 errored (COAL has no Fuel Rates entry — a BillDerivation coverage gap, not a harness one). Ready for the EPC dump. Co-Authored-By: Claude Opus 4.8 --- harness/cohort.py | 102 ++++++++++++++++++++++++++++++++ scripts/run_modelling_cohort.py | 52 ++++++++++++++++ tests/harness/test_cohort.py | 30 ++++++++++ 3 files changed, 184 insertions(+) create mode 100644 harness/cohort.py create mode 100644 scripts/run_modelling_cohort.py create mode 100644 tests/harness/test_cohort.py diff --git a/harness/cohort.py b/harness/cohort.py new file mode 100644 index 00000000..a3ff19cc --- /dev/null +++ b/harness/cohort.py @@ -0,0 +1,102 @@ +"""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. +""" + +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 harness.console import DEFAULT_CATALOGUE, run_modelling + + +@dataclass(frozen=True) +class CertResult: + """The outcome of modelling one cert: its measure count and SAP transition, + or the error it raised (then `measures` is 0 and the SAPs are None).""" + + name: str + measures: int + baseline_sap: Optional[float] + post_sap: Optional[float] + error: Optional[str] + + +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, + measures=len(plan.measures), + baseline_sap=plan.baseline.sap_continuous, + post_sap=plan.post_sap_continuous, + error=None, + ) + ) + except Exception as error: # noqa: BLE001 — one bad cert must not stop the sweep + results.append( + CertResult( + name=path.stem, + measures=0, + baseline_sap=None, + post_sap=None, + 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) diff --git a/scripts/run_modelling_cohort.py b/scripts/run_modelling_cohort.py new file mode 100644 index 00000000..ec6a04eb --- /dev/null +++ b/scripts/run_modelling_cohort.py @@ -0,0 +1,52 @@ +"""Run an EPC-JSON dump through Modelling offline and print a summary. + +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: + + python -m scripts.run_modelling_cohort [goal_band] + +e.g. against the committed golden cohort: + + python -m scripts.run_modelling_cohort tests/domain/sap10_calculator/rdsap/fixtures/golden +""" + +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 format_cohort_summary, run_cohort # noqa: E402 + + +def main() -> None: + if len(sys.argv) < 2: + print( + "usage: python -m scripts.run_modelling_cohort " + " [goal_band]" + ) + raise SystemExit(2) + + directory = Path(sys.argv[1]) + goal_band = sys.argv[2] if len(sys.argv) > 2 else "C" + paths = sorted(directory.glob("*.json")) + if not paths: + print(f"no *.json files under {directory}") + raise SystemExit(1) + + results = run_cohort(paths, goal_band=goal_band) + print(format_cohort_summary(results)) + print("\ncerts with measures:") + for result in results: + if result.measures and result.baseline_sap is not None and result.post_sap is not None: + print( + f" {result.name} SAP {result.baseline_sap:.1f} -> " + f"{result.post_sap:.1f} ({result.measures} measures)" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/harness/test_cohort.py b/tests/harness/test_cohort.py new file mode 100644 index 00000000..c040e160 --- /dev/null +++ b/tests/harness/test_cohort.py @@ -0,0 +1,30 @@ +"""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_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 From 8b5ab1c59e87cd7be28c49af09dc1409833c9012 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 09:30:53 +0000 Subject: [PATCH 082/190] feat(modelling): turnkey offline cohort script (tables + CSV) CertResult now carries its Plan (with flat baseline/post-SAP/measures properties), and `format_cohort_csv` renders one browsable row per cert (SAP transition, band, measures, cost, bill saving, valuation %, error). `scripts/run_modelling_cohort.py` is turnkey: no args runs the committed golden cohort, prints a sense-check table for the first measure-bearing certs (a capped preview so a large dump doesn't flood the terminal), the summary, and writes modelling_cohort.csv (gitignored). Point it at the EPC dump when it lands. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 +- harness/cohort.py | 86 ++++++++++++++++++++++++--------- scripts/run_modelling_cohort.py | 63 +++++++++++++++--------- tests/harness/test_cohort.py | 22 ++++++++- 4 files changed, 126 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index 6cd39e9d..a1bd9c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -298,4 +298,4 @@ pyrightconfig.json backlog/* # Local Claude config files -.claude/* \ No newline at end of file +.claude/*modelling_cohort.csv diff --git a/harness/cohort.py b/harness/cohort.py index a3ff19cc..a56aacb0 100644 --- a/harness/cohort.py +++ b/harness/cohort.py @@ -4,7 +4,7 @@ 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. +stops the inspection. Point it at your EPC dump and read the summary / CSV. """ from __future__ import annotations @@ -15,19 +15,30 @@ 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 measure count and SAP transition, - or the error it raised (then `measures` is 0 and the SAPs are None).""" + """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 - measures: int - baseline_sap: Optional[float] - post_sap: Optional[float] - error: Optional[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( @@ -48,24 +59,10 @@ def run_cohort( catalogue_path=catalogue_path, print_table=False, ) - results.append( - CertResult( - name=path.stem, - measures=len(plan.measures), - baseline_sap=plan.baseline.sap_continuous, - post_sap=plan.post_sap_continuous, - error=None, - ) - ) + 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, - measures=0, - baseline_sap=None, - post_sap=None, - error=f"{type(error).__name__}: {error}", - ) + CertResult(name=path.stem, error=f"{type(error).__name__}: {error}") ) return results @@ -100,3 +97,46 @@ def format_cohort_summary(results: list[CertResult]) -> str: 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/scripts/run_modelling_cohort.py b/scripts/run_modelling_cohort.py index ec6a04eb..d43cc66a 100644 --- a/scripts/run_modelling_cohort.py +++ b/scripts/run_modelling_cohort.py @@ -1,14 +1,18 @@ -"""Run an EPC-JSON dump through Modelling offline and print a summary. +"""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: +so imports resolve to this checkout, not /workspaces/model. - python -m scripts.run_modelling_cohort [goal_band] + # no args -> the committed golden cohort (57 real API certs) + python -m scripts.run_modelling_cohort -e.g. against the committed golden cohort: + # your dump, optional goal band (default C) + python -m scripts.run_modelling_cohort path/to/dump C - python -m scripts.run_modelling_cohort tests/domain/sap10_calculator/rdsap/fixtures/golden +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 @@ -19,33 +23,48 @@ 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 format_cohort_summary, run_cohort # noqa: E402 +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: - if len(sys.argv) < 2: - print( - "usage: python -m scripts.run_modelling_cohort " - " [goal_band]" - ) - raise SystemExit(2) - - directory = Path(sys.argv[1]) - goal_band = sys.argv[2] if len(sys.argv) > 2 else "C" + 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) - print(format_cohort_summary(results)) - print("\ncerts with measures:") + + shown = 0 for result in results: - if result.measures and result.baseline_sap is not None and result.post_sap is not None: - print( - f" {result.name} SAP {result.baseline_sap:.1f} -> " - f"{result.post_sap:.1f} ({result.measures} measures)" - ) + 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__": diff --git a/tests/harness/test_cohort.py b/tests/harness/test_cohort.py index c040e160..beac2505 100644 --- a/tests/harness/test_cohort.py +++ b/tests/harness/test_cohort.py @@ -4,7 +4,12 @@ from __future__ import annotations from pathlib import Path -from harness.cohort import CertResult, format_cohort_summary, run_cohort +from harness.cohort import ( + CertResult, + format_cohort_csv, + format_cohort_summary, + run_cohort, +) _GOLDEN = ( Path(__file__).resolve().parents[1] @@ -28,3 +33,18 @@ def test_run_cohort_models_each_api_json_offline() -> None: # 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 From c6eaa53931d295aeaec6bdf4be87cba601d1fa53 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 10:21:16 +0000 Subject: [PATCH 083/190] feat(billing): price house coal + heat network as documented proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coal and heat networks have no national retail/cap rate, so the snapshot left them null and BillDerivation raised UnpricedFuel — dropping those certs from an offline cohort run. Add researched proxy rates (fuel-input basis, sources + arithmetic in the JSON _note/_gaps): COAL 7.13 p/kWh (NEP Nov 2025 coal uprated + DESNZ DUKES house-coal GCV) and HEAT_NETWORK 16.0 p/kWh + 69.4 p/day (Insite Energy operator sample; indicative, schemes vary ~8-30). Both flagged proxy/indicative — sense-check estimates, not market rates. Existing curated fuels are unchanged. Replaces the unpriced-raises pin for these two with a positive rate pin; off-peak stays unpriced pending the day/night accessor. Golden cohort now runs 57/57 offline with zero errors. Co-Authored-By: Claude Opus 4.8 --- .../fuel_rates/data/fuel_rates_2026_q2.json | 11 +++++----- .../test_fuel_rates_static_file_repository.py | 22 +++++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/repositories/fuel_rates/data/fuel_rates_2026_q2.json b/repositories/fuel_rates/data/fuel_rates_2026_q2.json index 2b81bd30..bb095c72 100644 --- a/repositories/fuel_rates/data/fuel_rates_2026_q2.json +++ b/repositories/fuel_rates/data/fuel_rates_2026_q2.json @@ -16,12 +16,13 @@ "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 + "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. Existing fuels left on their prior curated values." } diff --git a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py index a129daf2..d73eda49 100644 --- a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py +++ b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py @@ -32,14 +32,22 @@ def test_snapshot_prices_metered_and_delivered_fuels_plus_seg() -> None: 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). +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) From 7be4d83ffae180df42bffd08687efb1a0c823bc8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 10:27:29 +0000 Subject: [PATCH 084/190] chore(billing): refresh off-gas fuel rates onto one consistent basis Apply the deep-research off-gas figures so oil/smokeless/wood sit on the same NEP-Apr-2026 retail / DESNZ DUKES gross-CV basis as the new coal proxy (fuel-input, not useful-heat): OIL 9.16 -> 12.11 (prior value was materially low vs current kerosene), SMOKELESS 10.0 -> 8.69, WOOD_LOGS 8.83 -> 8.25, WOOD_PELLETS 7.99 -> 7.38. SEG (15.0, Solar Energy UK) and LPG (17.61, bottled-propane) kept; gas/electricity (Ofgem cap) unchanged. CV arithmetic recorded in the snapshot _assumptions. OIL pin updated. Co-Authored-By: Claude Opus 4.8 --- .../fuel_rates/data/fuel_rates_2026_q2.json | 25 +++++++++++++------ .../test_fuel_rates_static_file_repository.py | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/repositories/fuel_rates/data/fuel_rates_2026_q2.json b/repositories/fuel_rates/data/fuel_rates_2026_q2.json index bb095c72..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,11 +11,11 @@ "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" }, + "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)." } }, @@ -24,5 +24,14 @@ "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. Existing fuels left on their prior curated values." + "_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/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py index d73eda49..6b177296 100644 --- a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py +++ b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py @@ -27,7 +27,7 @@ def test_snapshot_prices_metered_and_delivered_fuels_plus_seg() -> None: # 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 From 77e29ac2f8ad5cd1eee48efe36503cb243caf1a9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 10:46:31 +0000 Subject: [PATCH 085/190] =?UTF-8?q?docs(modelling):=20handover=20=E2=80=94?= =?UTF-8?q?=20EPC=20API=20fetch=20+=20property=20inspection=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next-phase handover: fetch live EPCs via EpcClientService, run the offline Modelling harness, and save a per-property report covering (1) lodged-vs-calculated SAP divergence (>0.5), (2) plans + costings, (3) recommended measures + the EPC attributes that triggered them. Maps the EPC API client (the user's blocker), the calculator-error ingredients (parity_report scaffolding), and each generator's exact trigger fields. Co-Authored-By: Claude Opus 4.8 --- docs/HANDOVER_API_FETCH_AND_REPORT.md | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/HANDOVER_API_FETCH_AND_REPORT.md 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. From ecebb07c9e732bdeeb41ee38a21b85215a3daa7e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:06:57 +0000 Subject: [PATCH 086/190] feat(modelling): calculator-error per property (lodged vs calculated SAP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 1 of the property inspection report: PropertyReport compares the cert's lodged energy_rating_current to Sap10Calculator's un-rounded SAP and flags |Δ| > 0.5 (the ADR-0010/0013 shadow-validation design target). A mapping/scoring raise is captured per-cert as calculator_error, never propagated, so one bad cert can't abort the sweep. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 80 ++++++++++++++++++++++++++++++++++++ tests/harness/test_report.py | 68 ++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 harness/report.py create mode 100644 tests/harness/test_report.py diff --git a/harness/report.py b/harness/report.py new file mode 100644 index 00000000..03e61681 --- /dev/null +++ b/harness/report.py @@ -0,0 +1,80 @@ +"""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 Final, Optional + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator + +# 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 PropertyReport: + """One property's inspection result. `calculator_error` records a raise + from mapping or scoring the cert (then the SAP figures are None).""" + + name: str + lodged_sap: Optional[int] + calculated_sap: Optional[float] + calculator_error: Optional[str] = None + + @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 build_property_report(path: Path) -> PropertyReport: + """Build one `PropertyReport` from an API-shaped EPC JSON file, comparing + its lodged SAP to our calculator's. A mapping/scoring raise is captured as + `calculator_error` rather than propagated.""" + 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}", + ) + return PropertyReport( + name=name, + lodged_sap=lodged_sap, + calculated_sap=calculated_sap, + ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py new file mode 100644 index 00000000..cfcaa705 --- /dev/null +++ b/tests/harness/test_report.py @@ -0,0 +1,68 @@ +"""Per-property inspection report over a dump of API-shaped EPC JSONs.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from harness.report import PropertyReport, build_property_report + +_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" + + +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_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 From 2b04dddb063be6ed37df51f6f7299fcbde578899 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:10:43 +0000 Subject: [PATCH 087/190] feat(modelling): surface each fired measure's trigger attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 3 of the report: build_property_report now runs the Modelling stage and, for every Plan Measure, records the EPC attribute(s) that caused its generator to fire (MeasureTrigger) — wall_construction/insulation for cavity fill, roof thickness for loft, floor thickness/construction for floors, the absent mechanical kind for ventilation. Modelling raises are captured as plan_error, independent of the calculator-error capture. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 106 +++++++++++++++++++++++++++++++++-- tests/harness/test_report.py | 65 ++++++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/harness/report.py b/harness/report.py index 03e61681..4a21a73b 100644 --- a/harness/report.py +++ b/harness/report.py @@ -22,25 +22,47 @@ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path -from typing import Final, Optional +from typing import Any, Final, 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 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).""" + 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]: @@ -57,10 +79,58 @@ class PropertyReport: return error is not None and abs(error) > SAP_ERROR_THRESHOLD -def build_property_report(path: Path) -> PropertyReport: - """Build one `PropertyReport` from an API-shaped EPC JSON file, comparing - its lodged SAP to our calculator's. A mapping/scoring raise is captured as - `calculator_error` rather than propagated.""" +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} + 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())) @@ -73,8 +143,32 @@ def build_property_report(path: Path) -> PropertyReport: 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, ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index cfcaa705..4f80eee4 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -5,7 +5,11 @@ from __future__ import annotations import json from pathlib import Path -from harness.report import PropertyReport, build_property_report +from harness.report import ( + MeasureTrigger, + PropertyReport, + build_property_report, +) _GOLDEN = ( Path(__file__).resolve().parents[1] @@ -18,6 +22,14 @@ _GOLDEN = ( _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 @@ -50,6 +62,54 @@ def test_calculator_error_flags_divergence_beyond_half_a_sap_point() -> None: 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 + triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) + assert set(triggers) == { + "cavity_wall_insulation", + "mechanical_ventilation", + "solid_floor_insulation", + } + # Cavity fill fired because the main wall is an uninsulated cavity. + assert triggers["cavity_wall_insulation"].triggers == { + "wall_construction": 4, + "wall_insulation_type": 4, + } + # Ventilation fired because the dwelling lodges no mechanical kind. + 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", + } + + +def test_single_measure_cert_surfaces_only_that_measures_trigger() -> None: + # Arrange + path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json" + + # Act + report: PropertyReport = build_property_report(path) + + # Assert — 0036 fires the solid-floor measure alone. + triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) + assert set(triggers) == {"solid_floor_insulation"} + assert triggers["solid_floor_insulation"].triggers == { + "floor_insulation_thickness": None, + "floor_construction_type": "Solid", + } + + 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" @@ -66,3 +126,6 @@ def test_unparseable_cert_is_captured_not_raised(tmp_path: Path) -> 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 == () From 5e4906dd704523b328c82f71239ea885def7612b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:13:04 +0000 Subject: [PATCH 088/190] feat(modelling): cohort builder + cohort-level ParityReport build_property_reports models a dump in order (errors captured per-cert); parity_report_for aggregates the lodged-vs-calculated SAP across the cohort into the existing ParityReport (MAE/RMSE/bias/worst-N), excluding certs that couldn't be mapped or scored. Residual convention is the calculator's own (predicted - actual), the negative of PropertyReport.sap_error. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 41 +++++++++++++++++++++++++++++- tests/harness/test_report.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/harness/report.py b/harness/report.py index 4a21a73b..28af881d 100644 --- a/harness/report.py +++ b/harness/report.py @@ -22,7 +22,7 @@ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path -from typing import Any, Final, Optional +from typing import Any, Final, Iterable, Optional from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, @@ -32,6 +32,11 @@ from datatypes.epc.domain.epc_property_data import ( 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 @@ -172,3 +177,37 @@ def build_property_report( 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) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 4f80eee4..e60d95ff 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -5,10 +5,13 @@ 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, + parity_report_for, ) _GOLDEN = ( @@ -110,6 +113,51 @@ def test_single_measure_cert_surfaces_only_that_measures_trigger() -> None: } +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_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" From 1c00708ecdb049c99ed3eb71263b8cf42d9440fd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:15:12 +0000 Subject: [PATCH 089/190] feat(modelling): render the three-section inspection report as Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format_report_markdown emits: (1) cohort parity stats + a per-property lodged-vs-calculated table flagging |Δ| > 0.5 (errors shown inline), (2) Plans + costings (SAP/band jump, cost + contingency, bill & CO2 savings, valuation uplift), (3) each fired measure with the EPC attributes that triggered it. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 119 +++++++++++++++++++++++++++++++++++ tests/harness/test_report.py | 30 +++++++++ 2 files changed, 149 insertions(+) diff --git a/harness/report.py b/harness/report.py index 28af881d..cfa8d570 100644 --- a/harness/report.py +++ b/harness/report.py @@ -211,3 +211,122 @@ def parity_report_for(reports: Iterable[PropertyReport]) -> ParityReport: 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" diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index e60d95ff..9f84c905 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -11,6 +11,7 @@ from harness.report import ( PropertyReport, build_property_report, build_property_reports, + format_report_markdown, parity_report_for, ) @@ -158,6 +159,35 @@ def test_cohort_parity_report_excludes_unscorable_certs() -> None: 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_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" From ae267070b19a6c6468103bfb7ad7754240fbc3c0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:16:52 +0000 Subject: [PATCH 090/190] feat(modelling): flat per-property CSV for the inspection report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format_report_csv emits one comma-safe row per property: the calculator-error fields (lodged/calculated/Δ/flag), the Plan headline figures (baseline+post SAP/band, measures, cost+contingency, bill & CO2 savings, valuation %), the flattened measure triggers, and any captured error — sortable in a spreadsheet for a large dump. Co-Authored-By: Claude Opus 4.8 --- harness/report.py | 62 ++++++++++++++++++++++++++++++++++++ tests/harness/test_report.py | 35 ++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/harness/report.py b/harness/report.py index cfa8d570..df36a05c 100644 --- a/harness/report.py +++ b/harness/report.py @@ -330,3 +330,65 @@ def format_report_markdown(reports: list[PropertyReport]) -> str: *_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/tests/harness/test_report.py b/tests/harness/test_report.py index 9f84c905..0e54350f 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -11,6 +11,7 @@ from harness.report import ( PropertyReport, build_property_report, build_property_reports, + format_report_csv, format_report_markdown, parity_report_for, ) @@ -188,6 +189,40 @@ def test_markdown_renders_the_three_sections(tmp_path: Path) -> None: 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" From ea3af8d2f4688a78d7db8723de36a709f5990bc1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:26:17 +0000 Subject: [PATCH 091/190] feat(modelling): CLI to fetch an EPC dump + build the inspection report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_property_report builds the three-section Markdown+CSV report over a dir of API-shaped EPC JSON, offline (defaults to the golden 57: 57/57 scorable, MAE 0.54, 6 flagged |Δ|>0.5). fetch_epc_dump pulls raw cert JSON from the live API by --uprn/--postcode (picking the latest cert per match, skipping existing files), mirroring fetch_cohort2's proven HTTP shape and reading OPEN_EPC_API_TOKEN. Report artifacts + epc_dump/ are gitignored. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + scripts/fetch_epc_dump.py | 153 +++++++++++++++++++++++++++++++++ scripts/run_property_report.py | 70 +++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 scripts/fetch_epc_dump.py create mode 100644 scripts/run_property_report.py diff --git a/.gitignore b/.gitignore index a1bd9c0b..a48af48a 100644 --- a/.gitignore +++ b/.gitignore @@ -283,6 +283,9 @@ 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 +/epc_dump/ *.xlsx # *.pdf **/Chunks/ 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_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() From cf8e5b9ec63297961a142b51eb2b7b5397e833c5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 12:16:18 +0000 Subject: [PATCH 092/190] feat(modelling): read the gov EPC bulk export via HTTP range requests The bulk endpoint 302-redirects to a 15.7 GB S3 ZIP with one NDJSON member per year; each line wraps the per-cert payload in a stringified 'document' that parses to the same RdSAP-Schema-21.0.1 shape from_api_response already handles. parse_bulk_line unwraps a record; is_sap_version filters to SAP 10.2; RangeFile exposes the S3 object as a seekable file so zipfile streams a single year's member (and a sampler stops early) without downloading the whole archive. Co-Authored-By: Claude Opus 4.8 --- harness/epc_bulk.py | 92 ++++++++++++++++++++++++++++++++++ tests/harness/test_epc_bulk.py | 43 ++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 harness/epc_bulk.py create mode 100644 tests/harness/test_epc_bulk.py diff --git a/harness/epc_bulk.py b/harness/epc_bulk.py new file mode 100644 index 00000000..84ccc313 --- /dev/null +++ b/harness/epc_bulk.py @@ -0,0 +1,92 @@ +"""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) + + 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) + return data + + def close(self) -> None: + self._client.close() + super().close() 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 From afabfa0147815b12c4e173f438ad4af17f55f9b3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 12:20:57 +0000 Subject: [PATCH 093/190] feat(modelling): sample a year from the EPC bulk export, offline-ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_epc_bulk_sample streams certificates-.json out of the bulk ZIP via range requests, keeps the first N SAP-version matches, and writes each cert's inner document to /.json for run_property_report. Stops after N, so only the member prefix transfers, not the 15.7 GB archive (RangeFile.bytes_read reports the true transfer vs the absolute ZIP offset). Verified on 2026: 100 SAP-10.2 certs -> report ran 81 scorable (MAE 2.03), 46 flagged, 19 raises (11 full-SAP schema 19.1.0, 7 unmapped floor_construction 0/3, 1 missing post_town) — real shadow-validation signal vs the curated golden 57. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 +- harness/epc_bulk.py | 4 + scripts/fetch_epc_bulk_sample.py | 127 +++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 scripts/fetch_epc_bulk_sample.py diff --git a/.gitignore b/.gitignore index a48af48a..e913b95c 100644 --- a/.gitignore +++ b/.gitignore @@ -285,7 +285,7 @@ cache/ !datatypes/epc/domain/epc_codes.csv # Generated property-inspection report artifacts (and any fetched EPC dump). property_report.md -/epc_dump/ +epc_dump*/ *.xlsx # *.pdf **/Chunks/ diff --git a/harness/epc_bulk.py b/harness/epc_bulk.py index 84ccc313..83b8e541 100644 --- a/harness/epc_bulk.py +++ b/harness/epc_bulk.py @@ -56,6 +56,9 @@ class RangeFile(io.RawIOBase): 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 @@ -85,6 +88,7 @@ class RangeFile(io.RawIOBase): resp.raise_for_status() data: bytes = resp.content self._pos += len(data) + self.bytes_read += len(data) return data def close(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()) From 68aa80c1740c29e65dc91cdaa46cc4f6b299ef78 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 15:11:26 +0000 Subject: [PATCH 094/190] feat(modelling): overlay models solid-wall insulation (IWI/EWI), pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of solid-wall insulation. BuildingPartOverlay gains a wall_insulation_thickness field; the generic applicator already folds it onto SapBuildingPart by name. With wall_insulation_type=1 (EWI) / 3 (IWI) + 100 mm, the calculator derives the post-insulation U-value (§5.8 documentary path, λ=0.04 default) — and for IWI also lowers the thermal-mass parameter. Two new Elmhurst before/after cascade pins (solid-brick EWI + IWI, cert 001431) reproduce the re-lodged after at abs(diff) <= 1e-4 across SAP/CO2/PE. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/simulation.py | 4 ++ .../fixtures/solid_brick_ewi_001431_after.pdf | Bin 0 -> 79958 bytes .../solid_brick_ewi_001431_before.pdf | Bin 0 -> 66803 bytes .../fixtures/solid_brick_iwi_001431_after.pdf | Bin 0 -> 80090 bytes .../solid_brick_iwi_001431_before.pdf | Bin 0 -> 66803 bytes .../modelling/test_elmhurst_cascade_pins.py | 58 +++++++++++++++++- 6 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/domain/modelling/fixtures/solid_brick_ewi_001431_after.pdf create mode 100644 tests/domain/modelling/fixtures/solid_brick_ewi_001431_before.pdf create mode 100644 tests/domain/modelling/fixtures/solid_brick_iwi_001431_after.pdf create mode 100644 tests/domain/modelling/fixtures/solid_brick_iwi_001431_before.pdf diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index c39d960e..deafd66c 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -22,6 +22,10 @@ class BuildingPartOverlay: """ 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 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 0000000000000000000000000000000000000000..566c1e798a876528564a61821521c4b14936cd87 GIT binary patch literal 79958 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3d0c7396afcbff33559567de66e612b96dc9d3e2 GIT binary patch literal 66803 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