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