mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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 <noreply@anthropic.com>
174 lines
6.7 KiB
Python
174 lines
6.7 KiB
Python
"""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 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);
|
||
`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]:
|
||
"""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 _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()))
|
||
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}",
|
||
)
|
||
|
||
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,
|
||
)
|