diff --git a/harness/report.py b/harness/report.py index 28af881d..cfa8d570 100644 --- a/harness/report.py +++ b/harness/report.py @@ -211,3 +211,122 @@ def parity_report_for(reports: Iterable[PropertyReport]) -> ParityReport: if report.lodged_sap is not None and report.calculated_sap is not None ] return build_parity_report(cases) + + +def _fmt_money(value: Optional[float]) -> str: + return "n/a" if value is None else f"£{value:,.0f}" + + +def _fmt_triggers(triggers: dict[str, Any]) -> str: + """Render trigger fields as `field=value, field=value` for the "why" line.""" + return ", ".join(f"{field}={value}" for field, value in triggers.items()) + + +def _calculator_error_section(reports: list[PropertyReport]) -> list[str]: + """Section 1 — the cohort parity stats plus a per-property lodged-vs- + calculated table with the |Δ| > 0.5 flag (and any scoring errors).""" + parity: ParityReport = parity_report_for(reports) + flagged: int = sum(1 for report in reports if report.sap_error_exceeds_threshold) + worst: str = ( + f" · worst Δ {abs(parity.worst_cases[0].predicted_sap - parity.worst_cases[0].actual_sap):.2f}" + if parity.worst_cases + else "" + ) + lines: list[str] = [ + "## 1. Calculator error — lodged vs calculated SAP", + "", + f"Cohort parity ({parity.case_count} scorable certs): " + f"MAE {parity.global_mae:.2f} · RMSE {parity.global_rmse:.2f} · " + f"bias {parity.global_bias:+.2f}{worst}", + f"Flagged (|Δ| > {SAP_ERROR_THRESHOLD}): {flagged} of {len(reports)}", + "", + "| Cert | Lodged | Calculated | Δ (lodged−calc) | Flag |", + "| --- | --- | --- | --- | --- |", + ] + for report in reports: + if report.calculator_error is not None: + lines.append( + f"| {report.name} | — | — | — | error: {report.calculator_error} |" + ) + continue + lodged: str = "—" if report.lodged_sap is None else str(report.lodged_sap) + calculated: str = ( + "—" if report.calculated_sap is None else f"{report.calculated_sap:.2f}" + ) + delta: str = "—" if report.sap_error is None else f"{report.sap_error:+.2f}" + flag: str = "⚠ FLAG" if report.sap_error_exceeds_threshold else "" + lines.append( + f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |" + ) + return lines + + +def _plan_costings_section(reports: list[PropertyReport]) -> list[str]: + """Section 2 — the optimised Plan and its costings, per property.""" + lines: list[str] = ["## 2. Plans + costings", ""] + for report in reports: + if report.plan is None: + note: str = report.plan_error or report.calculator_error or "not modelled" + lines.extend([f"### {report.name}", f"- No Plan — {note}", ""]) + continue + plan: Plan = report.plan + measure_types: str = ( + ", ".join(measure.measure_type for measure in plan.measures) + if plan.measures + else "none (already efficient)" + ) + lines.extend( + [ + f"### {report.name}", + f"- SAP: {plan.baseline.sap_continuous:.1f} → " + f"{plan.post_sap_continuous:.1f} " + f"(band {plan.baseline_epc_rating.value} → {plan.post_epc_rating.value})", + f"- Measures: {len(plan.measures)} — {measure_types}", + f"- Cost of works: {_fmt_money(plan.cost_of_works)} " + f"(+ {_fmt_money(plan.contingency_cost)} contingency)", + f"- Bill savings: {_fmt_money(plan.energy_bill_savings)}/yr · " + f"CO₂ savings: {plan.co2_savings_kg_per_yr:,.0f} kg/yr", + f"- Valuation uplift: {plan.valuation.average_pct * 100:+.1f}%", + "", + ] + ) + return lines + + +def _measures_triggers_section(reports: list[PropertyReport]) -> list[str]: + """Section 3 — each fired measure and the EPC attribute(s) behind it.""" + lines: list[str] = ["## 3. Recommended measures + their triggers", ""] + for report in reports: + if not report.measure_triggers: + continue + lines.append(f"### {report.name}") + lines.extend( + f"- **{trigger.measure_type}** — fired because " + f"{_fmt_triggers(trigger.triggers)}" + for trigger in report.measure_triggers + ) + lines.append("") + return lines + + +def format_report_markdown(reports: list[PropertyReport]) -> str: + """Render the three-section property inspection report as Markdown: + (1) calculator error vs lodged SAP, (2) Plans + costings, (3) recommended + measures and the attributes that triggered them.""" + modelled: int = sum(1 for report in reports if report.plan is not None) + errored: int = sum(1 for report in reports if report.calculator_error is not None) + header: list[str] = [ + "# Property inspection report", + "", + f"{len(reports)} properties · {modelled} modelled · " + f"{errored} calculator errors", + "", + ] + sections: list[str] = [ + *header, + *_calculator_error_section(reports), + "", + *_plan_costings_section(reports), + *_measures_triggers_section(reports), + ] + return "\n".join(sections).rstrip() + "\n" diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index e60d95ff..9f84c905 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -11,6 +11,7 @@ from harness.report import ( PropertyReport, build_property_report, build_property_reports, + format_report_markdown, parity_report_for, ) @@ -158,6 +159,35 @@ def test_cohort_parity_report_excludes_unscorable_certs() -> None: 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_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"