mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
5e4906dd70
commit
1c00708ecd
2 changed files with 149 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue