mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Three more pre-existing failures (present at9ee38211, before this branch's recent commits; same family as the orchestration multi-measure re-pin) — golden-cert plan expectations that predate the ASHP generator (ADR-0025) and the optimiser folding forced dependencies into candidate gain (ADR-0016): - test_console: a multi-measure plan now leads with air_source_heat_pump, not cavity_wall_insulation (which is dropped — its forced ventilation makes the pair net-negative). Assert a measure actually in the package. - test_report 0330: package is now {solid_floor_insulation, air_source_heat_ pump}; cavity_wall + forced mechanical_ventilation correctly excluded. - test_report 0036: gain-maximising package is now {solid_floor_insulation, low_energy_lighting}. Same verified-correct optimiser evolution as077e3a39(cavity_wall +2.9 SAP alone but its forced fabric→ventilation dep drags the pair net-negative). Re-pin to the actual packages + their trigger fields; the forced wall→vent edge stays covered by test_measure_dependency / test_optimiser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
313 lines
12 KiB
Python
313 lines
12 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,
|
||
)
|
||
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": 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 == ()
|