mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
283 lines
9.8 KiB
Python
283 lines
9.8 KiB
Python
import dataclasses
|
|
from typing import Optional
|
|
|
|
# The checks are registry-driven: production reaches them through `_REGISTRY`,
|
|
# never by name, so the underscore marks "not a direct call site" rather than
|
|
# "untestable". Naming them here is the only way to exercise one check in
|
|
# isolation, hence the private-usage waivers.
|
|
from scripts.audit.anomalies import (
|
|
_REGISTRY, # pyright: ignore[reportPrivateUsage]
|
|
PropertyAudit,
|
|
Severity,
|
|
_already_meets_goal_with_works, # pyright: ignore[reportPrivateUsage]
|
|
_costed_plan_without_real_gain, # pyright: ignore[reportPrivateUsage]
|
|
_effective_lodged_divergence, # pyright: ignore[reportPrivateUsage]
|
|
_implausible_lodged_score, # pyright: ignore[reportPrivateUsage]
|
|
_plan_stops_short_of_goal, # pyright: ignore[reportPrivateUsage]
|
|
)
|
|
|
|
|
|
def _make_audit(
|
|
*,
|
|
lodged_sap: Optional[float] = None,
|
|
effective_sap: Optional[float] = None,
|
|
rebaseline_reason: str = "both",
|
|
scenario_goal_band: Optional[str] = None,
|
|
scenario_budget: Optional[float] = None,
|
|
post_band: Optional[str] = None,
|
|
post_sap: Optional[float] = None,
|
|
cost_of_works: Optional[float] = None,
|
|
has_hot_water_cylinder: Optional[bool] = None,
|
|
cylinder_insulation_type: Optional[int] = None,
|
|
cylinder_insulation_thickness_mm: Optional[float] = None,
|
|
) -> PropertyAudit:
|
|
return PropertyAudit(
|
|
property_id=1,
|
|
uprn=None,
|
|
portfolio_id=796,
|
|
scenario_id=None,
|
|
scenario_goal_band=scenario_goal_band,
|
|
scenario_budget=scenario_budget,
|
|
lodged_sap=lodged_sap,
|
|
lodged_band=None,
|
|
effective_sap=effective_sap,
|
|
effective_band=None,
|
|
rebaseline_reason=rebaseline_reason,
|
|
post_sap=post_sap,
|
|
post_band=post_band,
|
|
cost_of_works=cost_of_works,
|
|
energy_bill_savings=None,
|
|
energy_consumption_savings=None,
|
|
solar_sap_points=None,
|
|
solar_bill_savings=None,
|
|
n_measures=0,
|
|
has_hot_water_cylinder=has_hot_water_cylinder,
|
|
cylinder_insulation_type=cylinder_insulation_type,
|
|
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
|
|
)
|
|
|
|
|
|
class TestEffectiveLodgedDivergence:
|
|
def test_silent_when_lodged_sap_below_floor(self) -> None:
|
|
# Arrange — Class C exemplar: lodged=1, effective=76 (gap +75, but lodged is implausible upstream data)
|
|
audit = _make_audit(lodged_sap=1.0, effective_sap=76.0)
|
|
|
|
# Act
|
|
result = _effective_lodged_divergence(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_fires_when_lodged_sap_at_or_above_floor_with_large_gap(self) -> None:
|
|
# Arrange — lodged=20 (band G, plausible), effective=65 (band D), gap +45
|
|
audit = _make_audit(lodged_sap=20.0, effective_sap=65.0)
|
|
|
|
# Act
|
|
result = _effective_lodged_divergence(audit)
|
|
|
|
# Assert
|
|
assert result is not None
|
|
assert "65" in result
|
|
assert "20" in result
|
|
|
|
|
|
class TestImplausibleLodgedScore:
|
|
def test_fires_when_lodged_sap_below_floor_and_gap_large(self) -> None:
|
|
# Arrange — Class C exemplar: lodged=1, effective=76 (gap +75)
|
|
audit = _make_audit(lodged_sap=1.0, effective_sap=76.0)
|
|
|
|
# Act
|
|
result = _implausible_lodged_score(audit)
|
|
|
|
# Assert
|
|
assert result is not None
|
|
assert "1" in result
|
|
|
|
def test_silent_when_gap_below_threshold(self) -> None:
|
|
# Arrange — lodged=9, effective=5 (gap -4, below ±15): both low, not a divergence anomaly
|
|
audit = _make_audit(lodged_sap=9.0, effective_sap=5.0)
|
|
|
|
# Act
|
|
result = _implausible_lodged_score(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_silent_when_lodged_sap_at_or_above_floor(self) -> None:
|
|
# Arrange — lodged=13 is on the floor boundary; effective=70 gives large gap
|
|
audit = _make_audit(lodged_sap=13.0, effective_sap=70.0)
|
|
|
|
# Act
|
|
result = _implausible_lodged_score(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
|
|
class TestPlanStopsShortOfGoal:
|
|
def test_fires_when_short_of_goal_on_unlimited_budget(self) -> None:
|
|
# Arrange — goal C, plan lands at D, budget unlimited (None): a shortfall
|
|
# the engine should be able to explain (Khalim's 742121 class).
|
|
audit = _make_audit(
|
|
scenario_goal_band="C",
|
|
scenario_budget=None,
|
|
post_band="D",
|
|
post_sap=63.0,
|
|
cost_of_works=8000.0,
|
|
)
|
|
|
|
# Act
|
|
result = _plan_stops_short_of_goal(audit)
|
|
|
|
# Assert
|
|
assert result is not None
|
|
assert "D" in result
|
|
assert "C" in result
|
|
|
|
def test_silent_when_plan_meets_goal(self) -> None:
|
|
# Arrange — goal C, plan reaches C: nothing to explain.
|
|
audit = _make_audit(
|
|
scenario_goal_band="C", scenario_budget=None, post_band="C", post_sap=70.0
|
|
)
|
|
|
|
# Act
|
|
result = _plan_stops_short_of_goal(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_silent_when_budget_capped(self) -> None:
|
|
# Arrange — goal C, plan short at D, but the scenario is budget-capped:
|
|
# falling short is expected once the money runs out, not an anomaly.
|
|
audit = _make_audit(
|
|
scenario_goal_band="C",
|
|
scenario_budget=5000.0,
|
|
post_band="D",
|
|
post_sap=63.0,
|
|
cost_of_works=5000.0,
|
|
)
|
|
|
|
# Act
|
|
result = _plan_stops_short_of_goal(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
|
|
class TestAlreadyMeetsGoalWithWorks:
|
|
"""Costed works on a dwelling already in the goal band (#1652 / #1654).
|
|
|
|
Ranked HIGH: this is the signature of the Optimiser judging the goal against
|
|
something other than the published band, and it bills real money — 630 homes
|
|
in portfolio 796 published at band C were each sold ~1 SAP point of works
|
|
toward a band they had already reached. It must not sit in the
|
|
lower-severity bucket that gets waved through as stale-plan noise.
|
|
"""
|
|
|
|
def test_fires_when_a_dwelling_already_at_goal_is_sold_works(self) -> None:
|
|
# Arrange — published band C, scenario goal C, yet £678 of works.
|
|
audit = _make_audit(scenario_goal_band="C", cost_of_works=678.0)
|
|
audit = dataclasses.replace(audit, effective_band="C")
|
|
|
|
# Act
|
|
result = _already_meets_goal_with_works(audit)
|
|
|
|
# Assert
|
|
assert result is not None
|
|
assert "678" in result
|
|
|
|
def test_silent_when_the_dwelling_is_below_the_goal_band(self) -> None:
|
|
# Arrange — published band D against a goal of C: works are warranted.
|
|
audit = _make_audit(scenario_goal_band="C", cost_of_works=678.0)
|
|
audit = dataclasses.replace(audit, effective_band="D")
|
|
|
|
# Act
|
|
result = _already_meets_goal_with_works(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_silent_when_an_already_compliant_dwelling_is_sold_nothing(self) -> None:
|
|
# Arrange — already band C and a £0 plan: the correct outcome.
|
|
audit = _make_audit(scenario_goal_band="C", cost_of_works=0.0)
|
|
audit = dataclasses.replace(audit, effective_band="C")
|
|
|
|
# Act
|
|
result = _already_meets_goal_with_works(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_is_registered_at_high_severity(self) -> None:
|
|
# Act — the severity the runner reports it under.
|
|
registered = {name: severity for name, severity, _ in _REGISTRY}
|
|
|
|
# Assert
|
|
assert registered["already-meets-goal-with-works"] is Severity.HIGH
|
|
|
|
|
|
class TestCostedPlanWithoutRealGain:
|
|
"""Money spent for no meaningful SAP movement (#1652 / #1655).
|
|
|
|
The coverage hole the #1652 diagnosis found: `plan-score-below-baseline`
|
|
only fires on a *drop* beyond 0.5 and `zero-works-post-differs` only on £0
|
|
plans, so a costed plan landing flat against the effective baseline was
|
|
caught by neither — including the 112 homes whose modelled post-retrofit SAP
|
|
sat ~0.2 *below* their effective baseline.
|
|
"""
|
|
|
|
def test_fires_when_works_are_bought_for_no_real_gain(self) -> None:
|
|
# Arrange — £6,240 of works moving the dwelling 0.2 SAP.
|
|
audit = _make_audit(
|
|
effective_sap=63.0, post_sap=63.2, cost_of_works=6240.0
|
|
)
|
|
|
|
# Act
|
|
result = _costed_plan_without_real_gain(audit)
|
|
|
|
# Assert
|
|
assert result is not None
|
|
assert "6240" in result or "6,240" in result
|
|
|
|
def test_fires_when_the_post_score_lands_below_the_baseline(self) -> None:
|
|
# Arrange — the likely-downgrade case: paid works, post *below* baseline.
|
|
audit = _make_audit(
|
|
effective_sap=63.0, post_sap=62.8, cost_of_works=1029.0
|
|
)
|
|
|
|
# Act
|
|
result = _costed_plan_without_real_gain(audit)
|
|
|
|
# Assert
|
|
assert result is not None
|
|
|
|
def test_silent_when_the_works_deliver_a_real_uplift(self) -> None:
|
|
# Arrange — £6,240 buying 9 SAP points: what a retrofit should look like.
|
|
audit = _make_audit(
|
|
effective_sap=63.0, post_sap=72.0, cost_of_works=6240.0
|
|
)
|
|
|
|
# Act
|
|
result = _costed_plan_without_real_gain(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_silent_on_a_zero_cost_plan(self) -> None:
|
|
# Arrange — no money spent, so there is nothing to justify; a £0 plan
|
|
# that moves nothing is the correct outcome for an already-compliant
|
|
# dwelling, and is covered by its own check.
|
|
audit = _make_audit(effective_sap=63.0, post_sap=63.0, cost_of_works=0.0)
|
|
|
|
# Act
|
|
result = _costed_plan_without_real_gain(audit)
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_is_registered_at_high_severity(self) -> None:
|
|
# Act
|
|
registered = {name: severity for name, severity, _ in _REGISTRY}
|
|
|
|
# Assert
|
|
assert registered["costed-plan-without-real-gain"] is Severity.HIGH
|