Model/tests/scripts/test_audit_anomalies.py
Jun-te Kim 26bcb3e9a6 Pass new cylinder fields through _make_audit test helper
PropertyAudit gained three required cylinder fields; the pre-existing
divergence/goal-shortfall tests construct it via _make_audit, which
wasn't updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:57:04 +00:00

157 lines
4.8 KiB
Python

from typing import Optional
import pytest
from scripts.audit.anomalies import (
PropertyAudit,
_effective_lodged_divergence,
_implausible_lodged_score,
_plan_stops_short_of_goal,
)
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