Model the override-folded Effective EPC in the modelling e2e script 🟩

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) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-16 17:38:55 +00:00
parent 4c038ae8dc
commit 7e6974d95e
2 changed files with 130 additions and 1 deletions

View file

@ -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.

View file

@ -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,