"""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 == ()