Model/harness/report.py
Khalim Conn-Kowlessar ecebb07c9e feat(modelling): calculator-error per property (lodged vs calculated SAP)
Section 1 of the property inspection report: PropertyReport compares the
cert's lodged energy_rating_current to Sap10Calculator's un-rounded SAP and
flags |Δ| > 0.5 (the ADR-0010/0013 shadow-validation design target). A
mapping/scoring raise is captured per-cert as calculator_error, never
propagated, so one bad cert can't abort the sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:06:57 +00:00

80 lines
3.2 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 Final, Optional
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
# 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 PropertyReport:
"""One property's inspection result. `calculator_error` records a raise
from mapping or scoring the cert (then the SAP figures are None)."""
name: str
lodged_sap: Optional[int]
calculated_sap: Optional[float]
calculator_error: Optional[str] = None
@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 build_property_report(path: Path) -> PropertyReport:
"""Build one `PropertyReport` from an API-shaped EPC JSON file, comparing
its lodged SAP to our calculator's. A mapping/scoring raise is captured as
`calculator_error` rather than propagated."""
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}",
)
return PropertyReport(
name=name,
lodged_sap=lodged_sap,
calculated_sap=calculated_sap,
)