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