From ecebb07c9e732bdeeb41ee38a21b85215a3daa7e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:06:57 +0000 Subject: [PATCH] feat(modelling): calculator-error per property (lodged vs calculated SAP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- harness/report.py | 80 ++++++++++++++++++++++++++++++++++++ tests/harness/test_report.py | 68 ++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 harness/report.py create mode 100644 tests/harness/test_report.py diff --git a/harness/report.py b/harness/report.py new file mode 100644 index 00000000..03e61681 --- /dev/null +++ b/harness/report.py @@ -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, + ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py new file mode 100644 index 00000000..cfcaa705 --- /dev/null +++ b/tests/harness/test_report.py @@ -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