mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
80 lines
3.2 KiB
Python
80 lines
3.2 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 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,
|
||
)
|