mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
0390's floor+ASHP package reaches post 68.772, which publishes as 69 = band C, so under ADR-0064 it now meets the goal and stops before low-energy lighting — the same over-purchase the ADR removes, one level up at the package. 0380 fires the same three measures with identical trigger attributes, so every assertion stands unchanged, and it lands at post 74.4, clear of a band floor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
13 KiB
Python
332 lines
13 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"
|
||
|
||
# 0380 fires three measures — an uninsulated solid floor, low-energy lighting,
|
||
# and the ASHP bundle — so every fired measure's trigger attributes are
|
||
# exercised together.
|
||
#
|
||
# Fixture history, both times because the *package* changed rather than the
|
||
# trigger logic under test:
|
||
# 0330 -> 0390: ADR-0049 sizes the pump to the dwelling's design heat loss, so
|
||
# the undersized-pump-era companion solid_floor_insulation stopped firing.
|
||
# 0390 -> 0380: ADR-0064 judges the goal band on the *published* rating, so
|
||
# 0390's floor+ASHP package (post 68.772, published 69 = band C) now meets
|
||
# the target and no longer buys low-energy lighting to clear an integer 69.
|
||
# 0380 reaches band C at post 74.4 — clear of a band floor, so it does not ride
|
||
# on the rounding rule it is standing in for.
|
||
_THREE_MEASURES = "0380-2530-6150-2326-4161"
|
||
|
||
|
||
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 selected package: the dwelling-sized ASHP (ADR-0025/ADR-0049) plus
|
||
# solid-floor insulation and low-energy lighting.
|
||
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
|
||
assert set(triggers) == {
|
||
"solid_floor_insulation",
|
||
"low_energy_lighting",
|
||
"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",
|
||
}
|
||
# Low-energy lighting fired off the dwelling's lodged bulb counts.
|
||
assert triggers["low_energy_lighting"].triggers == {
|
||
"incandescent_fixed_lighting_bulbs_count": 0,
|
||
"cfl_fixed_lighting_bulbs_count": 0,
|
||
"low_energy_fixed_lighting_bulbs_count": 9,
|
||
}
|
||
# 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) plus a
|
||
# zoned system tune-up: 0036 lodges its gas boiler as a PCDB main-heating
|
||
# index with no SAP code, which the heating generators now classify as a wet
|
||
# boiler eligible for a tune-up (#1399), so the boiler's controls upgrade
|
||
# joins the package.
|
||
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
|
||
assert set(triggers) == {
|
||
"solid_floor_insulation",
|
||
"low_energy_lighting",
|
||
"system_tune_up_zoned",
|
||
}
|
||
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 == ()
|