mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1433 from Hestia-Homes/fix/audit-cylinder-storage-loss-check
Audit check: lodged cylinder with unresolvable insulation drops storage loss
This commit is contained in:
commit
cde8a5d6ca
2 changed files with 100 additions and 1 deletions
|
|
@ -55,6 +55,26 @@ def _band_of(score: Optional[float]) -> Optional[str]:
|
|||
return Epc.from_sap_score(round(score)).value
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> Optional[int]:
|
||||
"""Coerce a jsonb-backed scalar (int / digit-string / None) to int."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _float_or_none(value: object) -> Optional[float]:
|
||||
"""Coerce a jsonb-backed scalar (number / numeric-string / None) to float."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class Severity(IntEnum):
|
||||
LOW = 1
|
||||
MEDIUM = 2
|
||||
|
|
@ -89,6 +109,12 @@ class PropertyAudit:
|
|||
solar_sap_points: Optional[float] # max SAP a single solar_pv measure earns
|
||||
solar_bill_savings: Optional[float] # the solar_pv measure's £/yr bill saving
|
||||
n_measures: int
|
||||
# Hot-water-cylinder fields from the latest lodged EPC row (None when the
|
||||
# Property has no epc_property row). Insulation type codes are the RdSAP
|
||||
# lodgement codes: 0 = none, 1 = factory foam, 2 = loose jacket.
|
||||
has_hot_water_cylinder: Optional[bool]
|
||||
cylinder_insulation_type: Optional[int]
|
||||
cylinder_insulation_thickness_mm: Optional[float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -342,6 +368,54 @@ def _negative_bill_savings(a: PropertyAudit) -> Optional[str]:
|
|||
return f"energy_bill_savings £{a.energy_bill_savings:.0f}/yr on £{a.cost_of_works:.0f} of works"
|
||||
|
||||
|
||||
@check("cylinder-storage-loss-dropped", Severity.HIGH)
|
||||
def _cylinder_storage_loss_dropped(a: PropertyAudit) -> Optional[str]:
|
||||
"""A lodged hot-water cylinder whose insulation fields cannot resolve a SAP
|
||||
10.2 Table 2 loss branch — insulation type is not factory foam (1) or loose
|
||||
jacket (2), or the thickness is missing — so the calculator's
|
||||
``_cylinder_storage_loss_override`` (domain/sap10_calculator/rdsap/
|
||||
cert_to_inputs.py) returns None and the worksheet keeps the zero-storage-loss
|
||||
default meant for combi / instantaneous systems. The baseline then
|
||||
OVER-RATES: an UNINSULATED cylinder (type 0, the worst case SAP knows) scores
|
||||
~+13 SAP better than a properly insulated one instead of much worse.
|
||||
|
||||
Provenance: portfolio 814 / scenario 1271 audit (2026-07-03). All 26
|
||||
properties with ``has_hot_water_cylinder`` and insulation type 0 (23) or NULL
|
||||
(3) over-rate vs their lodged score — Δ+1..+18, avg +12.7 for type 0 — while
|
||||
the type-1/2 population averages Δ−2. Same-lodged neighbours split by up to
|
||||
19 effective points (WD5 0DP: pid 742216 lodged 54 → effective 69 vs pid
|
||||
742215 lodged 54 → effective 50, identical building parts), and the inflated
|
||||
members distorted neighbour-divergence cohort medians (E17 6EB pid 742355
|
||||
Δ+18; WD5 0DP pushed honest pid 742210 over the flag threshold). Fix is in
|
||||
the calculator (apply the uninsulated Table 2 loss for type 0, or the RdSAP
|
||||
Table 29 age-band default when unknown), not in the data.
|
||||
|
||||
Fires on the input pattern (deterministic — the loss IS dropped for every
|
||||
such row), not on a tuned delta threshold; the reason string reports the
|
||||
effective-vs-lodged gap where a lodged score exists so triage can rank by
|
||||
materiality. A full-SAP cert lodging a manufacturer's declared loss instead
|
||||
of type/thickness would bypass the bug and false-positive here — none exist
|
||||
on the motivating portfolio; revisit if one appears."""
|
||||
if a.has_hot_water_cylinder is not True:
|
||||
return None
|
||||
if (
|
||||
a.cylinder_insulation_type in (1, 2)
|
||||
and a.cylinder_insulation_thickness_mm is not None
|
||||
):
|
||||
return None
|
||||
gap = (
|
||||
f", effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} "
|
||||
f"(Δ{a.effective_sap - a.lodged_sap:+.0f})"
|
||||
if a.effective_sap is not None and a.lodged_sap is not None
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"cylinder lodged with insulation_type={a.cylinder_insulation_type} "
|
||||
f"thickness={a.cylinder_insulation_thickness_mm} — storage loss dropped "
|
||||
f"to zero (combi default), baseline over-rates{gap}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────── runner ───────────────────────
|
||||
|
||||
_QUERY = text(
|
||||
|
|
@ -351,12 +425,24 @@ _QUERY = text(
|
|||
pbp.lodged_sap_score, pbp.lodged_epc_band,
|
||||
pbp.effective_sap_score, pbp.effective_epc_band, pbp.rebaseline_reason,
|
||||
pl.post_sap_points, pl.post_epc_rating, pl.cost_of_works,
|
||||
pl.energy_bill_savings, pl.energy_consumption_savings
|
||||
pl.energy_bill_savings, pl.energy_consumption_savings,
|
||||
ep.has_hot_water_cylinder, ep.cylinder_insulation_type,
|
||||
ep.cylinder_insulation_thickness_mm
|
||||
FROM property p
|
||||
LEFT JOIN property_baseline_performance pbp ON pbp.property_id = p.id
|
||||
LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = TRUE
|
||||
AND (:scenario_id IS NULL OR pl.scenario_id = :scenario_id)
|
||||
LEFT JOIN scenario s ON s.id = pl.scenario_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT e.has_hot_water_cylinder,
|
||||
e.heating_cylinder_insulation_type AS cylinder_insulation_type,
|
||||
e.heating_cylinder_insulation_thickness_mm
|
||||
AS cylinder_insulation_thickness_mm
|
||||
FROM epc_property e
|
||||
WHERE e.property_id = p.id
|
||||
ORDER BY e.id DESC
|
||||
LIMIT 1
|
||||
) ep ON TRUE
|
||||
WHERE (:portfolio_id IS NULL OR p.portfolio_id = :portfolio_id)
|
||||
AND (:property_id IS NULL OR p.id = :property_id)
|
||||
ORDER BY p.id
|
||||
|
|
@ -479,6 +565,13 @@ def _load(
|
|||
solar_sap_points=solar_sap,
|
||||
solar_bill_savings=solar_bill,
|
||||
n_measures=n_measures,
|
||||
has_hot_water_cylinder=m["has_hot_water_cylinder"],
|
||||
cylinder_insulation_type=_int_or_none(
|
||||
m["cylinder_insulation_type"]
|
||||
),
|
||||
cylinder_insulation_thickness_mm=_float_or_none(
|
||||
m["cylinder_insulation_thickness_mm"]
|
||||
),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ def _make_audit(
|
|||
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,
|
||||
|
|
@ -41,6 +44,9 @@ def _make_audit(
|
|||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue