From 7e6974d95efbf18fa8ccf624891a11bb141451c1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 16 Jun 2026 17:38:55 +0000 Subject: [PATCH] =?UTF-8?q?Model=20the=20override-folded=20Effective=20EPC?= =?UTF-8?q?=20in=20the=20modelling=20e2e=20script=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes run_modelling through prop.effective_epc and dumps each target's property_overrides before the run, so a landlord wall override moves the calculated SAP. Records the overlay design in ADR-0032. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr/0032-landlord-override-epc-overlay.md | 82 +++++++++++++++++++ scripts/run_modelling_e2e.py | 49 ++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0032-landlord-override-epc-overlay.md diff --git a/docs/adr/0032-landlord-override-epc-overlay.md b/docs/adr/0032-landlord-override-epc-overlay.md new file mode 100644 index 00000000..cfb7c3a5 --- /dev/null +++ b/docs/adr/0032-landlord-override-epc-overlay.md @@ -0,0 +1,82 @@ +# Landlord-Override EPC overlay (`epc_with_overlay`) + +When a Property has a lodged EPC **and** resolved Landlord Overrides, the +Effective EPC is the lodged EPC with those overrides **applied as a simulation**, +so the SAP10 calculator scores a picture that reflects what the landlord knows +beyond the cert (typically before a survey). This records *how* that overlay +works and refines ADR-0031 decision 3, which deferred it ("Landlord Overrides +overlay is a later slice"). Terms in CONTEXT.md (Effective EPC, Landlord +Overrides, Rebaselining, Validation Cohort). + +## Status + +Accepted (design). Refines ADR-0031 dec-3. Implementation: tracer slice +(`wall_type`) first, coverage expanded per component. + +## Decisions + +### 1. A Landlord Override is a Simulation Overlay, folded onto the lodged EPC + +The overlay reuses the existing `EpcSimulation` / `overlay_applicator` machinery +that every modelling Measure already uses to mutate an `EpcPropertyData` +(`domain/modelling/simulation.py`). "The landlord says the wall is now insulated" +and "a Measure insulates the wall" become **one code path**: a resolved override +maps to an `EpcSimulation`, and `effective_epc`'s `epc_with_overlay` branch folds +it onto the lodged `epc`. CONTEXT.md already frames overrides as "a simulation on +the `EpcPropertyData`" — this makes that literal. Rejected: bespoke field-writing +onto a copied `EpcPropertyData`, which would duplicate the fold and invent a +second way to mutate an EPC. + +### 2. The Property's override slot holds domain overlays; the repository maps to them + +`Property` gains a landlord-overrides slot typed as **domain** `EpcSimulation`s, +not the repository's `ResolvedPropertyOverrides` (which would make `domain/` +import `repositories/` and invert the dependency). `PropertyPostgresRepository` +hydrates the slot by reading the faithful value-space snapshot +(`PropertyOverridesReader`) and mapping each override value → an `EpcSimulation` — +the same place it already hydrates `epc` / `predicted_epc`. + +### 3. The fold is partial and per-component + +`property_overrides` is partial — only the components a portfolio actually +supplied are present (a Property may carry `property_type` + `wall_type` but no +`roof_type`). The overlay applies **only** the overlays derived from rows that +exist; every other field stays lodged-as-is. This falls out of the faithful +reader, which returns only the rows present. + +### 4. `WallType` / `RoofType` decompose into SAP int codes — the calculator reads codes, not descriptions + +The SAP calculator derives the wall U-value from the `wall_construction` and +`wall_insulation_type` **int codes**, never from `walls[].description` +(`cert_to_inputs.py`). So a `wall_type` override only moves the score once +translated into those codes. The translation is tractable because a `WallType` +value is *material × insulation state*: material → `wall_construction` (codes 1-5) +and state → `wall_insulation_type` (1 external, 3 internal, 4 as-built, 7 filled), +the code sets already documented in `solid_wall_recommendation.py`. The override's +`building_part` maps directly to the overlay's `BuildingPartIdentifier` +(0 → MAIN, 1 → EXTENSION_1, …). `property_type` / `built_form_type` carry no SAP +weight at this layer (geometry is already explicit) — they overlay as metadata +only. + +### 5. An overlaid Property is excluded from the Validation Cohort — on divergence, not source path + +Producing a *more accurate* score than the cert is the **point**: the calculated +Effective Performance deliberately diverges from the Lodged Performance (which is +preserved). That divergence means an overlaid Property is no longer a clean +`calc(effective_epc)`-vs-lodged control, so it is excluded from the Validation +Cohort — exactly as a predicted-source Property is. The exclusion signal is +**divergence** ("≥1 override was applied", the overlay slot is non-empty), **not** +`source_path`: `epc_with_overlay` also covers plain-EPC Properties with no +overrides, which *are* valid validation targets. This reuses the same divergence +signal CONTEXT.md already names as a Rebaselining trigger. No Validation-Cohort +code path exists today, so this is a recorded rule, not code in this slice. + +## Consequences + +- `EpcPropertyData` is untouched — provenance stays structural on the Property + (ADR-0031 dec-3), and every downstream consumer is oblivious to the overlay. +- The override→`EpcSimulation` mapping is the real deliverable and grows per + component; `wall_type` is the tracer (highest SAP impact, existing code-set + prior art). `roof_type` and full `WallType` coverage follow. +- Precedence is unchanged (CONTEXT.md): the overlay lives entirely inside the + `epc_with_overlay` branch and never applies when Site Notes are the source. diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index 6ca59c76..097a3109 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -57,6 +57,13 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402 +from domain.property.property import Property, PropertyIdentity # noqa: E402 +from repositories.property.landlord_override_overlays import ( # noqa: E402 + overlays_from, +) +from repositories.property.property_overrides_postgres_reader import ( # noqa: E402 + PropertyOverridesPostgresReader, +) from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402 from domain.geospatial.spatial_reference import SpatialReference # noqa: E402 from domain.modelling.measure_type import MeasureType # noqa: E402 @@ -169,6 +176,27 @@ def _uprns_for(engine: Engine, property_ids: list[int]) -> dict[int, Optional[in return {int(pid): (int(uprn) if uprn is not None else None) for pid, uprn in rows} +def _dump_overrides(engine: Engine, property_ids: list[int]) -> None: + """Print each target Property's ``property_overrides`` rows (read-only), so the + Landlord Overrides folded into the Effective EPC are visible before modelling.""" + with engine.connect() as conn: + rows = conn.execute( + text( + "SELECT property_id, building_part, override_component, override_value " + "FROM property_overrides WHERE property_id = ANY(:ids) " + "ORDER BY property_id, building_part, override_component" + ), + {"ids": property_ids}, + ).fetchall() + if not rows: + print("landlord overrides: none for the target propertie(s)\n") + return + print("landlord overrides (folded into the Effective EPC):") + for property_id, building_part, component, value in rows: + print(f" property {property_id} · part {building_part} · {component} = {value}") + print() + + def _scenario_for(session: Session, scenario_id: int) -> Scenario: """Read the Scenario the run targets (read-only). An Increasing-EPC Scenario must carry a ``goal_value`` (band) — the old null-band rows were a fixed bug @@ -309,6 +337,10 @@ def main() -> None: engine = _engine() considered = _parse_measures(args.measures) uprns = _uprns_for(engine, args.property_ids) + # Landlord Overrides are read from property_overrides and folded onto the lodged + # EPC to form the Effective EPC the calculator scores (ADR-0032). + overrides_reader = PropertyOverridesPostgresReader(lambda: Session(engine)) + _dump_overrides(engine, args.property_ids) # One read-only session for the live `material` catalogue, reused across the # batch so both store and no-store runs price against the same DB rows. catalogue_session = Session(engine) @@ -344,6 +376,21 @@ def main() -> None: epc: Optional[EpcPropertyData] = epc_client.get_by_uprn(uprn) if epc is None: raise ValueError(f"no EPC found for UPRN {uprn}") + # Fold any Landlord Overrides onto the lodged EPC; with none, the + # Effective EPC is the lodged EPC unchanged (ADR-0032). + overlaid_property = Property( + identity=PropertyIdentity( + portfolio_id=args.portfolio_id or 0, + postcode="", + address="", + uprn=uprn, + ), + epc=epc, + landlord_overrides=overlays_from( + overrides_reader.overrides_for(property_id) + ), + ) + effective_epc: EpcPropertyData = overlaid_property.effective_epc spatial: Optional[SpatialReference] = _spatial_for(geospatial, uprn) restrictions: PlanningRestrictions = ( spatial.restrictions if spatial is not None else PlanningRestrictions() @@ -352,7 +399,7 @@ def main() -> None: None if args.no_solar else _solar_insights_for(solar_client, spatial) ) plan: Plan = run_modelling( - epc, + effective_epc, goal_band=args.goal, planning_restrictions=restrictions, solar_insights=solar_insights,