feat(modelling): render the three-section inspection report as Markdown

format_report_markdown emits: (1) cohort parity stats + a per-property
lodged-vs-calculated table flagging |Δ| > 0.5 (errors shown inline),
(2) Plans + costings (SAP/band jump, cost + contingency, bill & CO2 savings,
valuation uplift), (3) each fired measure with the EPC attributes that
triggered it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 11:15:12 +00:00
parent 5e4906dd70
commit 1c00708ecd
2 changed files with 149 additions and 0 deletions

View file

@ -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 | Δ (lodgedcalc) | 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"

View file

@ -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"