Model/harness/report.py
Khalim Conn-Kowlessar 2b04dddb06 feat(modelling): surface each fired measure's trigger attributes
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>
2026-06-04 11:10:43 +00:00

174 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
)