mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
This commit is contained in:
parent
77e29ac2f8
commit
ecebb07c9e
2 changed files with 148 additions and 0 deletions
80
harness/report.py
Normal file
80
harness/report.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""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,
|
||||
)
|
||||
68
tests/harness/test_report.py
Normal file
68
tests/harness/test_report.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Per-property inspection report over a dump of API-shaped EPC JSONs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from harness.report import PropertyReport, build_property_report
|
||||
|
||||
_GOLDEN = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "domain/sap10_calculator/rdsap/fixtures/golden"
|
||||
)
|
||||
|
||||
# Two real golden certs straddling the |Δ| > 0.5 calculator-error flag:
|
||||
# 0036 — lodged 63, calculated 62.747 -> Δ 0.253 (not flagged)
|
||||
# 0240 — lodged 73, calculated 71.727 -> Δ 1.273 (flagged)
|
||||
_WITHIN_TOLERANCE = "0036-6325-1100-0063-1226"
|
||||
_DIVERGENT = "0240-0200-5706-2365-8010"
|
||||
|
||||
|
||||
def test_calculator_error_is_lodged_minus_calculated_and_within_tolerance() -> None:
|
||||
# Arrange
|
||||
path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json"
|
||||
|
||||
# Act
|
||||
report: PropertyReport = build_property_report(path)
|
||||
|
||||
# Assert — lodged SAP read straight off the cert; calculated un-rounded.
|
||||
assert report.lodged_sap == 63
|
||||
assert report.calculated_sap is not None
|
||||
assert abs(report.calculated_sap - 62.747) <= 0.01
|
||||
assert report.sap_error is not None
|
||||
assert abs(report.sap_error - (63 - report.calculated_sap)) <= 1e-9
|
||||
assert report.sap_error_exceeds_threshold is False
|
||||
assert report.calculator_error is None
|
||||
|
||||
|
||||
def test_calculator_error_flags_divergence_beyond_half_a_sap_point() -> None:
|
||||
# Arrange
|
||||
path: Path = _GOLDEN / f"{_DIVERGENT}.json"
|
||||
|
||||
# Act
|
||||
report: PropertyReport = build_property_report(path)
|
||||
|
||||
# Assert — Δ 1.273 > 0.5, so the shadow-validation flag fires.
|
||||
assert report.lodged_sap == 73
|
||||
assert report.sap_error is not None
|
||||
assert report.sap_error > 0.5
|
||||
assert report.sap_error_exceeds_threshold is True
|
||||
|
||||
|
||||
def test_unparseable_cert_is_captured_not_raised(tmp_path: Path) -> None:
|
||||
# Arrange — a payload the mapper rejects must not abort the report.
|
||||
bad: Path = tmp_path / "broken.json"
|
||||
bad.write_text(json.dumps({"not": "an epc"}))
|
||||
|
||||
# Act
|
||||
report: PropertyReport = build_property_report(bad)
|
||||
|
||||
# Assert — the raise is recorded as this property's calculator error.
|
||||
assert report.name == "broken"
|
||||
assert report.lodged_sap is None
|
||||
assert report.calculated_sap is None
|
||||
assert report.sap_error is None
|
||||
assert report.sap_error_exceeds_threshold is False
|
||||
assert report.calculator_error is not None
|
||||
assert "ValueError" in report.calculator_error
|
||||
Loading…
Add table
Reference in a new issue