Model/tests/harness/test_report.py
Khalim Conn-Kowlessar 07f534ee11 feat(modelling): system tune-up options (standard + zone controls)
Add the system tune-up to the heating Recommendation: keep the existing wet
boiler but install better heating controls and fix the cylinder. Two competing
Options (the Optimiser picks <=1 across the whole heating rec) per the user's
two best control end-states:

- system_tune_up        — standard controls (programmer + room thermostat +
  TRVs, SAP 10.2 Table 4e code 2106)
- system_tune_up_zoned  — time-and-temperature zone control (code 2110, type 3):
  more SAP uplift for more cost

Both keep the boiler (no fuel / SAP code / flue change), set the control
ABSOLUTELY to their end-state, and apply the conditional cylinder fixes (an
80 mm jacket when under-insulated, a thermostat when absent — only when a
cylinder exists). Each control option is offered only when it genuinely improves
the existing control — standard is skipped when the control is already 2106 /
2110 / 2112, zone when already 2110 / 2112 — so neither is ever a downgrade or a
no-op.

Validated against the Elmhurst "system tune up" re-lodgements (cert 001431):
nine befores spanning controls 2101-2113 all converge to the two common afters,
proving the control overlay is absolute. The cascade pin is parametrised over
two starting controls (2101 "no control" + 2113 "room thermostat and TRVs") x
both afters, delta 0 (SAP/CO2/PE).

Wires the two MeasureTypes through contingencies (0.15), the offline catalogue
(500 / 900), the catalogue-coverage list, the report triggers, and the ARA
first-run seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:20:46 +00:00

291 lines
11 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 efficient representative heat pump (Vaillant aroTHERM plus 5 kW,
# ADR-0025) raises SAP enough on this gas dwelling that ASHP + solid-floor
# insulation reach the target band on their own, displacing the fabric stack
# the Optimiser used to assemble (ADR-0024 — ASHP is now a strong candidate).
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
assert set(triggers) == {
"air_source_heat_pump",
"solid_floor_insulation",
}
# ASHP fired because the dwelling is a non-flat house not already a heat pump
# (eligibility is physical/planning only — ADR-0024).
assert triggers["air_source_heat_pump"].triggers == {
"property_type": "0",
"main_heating_category": 2,
}
# 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_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_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 fires solid-floor insulation and the LED upgrade (it lodges
# 7 low-energy-unknown bulbs), 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": None,
"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 == ()