Model/tests/harness/test_report.py
Khalim Conn-Kowlessar ae267070b1 feat(modelling): flat per-property CSV for the inspection report
format_report_csv emits one comma-safe row per property: the calculator-error
fields (lodged/calculated/Δ/flag), the Plan headline figures (baseline+post
SAP/band, measures, cost+contingency, bill & CO2 savings, valuation %), the
flattened measure triggers, and any captured error — sortable in a spreadsheet
for a large dump.

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

244 lines
8.9 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."""
from __future__ import annotations
import json
from pathlib import Path
from domain.sap10_calculator.validation.parity_report import ParityReport
from harness.report import (
MeasureTrigger,
PropertyReport,
build_property_report,
build_property_reports,
format_report_csv,
format_report_markdown,
parity_report_for,
)
_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"
# 0330 fires all three trigger kinds: an uninsulated cavity wall (cavity fill),
# its dependent mechanical ventilation, and an uninsulated solid floor.
_THREE_MEASURES = "0330-2249-8150-2326-4121"
def _triggers_by_measure(report: PropertyReport) -> dict[str, MeasureTrigger]:
return {trigger.measure_type: trigger for trigger in report.measure_triggers}
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_each_fired_measure_carries_the_attributes_that_triggered_it() -> None:
# Arrange
path: Path = _GOLDEN / f"{_THREE_MEASURES}.json"
# Act
report: PropertyReport = build_property_report(path)
# Assert — the Plan ran and every fired measure names its trigger fields.
assert report.plan is not None
assert report.plan_error is None
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
assert set(triggers) == {
"cavity_wall_insulation",
"mechanical_ventilation",
"solid_floor_insulation",
}
# Cavity fill fired because the main wall is an uninsulated cavity.
assert triggers["cavity_wall_insulation"].triggers == {
"wall_construction": 4,
"wall_insulation_type": 4,
}
# Ventilation fired because the dwelling lodges no mechanical kind.
assert triggers["mechanical_ventilation"].triggers == {
"mechanical_ventilation_kind": None,
}
# Solid-floor insulation fired off an uninsulated solid ground floor.
assert triggers["solid_floor_insulation"].triggers == {
"floor_insulation_thickness": None,
"floor_construction_type": "Solid",
}
def test_single_measure_cert_surfaces_only_that_measures_trigger() -> None:
# Arrange
path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json"
# Act
report: PropertyReport = build_property_report(path)
# Assert — 0036 fires the solid-floor measure alone.
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
assert set(triggers) == {"solid_floor_insulation"}
assert triggers["solid_floor_insulation"].triggers == {
"floor_insulation_thickness": None,
"floor_construction_type": "Solid",
}
def test_cohort_builder_models_each_path_capturing_errors(tmp_path: Path) -> None:
# Arrange — two real certs plus one the mapper rejects.
bad: Path = tmp_path / "broken.json"
bad.write_text(json.dumps({"not": "an epc"}))
paths: list[Path] = [
_GOLDEN / f"{_WITHIN_TOLERANCE}.json",
_GOLDEN / f"{_DIVERGENT}.json",
bad,
]
# Act
reports: list[PropertyReport] = build_property_reports(paths)
# Assert — one report per path, the bad one carrying its error.
assert [report.name for report in reports] == [
_WITHIN_TOLERANCE,
_DIVERGENT,
"broken",
]
assert reports[2].calculator_error is not None
def test_cohort_parity_report_excludes_unscorable_certs() -> None:
# Arrange — a within-tolerance cert, a divergent cert, and an unscorable one.
reports: list[PropertyReport] = [
PropertyReport(name="a", lodged_sap=63, calculated_sap=62.747),
PropertyReport(name="b", lodged_sap=73, calculated_sap=71.727),
PropertyReport(
name="c", lodged_sap=None, calculated_sap=None, calculator_error="boom"
),
]
# Act
parity: ParityReport = parity_report_for(reports)
# Assert — only the two scorable certs form parity cases; b is the worst.
assert parity.case_count == 2
assert parity.worst_cases[0].certificate_number == "b"
# ParityReport's residual is predicted actual (calculated lodged); we
# under-predict both certs, so the global bias is negative.
assert parity.global_bias < 0
expected_mae: float = (abs(63 - 62.747) + abs(73 - 71.727)) / 2
assert abs(parity.global_mae - expected_mae) <= 1e-9
def test_markdown_renders_the_three_sections(tmp_path: Path) -> None:
# Arrange — a measure-bearing within-tolerance cert, a flagged cert, and an
# unscorable one.
bad: Path = tmp_path / "broken.json"
bad.write_text(json.dumps({"not": "an epc"}))
reports: list[PropertyReport] = build_property_reports(
[
_GOLDEN / f"{_WITHIN_TOLERANCE}.json",
_GOLDEN / f"{_DIVERGENT}.json",
bad,
]
)
# Act
markdown: str = format_report_markdown(reports)
# Assert — the three sections are present.
assert "## 1. Calculator error" in markdown
assert "## 2. Plans + costings" in markdown
assert "## 3. Recommended measures" in markdown
# Section 1 carries the cohort parity stats and a flag on the divergent cert.
assert "MAE" in markdown
assert _DIVERGENT in markdown
assert "broken" in markdown # the unscorable cert still appears, as an error
# Section 3 explains a fired measure via its trigger fields.
assert "solid_floor_insulation" in markdown
assert "floor_construction_type" in markdown
def test_csv_has_one_row_per_property_with_flags_and_triggers(tmp_path: Path) -> None:
# Arrange
bad: Path = tmp_path / "broken.json"
bad.write_text(json.dumps({"not": "an epc"}))
reports: list[PropertyReport] = build_property_reports(
[
_GOLDEN / f"{_WITHIN_TOLERANCE}.json",
_GOLDEN / f"{_DIVERGENT}.json",
bad,
]
)
# Act
csv: str = format_report_csv(reports)
# Assert — header plus one row per property.
lines: list[str] = csv.splitlines()
assert lines[0].startswith("cert,")
assert len(lines) == len(reports) + 1
# Every data row is comma-safe (no row splits into extra columns).
column_count: int = len(lines[0].split(","))
assert all(len(line.split(",")) == column_count for line in lines[1:])
rows: dict[str, str] = {line.split(",")[0]: line for line in lines[1:]}
# The divergent cert carries the |Δ| > 0.5 flag and the within-tolerance one doesn't.
flag_index: int = lines[0].split(",").index("sap_error_flag")
assert rows[_DIVERGENT].split(",")[flag_index] == "1"
assert rows[_WITHIN_TOLERANCE].split(",")[flag_index] == "0"
# The measure-bearing cert flattens its triggers into the row.
assert "solid_floor_insulation" in rows[_WITHIN_TOLERANCE]
assert "floor_construction_type=Solid" in rows[_WITHIN_TOLERANCE]
# The unscorable cert keeps its error.
assert "ValueError" in rows["broken"]
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
# No Plan either — but it is recorded, not raised.
assert report.plan is None
assert report.measure_triggers == ()