mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Section 3 of the report: build_property_report now runs the Modelling stage and, for every Plan Measure, records the EPC attribute(s) that caused its generator to fire (MeasureTrigger) — wall_construction/insulation for cavity fill, roof thickness for loft, floor thickness/construction for floors, the absent mechanical kind for ventilation. Modelling raises are captured as plan_error, independent of the calculator-error capture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
"""Per-property inspection report over a dump of API-shaped EPC JSONs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from harness.report import (
|
|
MeasureTrigger,
|
|
PropertyReport,
|
|
build_property_report,
|
|
)
|
|
|
|
_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
|
|
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
|
|
assert set(triggers) == {
|
|
"cavity_wall_insulation",
|
|
"mechanical_ventilation",
|
|
"solid_floor_insulation",
|
|
}
|
|
# Cavity fill fired because the main wall is an uninsulated cavity.
|
|
assert triggers["cavity_wall_insulation"].triggers == {
|
|
"wall_construction": 4,
|
|
"wall_insulation_type": 4,
|
|
}
|
|
# Ventilation fired because the dwelling lodges no mechanical kind.
|
|
assert triggers["mechanical_ventilation"].triggers == {
|
|
"mechanical_ventilation_kind": None,
|
|
}
|
|
# 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_single_measure_cert_surfaces_only_that_measures_trigger() -> None:
|
|
# Arrange
|
|
path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json"
|
|
|
|
# Act
|
|
report: PropertyReport = build_property_report(path)
|
|
|
|
# Assert — 0036 fires the solid-floor measure alone.
|
|
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
|
|
assert set(triggers) == {"solid_floor_insulation"}
|
|
assert triggers["solid_floor_insulation"].triggers == {
|
|
"floor_insulation_thickness": None,
|
|
"floor_construction_type": "Solid",
|
|
}
|
|
|
|
|
|
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 == ()
|