mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
4c038ae8dc
commit
7e6974d95e
2 changed files with 130 additions and 1 deletions
82
docs/adr/0032-landlord-override-epc-overlay.md
Normal file
82
docs/adr/0032-landlord-override-epc-overlay.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue