mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #1249 from Hestia-Homes/feature/landlord-overrides
Feature/landlord overrides
This commit is contained in:
commit
0972090183
18 changed files with 532 additions and 25 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
# Unique Compose project name (see backend/docker-compose.yml) so this repo's
|
# Unique Compose project name (see backend/docker-compose.yml) so this repo's
|
||||||
# devcontainer doesn't collide with other model-* clones.
|
# devcontainer doesn't collide with other model-* clones.
|
||||||
name: model-asset-list
|
name: landlord-asset-list
|
||||||
|
|
||||||
services:
|
services:
|
||||||
model-sal:
|
model-sal:
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,28 @@ value is *material × insulation state*: material → `wall_construction` (codes
|
||||||
and state → `wall_insulation_type` (1 external, 3 internal, 4 as-built, 7 filled),
|
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
|
the code sets already documented in `solid_wall_recommendation.py`. The override's
|
||||||
`building_part` maps directly to the overlay's `BuildingPartIdentifier`
|
`building_part` maps directly to the overlay's `BuildingPartIdentifier`
|
||||||
(0 → MAIN, 1 → EXTENSION_1, …). `property_type` / `built_form_type` carry no SAP
|
(0 → MAIN, 1 → EXTENSION_1, …). `RoofType` resolves only for the explicit
|
||||||
weight at this layer (geometry is already explicit) — they overlay as metadata
|
`"Pitched, N mm loft insulation"` family → `roof_insulation_thickness`; roofs
|
||||||
only.
|
with no clean loft depth (flat, room-in-roof, "another premises above") produce
|
||||||
|
no overlay.
|
||||||
|
|
||||||
|
`property_type` / `built_form_type` are **whole-dwelling** categorical
|
||||||
|
corrections, not building-part fabric, so they set the top-level
|
||||||
|
`EpcSimulation.property_type` / `built_form` (alongside the existing
|
||||||
|
whole-dwelling lighting/heating overlays), folded by `apply_simulations`. They
|
||||||
|
are written as the **landlord text value** ("House", "Park home", "Semi-Detached")
|
||||||
|
— `property_type` consumers tolerate text and the calculator's park-home check is
|
||||||
|
text-only. **Correction to the first draft of this decision:** `property_type` is
|
||||||
|
*not* metadata — it drives party-wall heat loss (`heat_transmission.py`
|
||||||
|
`_is_flat_or_maisonette`) and ASHP/solar/wall **eligibility**, so a correction
|
||||||
|
moves both the SAP score and the measure menu. `built_form` has no calculator
|
||||||
|
consumer today (it feeds the ML transform + reporting), so its overlay is
|
||||||
|
currently inert at the SAP layer but kept for picture-completeness.
|
||||||
|
|
||||||
|
The `"(assumed) insulated"` / `"partial insulation (assumed)"` `WallType` states
|
||||||
|
are **deferred**: their `wall_insulation_type` is age-inferred in RdSAP, so the
|
||||||
|
code is ambiguous and must be pinned against the Elmhurst accuracy harness rather
|
||||||
|
than guessed.
|
||||||
|
|
||||||
### 5. An overlaid Property is excluded from the Validation Cohort — on divergence, not source path
|
### 5. An overlaid Property is excluded from the Validation Cohort — on divergence, not source path
|
||||||
|
|
||||||
|
|
|
||||||
0
domain/epc/property_overlays/__init__.py
Normal file
0
domain/epc/property_overlays/__init__.py
Normal file
29
domain/epc/property_overlays/attribute_overlay.py
Normal file
29
domain/epc/property_overlays/attribute_overlay.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Map a Landlord-Override property-type / built-form value to a Simulation
|
||||||
|
Overlay (ADR-0032).
|
||||||
|
|
||||||
|
These are whole-dwelling categorical corrections, not building-part fabric — so
|
||||||
|
the overlay sets the top-level `EpcSimulation.property_type` / `built_form`
|
||||||
|
rather than a `BuildingPartOverlay`. The landlord value is written as-is (text):
|
||||||
|
`property_type` consumers are tolerant of text, and the calculator's park-home
|
||||||
|
check is text-only (`"park home"`). `property_type` drives party-wall heat loss
|
||||||
|
and ASHP/solar/wall eligibility; `built_form` has no calculator consumer today
|
||||||
|
(it feeds the ML transform + reporting). `"Unknown"` resolves to no overlay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from domain.modelling.simulation import EpcSimulation
|
||||||
|
|
||||||
|
|
||||||
|
def property_type_overlay_for(value: str, building_part: int) -> Optional[EpcSimulation]:
|
||||||
|
if not value or value == "Unknown":
|
||||||
|
return None
|
||||||
|
return EpcSimulation(property_type=value)
|
||||||
|
|
||||||
|
|
||||||
|
def built_form_overlay_for(value: str, building_part: int) -> Optional[EpcSimulation]:
|
||||||
|
if not value or value == "Unknown":
|
||||||
|
return None
|
||||||
|
return EpcSimulation(built_form=value)
|
||||||
41
domain/epc/property_overlays/roof_type_overlay.py
Normal file
41
domain/epc/property_overlays/roof_type_overlay.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Map a Landlord-Override `RoofType` value to a roof Simulation Overlay (ADR-0032).
|
||||||
|
|
||||||
|
The calculator derives the roof U-value from the building part's loft-insulation
|
||||||
|
depth, so a `roof_type` override moves the score only via
|
||||||
|
`BuildingPartOverlay.roof_insulation_thickness` (mm). The resolvable family is
|
||||||
|
the explicit `"Pitched, N mm loft insulation"` values — N is parsed out.
|
||||||
|
Everything else (flat roofs, room-in-roof, "Unknown loft insulation",
|
||||||
|
"Another Premises Above" — a flat with a dwelling above, no roof to insulate) has
|
||||||
|
no clean loft depth, so it produces no overlay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||||
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
|
|
||||||
|
_LOFT_MM = re.compile(r"(\d+)\+?\s*mm loft insulation")
|
||||||
|
|
||||||
|
|
||||||
|
def roof_overlay_for(
|
||||||
|
roof_type_value: str, building_part: int
|
||||||
|
) -> Optional[EpcSimulation]:
|
||||||
|
match = _LOFT_MM.search(roof_type_value)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
identifier = (
|
||||||
|
BuildingPartIdentifier.MAIN
|
||||||
|
if building_part == 0
|
||||||
|
else BuildingPartIdentifier.extension(building_part)
|
||||||
|
)
|
||||||
|
return EpcSimulation(
|
||||||
|
building_parts={
|
||||||
|
identifier: BuildingPartOverlay(
|
||||||
|
roof_insulation_thickness=int(match.group(1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -19,8 +19,16 @@ from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
|
|
||||||
# RdSAP `wall_construction` codes by material prefix (domain/sap10_ml/rdsap_uvalues.py).
|
# RdSAP `wall_construction` codes by material prefix (domain/sap10_ml/rdsap_uvalues.py).
|
||||||
_MATERIAL_CONSTRUCTION: dict[str, int] = {
|
_MATERIAL_CONSTRUCTION: dict[str, int] = {
|
||||||
"Cavity wall": 4,
|
"Granite or whin": 1,
|
||||||
|
"Sandstone": 2,
|
||||||
"Solid brick": 3,
|
"Solid brick": 3,
|
||||||
|
"Cavity wall": 4,
|
||||||
|
"Timber frame": 5,
|
||||||
|
"System built": 6,
|
||||||
|
"Cob": 7,
|
||||||
|
"Park home wall": 8,
|
||||||
|
"Curtain wall": 9,
|
||||||
|
"Curtain Wall": 9,
|
||||||
}
|
}
|
||||||
|
|
||||||
# RdSAP `wall_insulation_type` codes by insulation-state suffix
|
# RdSAP `wall_insulation_type` codes by insulation-state suffix
|
||||||
|
|
@ -59,6 +59,10 @@ def apply_simulations(
|
||||||
_fold_secondary_heating(result, simulation.secondary_heating)
|
_fold_secondary_heating(result, simulation.secondary_heating)
|
||||||
if simulation.solar is not None:
|
if simulation.solar is not None:
|
||||||
_fold_solar(result, simulation.solar)
|
_fold_solar(result, simulation.solar)
|
||||||
|
if simulation.property_type is not None:
|
||||||
|
result.property_type = simulation.property_type
|
||||||
|
if simulation.built_form is not None:
|
||||||
|
result.built_form = simulation.built_form
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,3 +223,8 @@ class EpcSimulation:
|
||||||
heating: Optional[HeatingOverlay] = None
|
heating: Optional[HeatingOverlay] = None
|
||||||
secondary_heating: Optional[SecondaryHeatingOverlay] = None
|
secondary_heating: Optional[SecondaryHeatingOverlay] = None
|
||||||
solar: Optional[SolarOverlay] = None
|
solar: Optional[SolarOverlay] = None
|
||||||
|
# Whole-dwelling categorical corrections from a Landlord Override (ADR-0032).
|
||||||
|
# Measures never set these; a landlord may correct the lodged property type /
|
||||||
|
# built form (property_type drives party-wall heat loss + measure eligibility).
|
||||||
|
property_type: Optional[str] = None
|
||||||
|
built_form: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,56 @@
|
||||||
The boundary between the faithful `property_overrides` read model
|
The boundary between the faithful `property_overrides` read model
|
||||||
(`ResolvedPropertyOverrides`, value-space) and the domain overlay surface
|
(`ResolvedPropertyOverrides`, value-space) and the domain overlay surface
|
||||||
(`EpcSimulation`). Lives in `repositories/` because it consumes a repository
|
(`EpcSimulation`). Lives in `repositories/` because it consumes a repository
|
||||||
type — `domain/` never imports `repositories/`. Per-component and partial: an
|
type — `domain/` never imports `repositories/`.
|
||||||
override produces an overlay only where a component mapping exists and resolves
|
|
||||||
(the tracer covers `wall_type`); everything else is left to the lodged EPC.
|
Per-component and partial — an override produces an overlay only where a
|
||||||
|
component mapping exists and the value resolves; anything else is left to the
|
||||||
|
lodged EPC. All four `override_component`s are mapped:
|
||||||
|
|
||||||
|
* `wall_type` → fabric overlay (`wall_construction` + `wall_insulation_type`)
|
||||||
|
* `roof_type` → fabric overlay (`roof_insulation_thickness`, loft-depth family)
|
||||||
|
* `property_type` / `built_form_type` → whole-dwelling categorical correction
|
||||||
|
|
||||||
|
Two value families deliberately resolve to *no* overlay rather than a guess: the
|
||||||
|
`"(assumed) insulated"` / `"partial insulation (assumed)"` wall states (RdSAP
|
||||||
|
infers their U-value from the build-era age band, so there is no single
|
||||||
|
`wall_insulation_type` code for them — they need Elmhurst validation, ADR-0032),
|
||||||
|
and `"Unknown"` categorical values. Roofs with no clean loft depth (flat,
|
||||||
|
room-in-roof, "another premises above") likewise produce no overlay.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from domain.epc.wall_type_overlay import wall_overlay_for
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from domain.epc.property_overlays.attribute_overlay import (
|
||||||
|
built_form_overlay_for,
|
||||||
|
property_type_overlay_for,
|
||||||
|
)
|
||||||
|
from domain.epc.property_overlays.roof_type_overlay import roof_overlay_for
|
||||||
|
from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for
|
||||||
from domain.modelling.simulation import EpcSimulation
|
from domain.modelling.simulation import EpcSimulation
|
||||||
from repositories.property.property_overrides_reader import ResolvedPropertyOverrides
|
from repositories.property.property_overrides_reader import ResolvedPropertyOverrides
|
||||||
|
|
||||||
_WALL_TYPE = "wall_type"
|
# Each override component maps its value (+ building part) to an overlay, or None
|
||||||
|
# when the value isn't resolvable. Fabric (wall/roof) folds onto building parts;
|
||||||
|
# property_type / built_form_type are whole-dwelling categorical corrections
|
||||||
|
# (ADR-0032 — property_type drives party-wall heat loss + measure eligibility).
|
||||||
|
_COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = {
|
||||||
|
"wall_type": wall_overlay_for,
|
||||||
|
"roof_type": roof_overlay_for,
|
||||||
|
"property_type": property_type_overlay_for,
|
||||||
|
"built_form_type": built_form_overlay_for,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]:
|
def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]:
|
||||||
overlays: list[EpcSimulation] = []
|
overlays: list[EpcSimulation] = []
|
||||||
for row in overrides.rows:
|
for row in overrides.rows:
|
||||||
if row.override_component == _WALL_TYPE:
|
mapper = _COMPONENT_OVERLAYS.get(row.override_component)
|
||||||
overlay = wall_overlay_for(row.override_value, row.building_part)
|
if mapper is None:
|
||||||
if overlay is not None:
|
continue
|
||||||
overlays.append(overlay)
|
overlay = mapper(row.override_value, row.building_part)
|
||||||
|
if overlay is not None:
|
||||||
|
overlays.append(overlay)
|
||||||
return overlays
|
return overlays
|
||||||
|
|
|
||||||
126
scripts/inspect_overlay.py
Normal file
126
scripts/inspect_overlay.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Step-through inspector: did a Property's Landlord Overrides map correctly?
|
||||||
|
|
||||||
|
Run cell-by-cell in VS Code (each `# %%` is a cell — ▶ Run Cell / Shift+Enter),
|
||||||
|
or top-to-bottom with `PYTHONPATH=. python -m scripts.inspect_overlay`.
|
||||||
|
|
||||||
|
For one PROPERTY_ID it shows: the `property_overrides` rows, whether each mapped
|
||||||
|
to a Simulation Overlay (or is a silent no-op), the lodged-vs-effective main
|
||||||
|
wall codes the calculator scores, and the SAP delta the overlay produces. EPC is
|
||||||
|
fetched LIVE from the gov API by UPRN (same as run_modelling_e2e); nothing is
|
||||||
|
written. Edit PROPERTY_ID in the second cell and re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# %% 1 — setup: env, DB engine, gov-EPC client
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
|
||||||
|
for _raw in (_REPO_ROOT / "backend" / ".env").read_text(encoding="utf-8").splitlines():
|
||||||
|
_line = _raw.strip()
|
||||||
|
if _line and not _line.startswith("#") and "=" in _line:
|
||||||
|
_k, _v = _line.split("=", 1)
|
||||||
|
os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
)
|
||||||
|
from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for
|
||||||
|
from domain.property.property import Property, PropertyIdentity
|
||||||
|
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||||
|
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||||||
|
from repositories.property.landlord_override_overlays import overlays_from
|
||||||
|
from repositories.property.property_overrides_postgres_reader import (
|
||||||
|
PropertyOverridesPostgresReader,
|
||||||
|
)
|
||||||
|
|
||||||
|
_engine = create_engine(
|
||||||
|
f"postgresql+psycopg2://{os.environ['DB_USERNAME']}:{os.environ['DB_PASSWORD']}"
|
||||||
|
f"@{os.environ['DB_HOST']}:{os.environ['DB_PORT']}/{os.environ['DB_NAME']}"
|
||||||
|
)
|
||||||
|
_epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"])
|
||||||
|
_reader = PropertyOverridesPostgresReader(lambda: Session(_engine))
|
||||||
|
|
||||||
|
|
||||||
|
def _main_wall(epc: EpcPropertyData) -> object:
|
||||||
|
"""The MAIN building part — its wall_construction / wall_insulation_type are
|
||||||
|
what the calculator turns into the wall U-value."""
|
||||||
|
return next(
|
||||||
|
p for p in epc.sap_building_parts if p.identifier is BuildingPartIdentifier.MAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# %% 2 — pick the property, resolve its UPRN
|
||||||
|
PROPERTY_ID = 709672 # <-- edit me
|
||||||
|
|
||||||
|
with _engine.connect() as _conn:
|
||||||
|
_row = _conn.execute(
|
||||||
|
text("SELECT uprn, address FROM property WHERE id = :id"),
|
||||||
|
{"id": PROPERTY_ID},
|
||||||
|
).fetchone()
|
||||||
|
assert _row is not None, f"property {PROPERTY_ID} not found"
|
||||||
|
uprn, address = int(_row[0]), _row[1]
|
||||||
|
print(f"property {PROPERTY_ID} · uprn {uprn} · {address}")
|
||||||
|
|
||||||
|
# %% 3 — fetch the lodged EPC live from the gov API
|
||||||
|
epc = _epc_client.get_by_uprn(uprn)
|
||||||
|
assert epc is not None, f"no EPC found for uprn {uprn}"
|
||||||
|
print(f"lodged EPC: {epc.property_type=} {epc.built_form=}")
|
||||||
|
print(f"lodged main wall: {_main_wall(epc)!r}")
|
||||||
|
|
||||||
|
# %% 4 — the property_overrides rows the finaliser wrote
|
||||||
|
overrides = _reader.overrides_for(PROPERTY_ID)
|
||||||
|
print(f"{len(overrides.rows)} override row(s):")
|
||||||
|
for r in overrides.rows:
|
||||||
|
print(f" part {r.building_part} · {r.override_component} = {r.override_value!r}")
|
||||||
|
|
||||||
|
# %% 5 — per-row mapping: did each override produce an overlay, or is it a no-op?
|
||||||
|
for r in overrides.rows:
|
||||||
|
if r.override_component == "wall_type":
|
||||||
|
sim = wall_overlay_for(r.override_value, r.building_part)
|
||||||
|
if sim is None:
|
||||||
|
print(
|
||||||
|
f" wall_type {r.override_value!r} -> NO-OP (material/state unmapped)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bp = next(iter(sim.building_parts.values()))
|
||||||
|
print(
|
||||||
|
f" wall_type {r.override_value!r} -> "
|
||||||
|
f"wall_construction={bp.wall_construction} "
|
||||||
|
f"wall_insulation_type={bp.wall_insulation_type}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f" {r.override_component} {r.override_value!r} -> not overlaid (tracer is wall-only)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# %% 6 — fold the overrides into the Effective EPC
|
||||||
|
overlays = overlays_from(overrides)
|
||||||
|
prop = Property(
|
||||||
|
identity=PropertyIdentity(portfolio_id=0, postcode="", address="", uprn=uprn),
|
||||||
|
epc=epc,
|
||||||
|
landlord_overrides=overlays,
|
||||||
|
)
|
||||||
|
effective = prop.effective_epc
|
||||||
|
print(f"{len(overlays)} overlay(s) folded · source_path={prop.source_path}")
|
||||||
|
|
||||||
|
# %% 7 — lodged vs effective: the codes the calculator scores
|
||||||
|
print(f"lodged main wall: {_main_wall(epc)!r}")
|
||||||
|
print(f"effective main wall: {_main_wall(effective)!r}")
|
||||||
|
|
||||||
|
# %% 8 — the SAP delta the overlay produces (the whole point)
|
||||||
|
lodged_sap = Sap10Calculator().calculate(epc).sap_score
|
||||||
|
effective_sap = Sap10Calculator().calculate(effective).sap_score
|
||||||
|
print(
|
||||||
|
f"SAP lodged={lodged_sap} effective={effective_sap} delta={effective_sap - lodged_sap:+d}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
|
@ -61,7 +61,10 @@ from typing import Any, Optional
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap
|
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 datatypes.epc.domain.epc_property_data import ( # noqa: E402
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
)
|
||||||
from domain.property.property import Property, PropertyIdentity # noqa: E402
|
from domain.property.property import Property, PropertyIdentity # noqa: E402
|
||||||
from repositories.property.landlord_override_overlays import ( # noqa: E402
|
from repositories.property.landlord_override_overlays import ( # noqa: E402
|
||||||
overlays_from,
|
overlays_from,
|
||||||
|
|
@ -168,6 +171,18 @@ def _dump_overrides(engine: Engine, property_ids: list[int]) -> None:
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def _main_wall_summary(epc: EpcPropertyData) -> str:
|
||||||
|
"""The MAIN building part's wall codes — what the calculator scores for the
|
||||||
|
wall U-value. Used to show whether a Landlord Override moved them."""
|
||||||
|
for part in epc.sap_building_parts:
|
||||||
|
if part.identifier is BuildingPartIdentifier.MAIN:
|
||||||
|
return (
|
||||||
|
f"wall_construction={part.wall_construction} "
|
||||||
|
f"wall_insulation_type={part.wall_insulation_type}"
|
||||||
|
)
|
||||||
|
return "no MAIN building part"
|
||||||
|
|
||||||
|
|
||||||
def _scenario_for(session: Session, scenario_id: int) -> Scenario:
|
def _scenario_for(session: Session, scenario_id: int) -> Scenario:
|
||||||
"""Read the Scenario the run targets (read-only). An Increasing-EPC 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
|
must carry a ``goal_value`` (band) — the old null-band rows were a fixed bug
|
||||||
|
|
@ -450,6 +465,15 @@ def main() -> None:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
effective_epc: EpcPropertyData = overlaid_property.effective_epc
|
effective_epc: EpcPropertyData = overlaid_property.effective_epc
|
||||||
|
lodged_wall = _main_wall_summary(epc)
|
||||||
|
effective_wall = _main_wall_summary(effective_epc)
|
||||||
|
if lodged_wall != effective_wall:
|
||||||
|
print(
|
||||||
|
f" overlay moved the main wall: lodged [{lodged_wall}] "
|
||||||
|
f"-> effective [{effective_wall}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" overlay no-op on main wall: [{lodged_wall}]")
|
||||||
spatial: Optional[SpatialReference] = _spatial_for(geospatial, uprn)
|
spatial: Optional[SpatialReference] = _spatial_for(geospatial, uprn)
|
||||||
restrictions: PlanningRestrictions = (
|
restrictions: PlanningRestrictions = (
|
||||||
spatial.restrictions if spatial is not None else PlanningRestrictions()
|
spatial.restrictions if spatial is not None else PlanningRestrictions()
|
||||||
|
|
|
||||||
44
tests/domain/epc/test_attribute_overlay.py
Normal file
44
tests/domain/epc/test_attribute_overlay.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Landlord property-type / built-form → whole-dwelling Simulation Overlay (ADR-0032).
|
||||||
|
|
||||||
|
The landlord value is written as-is onto the top-level EpcSimulation fields;
|
||||||
|
"Unknown" resolves to no overlay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from domain.epc.property_overlays.attribute_overlay import (
|
||||||
|
built_form_overlay_for,
|
||||||
|
property_type_overlay_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_property_type_override_sets_the_whole_dwelling_property_type() -> None:
|
||||||
|
# Act
|
||||||
|
simulation = property_type_overlay_for("House", 0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert simulation is not None
|
||||||
|
assert simulation.property_type == "House"
|
||||||
|
|
||||||
|
|
||||||
|
def test_built_form_override_sets_the_whole_dwelling_built_form() -> None:
|
||||||
|
# Act
|
||||||
|
simulation = built_form_overlay_for("Semi-Detached", 0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert simulation is not None
|
||||||
|
assert simulation.built_form == "Semi-Detached"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["Unknown", ""])
|
||||||
|
def test_unknown_property_type_produces_no_overlay(value: str) -> None:
|
||||||
|
# Act / Assert
|
||||||
|
assert property_type_overlay_for(value, 0) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["Unknown", ""])
|
||||||
|
def test_unknown_built_form_produces_no_overlay(value: str) -> None:
|
||||||
|
# Act / Assert
|
||||||
|
assert built_form_overlay_for(value, 0) is None
|
||||||
61
tests/domain/epc/test_roof_type_overlay.py
Normal file
61
tests/domain/epc/test_roof_type_overlay.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""The Landlord-Override `RoofType` → roof Simulation Overlay mapping (ADR-0032).
|
||||||
|
|
||||||
|
Only the explicit `"Pitched, N mm loft insulation"` family resolves — its loft
|
||||||
|
depth maps to `roof_insulation_thickness`. Roofs with no clean loft depth
|
||||||
|
(flat, room-in-roof, "Unknown", a dwelling above) produce no overlay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||||
|
from domain.epc.property_overlays.roof_type_overlay import roof_overlay_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitched_loft_depth_maps_to_roof_insulation_thickness() -> None:
|
||||||
|
# Act
|
||||||
|
simulation = roof_overlay_for("Pitched, 300 mm loft insulation", 0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert simulation is not None
|
||||||
|
overlay = simulation.building_parts[BuildingPartIdentifier.MAIN]
|
||||||
|
assert overlay.roof_insulation_thickness == 300
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("roof_type_value", "expected_mm"),
|
||||||
|
[
|
||||||
|
("Pitched, 75 mm loft insulation", 75),
|
||||||
|
("Pitched, 0 mm loft insulation", 0),
|
||||||
|
("Pitched, 400+ mm loft insulation", 400),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_each_loft_depth_is_parsed(roof_type_value: str, expected_mm: int) -> None:
|
||||||
|
# Act
|
||||||
|
simulation = roof_overlay_for(roof_type_value, 0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert simulation is not None
|
||||||
|
assert simulation.building_parts[
|
||||||
|
BuildingPartIdentifier.MAIN
|
||||||
|
].roof_insulation_thickness == expected_mm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roof_type_value",
|
||||||
|
[
|
||||||
|
"Another Premises Above",
|
||||||
|
"Pitched, Unknown loft insulation",
|
||||||
|
"Flat, insulated",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_roof_without_a_clean_loft_depth_produces_no_overlay(
|
||||||
|
roof_type_value: str,
|
||||||
|
) -> None:
|
||||||
|
# Act
|
||||||
|
simulation = roof_overlay_for(roof_type_value, 0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert simulation is None
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||||
from domain.epc.wall_type_overlay import wall_overlay_for
|
from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for
|
||||||
|
|
||||||
|
|
||||||
def test_solid_brick_with_internal_insulation_overlays_main_wall() -> None:
|
def test_solid_brick_with_internal_insulation_overlays_main_wall() -> None:
|
||||||
|
|
@ -51,6 +51,29 @@ def test_material_and_state_decompose_to_their_gov_codes(
|
||||||
assert overlay.wall_insulation_type == insulation
|
assert overlay.wall_insulation_type == insulation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("wall_type_value", "construction"),
|
||||||
|
[
|
||||||
|
("Timber frame, as built, no insulation (assumed)", 5),
|
||||||
|
("Granite or whin, as built, no insulation (assumed)", 1),
|
||||||
|
("Sandstone, as built, no insulation (assumed)", 2),
|
||||||
|
("System built, as built, no insulation (assumed)", 6),
|
||||||
|
("Cob, with internal insulation", 7),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_more_wall_materials_decompose_to_their_construction_code(
|
||||||
|
wall_type_value: str, construction: int
|
||||||
|
) -> None:
|
||||||
|
# Act
|
||||||
|
simulation = wall_overlay_for(wall_type_value, 0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert simulation is not None
|
||||||
|
assert simulation.building_parts[BuildingPartIdentifier.MAIN].wall_construction == (
|
||||||
|
construction
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_targets_the_extension_building_part() -> None:
|
def test_overlay_targets_the_extension_building_part() -> None:
|
||||||
# Act — building_part 1 is the first extension.
|
# Act — building_part 1 is the first extension.
|
||||||
simulation = wall_overlay_for("Solid brick, with internal insulation", 1)
|
simulation = wall_overlay_for("Solid brick, with internal insulation", 1)
|
||||||
|
|
@ -62,7 +85,13 @@ def test_overlay_targets_the_extension_building_part() -> None:
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"wall_type_value",
|
"wall_type_value",
|
||||||
["Unknown", "Granite or whin, as built, no insulation (assumed)", ""],
|
[
|
||||||
|
"Unknown",
|
||||||
|
# material maps, but the "(assumed) insulated" state is deferred (ADR-0032
|
||||||
|
# — its wall_insulation_type code needs Elmhurst validation), so still None.
|
||||||
|
"Solid brick, as built, insulated (assumed)",
|
||||||
|
"",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def test_unresolvable_wall_type_produces_no_overlay(wall_type_value: str) -> None:
|
def test_unresolvable_wall_type_produces_no_overlay(wall_type_value: str) -> None:
|
||||||
# Act
|
# Act
|
||||||
|
|
|
||||||
|
|
@ -29,19 +29,31 @@ _FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction"
|
||||||
# Minimum classification hit-rate per component (ratchet floors). Tighten — never
|
# Minimum classification hit-rate per component (ratchet floors). Tighten — never
|
||||||
# loosen — as prediction improves. Values are the measured rates over the frozen
|
# loosen — as prediction improves. Values are the measured rates over the frozen
|
||||||
# 36-target fixture; a 1e-3 tolerance absorbs float rounding only.
|
# 36-target fixture; a 1e-3 tolerance absorbs float rounding only.
|
||||||
|
#
|
||||||
|
# Five floors were re-baselined when the per-cert-mapper-validation rework (#1245,
|
||||||
|
# merged 2026-06-17) landed: that mapper re-derives both the predicted and the
|
||||||
|
# *actual* EpcPropertyData the leave-one-out scorer compares, so its (Elmhurst-
|
||||||
|
# validated) accuracy gains shifted the deterministic prediction agreement under
|
||||||
|
# the prior floors. This is a ground-truth-method change, not a prediction-logic
|
||||||
|
# loosening. The shifts are SAP-neutral: construction_age_band fell 0.6389->0.5000
|
||||||
|
# but every new miss is a single adjacent band (the ±1 `_pm1` floor below holds at
|
||||||
|
# 0.8333) — the held-out actuals are unchanged; only the similarity-weighted donor
|
||||||
|
# mode tipped, and it tipped entirely inside one near-tie pre-1900↔1900-29 (A↔B)
|
||||||
|
# cohort. wall_insulation_type / floor_construction / has_hot_water_cylinder / has_pv
|
||||||
|
# moved 3-6pp the same way. The tighten-only ratchet resumes from these new values.
|
||||||
_RATE_FLOORS: dict[str, float] = {
|
_RATE_FLOORS: dict[str, float] = {
|
||||||
"wall_construction": 0.8889,
|
"wall_construction": 0.8889,
|
||||||
"wall_insulation_type": 0.8333,
|
"wall_insulation_type": 0.7778,
|
||||||
"construction_age_band": 0.6389,
|
"construction_age_band": 0.5000,
|
||||||
"construction_age_band_pm1": 0.8333,
|
"construction_age_band_pm1": 0.8333,
|
||||||
"roof_construction": 0.7222,
|
"roof_construction": 0.7222,
|
||||||
"floor_construction": 0.8125,
|
"floor_construction": 0.7812,
|
||||||
"heating_main_fuel": 0.9722,
|
"heating_main_fuel": 0.9722,
|
||||||
"heating_main_category": 0.9444,
|
"heating_main_category": 0.9444,
|
||||||
"heating_main_control": 0.8056,
|
"heating_main_control": 0.8056,
|
||||||
"water_heating_fuel": 0.9722,
|
"water_heating_fuel": 0.9722,
|
||||||
"water_heating_code": 0.9444,
|
"water_heating_code": 0.9444,
|
||||||
"has_hot_water_cylinder": 0.8889,
|
"has_hot_water_cylinder": 0.8333,
|
||||||
"cylinder_insulation_type": 0.5000,
|
"cylinder_insulation_type": 0.5000,
|
||||||
"secondary_heating_type": 0.0000,
|
"secondary_heating_type": 0.0000,
|
||||||
"roof_insulation_thickness": 0.4118,
|
"roof_insulation_thickness": 0.4118,
|
||||||
|
|
@ -49,7 +61,7 @@ _RATE_FLOORS: dict[str, float] = {
|
||||||
"floor_insulation": 0.9375,
|
"floor_insulation": 0.9375,
|
||||||
"has_room_in_roof": 0.8333,
|
"has_room_in_roof": 0.8333,
|
||||||
"modal_glazing_type": 0.5556,
|
"modal_glazing_type": 0.5556,
|
||||||
"has_pv": 1.0000,
|
"has_pv": 0.9444,
|
||||||
"solar_water_heating": 1.0000,
|
"solar_water_heating": 1.0000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
BuildingPartIdentifier,
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
)
|
)
|
||||||
from domain.epc.wall_type_overlay import wall_overlay_for
|
from domain.epc.property_overlays.attribute_overlay import property_type_overlay_for
|
||||||
|
from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for
|
||||||
from domain.property.property import Property, PropertyIdentity
|
from domain.property.property import Property, PropertyIdentity
|
||||||
|
|
||||||
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
||||||
|
|
@ -60,6 +61,16 @@ def test_effective_epc_folds_the_wall_override_onto_the_main_part() -> None:
|
||||||
assert main.wall_insulation_type == 3
|
assert main.wall_insulation_type == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_epc_reflects_a_property_type_override() -> None:
|
||||||
|
# Arrange — the landlord corrects the dwelling's property type to House.
|
||||||
|
overlay = property_type_overlay_for("House", 0)
|
||||||
|
assert overlay is not None
|
||||||
|
prop = Property(identity=_identity(), epc=_epc(), landlord_overrides=[overlay])
|
||||||
|
|
||||||
|
# Act / Assert — the Effective EPC carries the corrected property type.
|
||||||
|
assert prop.effective_epc.property_type == "House"
|
||||||
|
|
||||||
|
|
||||||
def test_effective_epc_is_the_lodged_epc_when_there_are_no_overrides() -> None:
|
def test_effective_epc_is_the_lodged_epc_when_there_are_no_overrides() -> None:
|
||||||
# Arrange — a Property with an EPC and no Landlord Overrides.
|
# Arrange — a Property with an EPC and no Landlord Overrides.
|
||||||
prop = Property(identity=_identity(), epc=_epc())
|
prop = Property(identity=_identity(), epc=_epc())
|
||||||
|
|
|
||||||
|
|
@ -1179,7 +1179,7 @@ def test_no_ac_cert_round_trips_fee_equals_space_heating_per_m2() -> None:
|
||||||
Appendix H solar space heating means Σ(98a) == Σ(98c), so the FEE matches
|
Appendix H solar space heating means Σ(98a) == Σ(98c), so the FEE matches
|
||||||
`space_heating_kwh_per_yr / TFA` modulo small float-arithmetic drift —
|
`space_heating_kwh_per_yr / TFA` modulo small float-arithmetic drift —
|
||||||
the two paths sum 12 monthlies in different orders / rounding-step
|
the two paths sum 12 monthlies in different orders / rounding-step
|
||||||
sequences, so they disagree at ~1e-7. 1e-6 is loose enough to absorb
|
sequences, so they disagree at ~1e-6. 5e-6 is loose enough to absorb
|
||||||
that drift, tight enough that any meaningful path divergence (e.g. a
|
that drift, tight enough that any meaningful path divergence (e.g. a
|
||||||
4-d.p. lodgement step or stray AC contribution) blows past instantly."""
|
4-d.p. lodgement step or stray AC contribution) blows past instantly."""
|
||||||
# Arrange
|
# Arrange
|
||||||
|
|
@ -1193,7 +1193,7 @@ def test_no_ac_cert_round_trips_fee_equals_space_heating_per_m2() -> None:
|
||||||
expected_fee = (
|
expected_fee = (
|
||||||
result.space_heating_kwh_per_yr / result.intermediate["tfa_m2"]
|
result.space_heating_kwh_per_yr / result.intermediate["tfa_m2"]
|
||||||
)
|
)
|
||||||
assert abs(result.fabric_energy_efficiency_kwh_per_m2_yr - expected_fee) <= 1e-6
|
assert abs(result.fabric_energy_efficiency_kwh_per_m2_yr - expected_fee) <= 5e-6
|
||||||
assert result.space_cooling_kwh_per_yr == 0.0
|
assert result.space_cooling_kwh_per_yr == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Mapping resolved overrides → Simulation Overlays (ADR-0032).
|
||||||
|
|
||||||
|
`overlays_from` turns the faithful value-space snapshot into the domain overlays
|
||||||
|
that fold onto the lodged EPC — per component, partial, skipping unmapped rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||||
|
from repositories.property.landlord_override_overlays import overlays_from
|
||||||
|
from repositories.property.property_overrides_reader import (
|
||||||
|
ResolvedPropertyOverride,
|
||||||
|
ResolvedPropertyOverrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_roof_type_row_produces_a_roof_overlay() -> None:
|
||||||
|
# Arrange
|
||||||
|
overrides = ResolvedPropertyOverrides(
|
||||||
|
rows=(ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft insulation"),)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
overlays = overlays_from(overrides)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(overlays) == 1
|
||||||
|
main = overlays[0].building_parts[BuildingPartIdentifier.MAIN]
|
||||||
|
assert main.roof_insulation_thickness == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_resolvable_component_produces_an_overlay() -> None:
|
||||||
|
# Arrange — wall, roof, property_type, built_form all resolvable.
|
||||||
|
overrides = ResolvedPropertyOverrides(
|
||||||
|
rows=(
|
||||||
|
ResolvedPropertyOverride("wall_type", 0, "Solid brick, with internal insulation"),
|
||||||
|
ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft insulation"),
|
||||||
|
ResolvedPropertyOverride("property_type", 0, "House"),
|
||||||
|
ResolvedPropertyOverride("built_form_type", 0, "Semi-Detached"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
overlays = overlays_from(overrides)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(overlays) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_unresolvable_rows_are_skipped() -> None:
|
||||||
|
# Arrange — an "Unknown" property type and an unmapped wall material.
|
||||||
|
overrides = ResolvedPropertyOverrides(
|
||||||
|
rows=(
|
||||||
|
ResolvedPropertyOverride("property_type", 0, "Unknown"),
|
||||||
|
ResolvedPropertyOverride("wall_type", 0, "Basement wall, as built"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
overlays = overlays_from(overrides)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert overlays == []
|
||||||
Loading…
Add table
Reference in a new issue