Model/tests/harness/test_report.py
Daniel Roth 10c975b1ae Fix test asserting None for cfl_fixed_lighting_bulbs_count on 21.0.1 cert 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:31:02 +00:00

313 lines
12 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,
)
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
)
_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
# The gain-maximising package: the efficient representative ASHP (ADR-0025)
# plus solid-floor insulation. The cavity wall + its forced mechanical
# ventilation (ADR-0016) are NOT selected — the wall earns +SAP alone but
# the forced-ventilation penalty makes the pair net-negative, so the
# Optimiser correctly leaves them out (see test_measure_dependency /
# test_optimiser for the forced-edge unit coverage; cavity_wall +
# mechanical_ventilation trigger fields are exercised in
# test_cavity_wall_recommendation / the ventilation generator tests).
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
assert set(triggers) == {
"solid_floor_insulation",
"air_source_heat_pump",
}
# Solid-floor insulation fired off an uninsulated solid ground floor.
assert triggers["solid_floor_insulation"].triggers == {
"floor_insulation_thickness": None,
"floor_construction_type": "Solid",
}
# The ASHP bundle fired off the gas-dwelling main it replaces.
assert triggers["air_source_heat_pump"].triggers == {
"property_type": "0",
"main_heating_category": 2,
}
def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None:
# No golden API cert selects the boiler upgrade (it competes with — and on
# houses loses to — the ASHP bundle within the one heating Recommendation),
# so the trigger branch is exercised directly, like the cert_to_inputs unit
# tests of internal helpers.
from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage]
# Arrange — a mains-gas wet boiler (SAP code 114) with a hot-water cylinder:
# the boiler-upgrade eligibility attributes the report should explain.
epc = parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf")
# Act
triggers = _triggers_for(epc, "gas_boiler_upgrade")
# Assert — the wet-boiler SAP code, the mains-gas connection that makes the
# gas end-state installable, and the cylinder that shapes the bundle.
assert triggers == {
"sap_main_heating_code": 114,
"mains_gas": True,
"has_hot_water_cylinder": True,
}
def test_secondary_heating_removal_surfaces_its_eligibility_triggers() -> None:
# No golden API cert selects secondary-heating removal, so the trigger branch
# is exercised directly. The generator fires on any lodged secondary, so the
# lodged SAP code is what the report should explain (ADR-0028).
from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage]
# Arrange — a parseable 001431 cert with a secondary heating system lodged
# (SAP code 691, electric panel/convector/radiant heaters).
epc = parse_recommendation_summary("cavity_wall_001431_before.pdf")
epc.sap_heating.secondary_heating_type = 691
# Act / Assert
assert _triggers_for(epc, "secondary_heating_removal") == {
"secondary_heating_type": 691,
}
def test_system_tune_up_surfaces_its_eligibility_triggers() -> None:
# Like the boiler-upgrade trigger, no golden cert selects a tune-up, so the
# branch is covered directly.
from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage]
# Arrange — a wet boiler (SAP code 102) with "no control" (2101): the wet-
# boiler code and the improvable control are what the report should explain.
epc = parse_recommendation_summary("tune_up_from_2101_001431_before.pdf")
# Act / Assert — both tune-up measure types surface the same eligibility.
expected = {"sap_main_heating_code": 102, "main_heating_control": 2101}
assert _triggers_for(epc, "system_tune_up") == expected
assert _triggers_for(epc, "system_tune_up_zoned") == expected
def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None:
# Arrange
path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json"
# Act
report: PropertyReport = build_property_report(path)
# Assert — 0036's gain-maximising package is solid-floor insulation plus the
# low-energy-lighting upgrade (it lodges 7 low-energy + 0 incandescent fixed
# bulbs, so the LED top-up is a cheap positive-SAP lever, ADR-0023), and
# nothing else.
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"}
assert triggers["solid_floor_insulation"].triggers == {
"floor_insulation_thickness": None,
"floor_construction_type": "Solid",
}
assert triggers["low_energy_lighting"].triggers == {
"incandescent_fixed_lighting_bulbs_count": 0,
"cfl_fixed_lighting_bulbs_count": 0,
"low_energy_fixed_lighting_bulbs_count": 7,
}
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 == ()