Merge pull request #1408 from Hestia-Homes/audit/plan-stuck-in-low-band

Add plan-stuck-in-low-band audit check + refresh weird-recs catalogue
This commit is contained in:
KhalimCK 2026-07-02 00:08:06 +01:00 committed by GitHub
commit 51f1cafb75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 84 additions and 1 deletions

View file

@ -61,6 +61,17 @@ Writes `modelling_audit.md` / `.csv`. The check that carries this skill:
no budget cap, the only reasons to fall short are (a) a measure was wrongly
withheld — a bug — or (b) the dwelling genuinely can't reach the band. Phase 4
decides which. Budget-capped scenarios are excluded (falling short is expected).
**Caveat: this check is noisy on flat-heavy portfolios** — it fired 322/338 on
portfolio 814 (every flat a band short of B). Use `plan-stuck-in-low-band` to
cut straight to the real hits.
- **`plan-stuck-in-low-band`** (HIGH) — the plan leaves the dwelling **still
rated E/F/G with the band unchanged** from baseline (unlimited budget). This is
the sharp "E→E, no big movement" worklist — the genuinely-trapped tail, not
every dwelling a band short of goal. On portfolio 814 it flagged just **4**
(pids 742121, 742210, 742265, 742347) vs `plan-stops-short-of-goal`'s 322. A
wrongly-withheld measure or a physically-trapped dwelling both surface here;
Phase 4 tells them apart. Band-keyed (no SAP-points threshold), and the
strictly-worse case is left to `plan-below-baseline-band` so the two partition.
Also read `plan-below-baseline-band`, `already-meets-goal-with-works`, and — if
you pass `--with-recommendations` (EXPLAIN-gated; confirm with the user first) —
@ -136,11 +147,39 @@ Match each deep-dive against these before hypothesising a novel cause:
main fuel / heat-network flag, then fix by restoring the gate in
`_hhr_storage_eligible` (community heating ⇒ not HHR-eligible). A community
network is a shared asset a single dwelling can't rip out for storage heaters.
- **Measure withheld → plan stops short of goal** (pid 742121, "why no HHRSH").
- **Measure withheld → plan stops short of goal** (the "why no HHRSH" class).
The dwelling can't reach the goal band, and the expected bundle is *absent from
candidates*. Read the generator's eligibility predicate against the live EPC —
an over-tight gate (a fuel/description/category condition that shouldn't
exclude this dwelling) is the usual cause.
- **Measure OFFERED but not selected because it *lowers* SAP** (pid 742121, "why
no HHRSH" — re-modelled 2026-07, NOT the stale mains-gas story). This dwelling
is an **electric** maisonette (`main_fuel_type=29`), so HHRSH **is** in the
candidate set — eligibility passes. But scoring the HHRSH overlay in isolation
gives **6.3 SAP** (54.3 → 48.0): swapping its efficient existing electric
system for off-peak storage heaters *worsens* the cost-based SAP, so the
Optimiser correctly drops it. Its fabric is already done (double-glazed, walls
insulated, no cylinder, no own roof), so the plan is **empty** and it sits at E,
unreachable to B — a legitimate £0 result, not a bug. **Lesson: a measure
*absent from candidates* is a generator-gate bug; a measure *present but not
chosen* is the Optimiser judging it not worth it — score the overlay
(`PackageScorer.score(epc, [EpcSimulation(...)])`) before blaming a gate.** The
old note here claimed 742121 was a mains-gas maisonette that "correctly gets
only lighting" — that was stale; verify fuel against the live EPC every time.
- **Community-heated flat stuck low, correctly** (pid 742265, "might need loft
insulation?"). A **community-heated** flat (SAP main-heating code 301, category
6) on portfolio 814. HHRSH and ASHP are both gated out by `is_heat_network_main`
(the topology guard) — right, per the community bug above. Its roof is
`"Pitched (vaulted ceiling)"` with insulation lodged as **`ND` (no data)**, so
the loft generator withholds insulation: it only fires on a definite `0 mm`
(`roof_insulation_thickness != _ROOF_UNINSULATED_MM` → None), and a vaulted
ceiling isn't the loft-fallback's target anyway. Its only real lever is IWI
(+6.7), not enough to clear E — so it stays E→E, legitimately. **Watch item:**
a vaulted-ceiling roof with `ND` insulation gets *no* roof measure at all
(neither the sloping-ceiling branch, which matches the literal `"sloping
ceiling"`, nor the loft branch, which needs `0 mm`). If several such roofs turn
out to be genuinely uninsulated upstream, that's a mapper/gate gap — but `ND`
is unknown, not zero, so withholding is defensible for now.
- **Missing `secondary_heating_removal`** (pid 742264, "gov site shows secondary
heating"). Check whether the live EPC carries a secondary-heating system and
whether

View file

@ -175,6 +175,50 @@ def _plan_stops_short_of_goal(a: PropertyAudit) -> Optional[str]:
)
@check("plan-stuck-in-low-band", Severity.HIGH)
def _plan_stuck_in_low_band(a: PropertyAudit) -> Optional[str]:
"""The plan leaves the dwelling STUCK in a low band (E or worse) with the band
UNCHANGED from baseline the engine spent (or spent nothing) yet moved the
dwelling nowhere off the floor.
Complements ``plan-stops-short-of-goal``. That check fires on anything below
the goal band 322/338 on portfolio 814, mostly flats a single band short of
B, which is expected and drowns the real hits. This one isolates the
genuinely-stuck tail: the handful still rated E/F/G *after* works, where a
wrongly-withheld measure or a physically-trapped dwelling actually shows up.
It is the "E→E, no big movement" worklist the tech team asked for.
Motivated by portfolio 814 (scenario 1271, goal B, unlimited budget): only 4
properties land E-or-worse and all 4 stay at E pid 742121 (electric
maisonette, £0 works: HHRSH *is* offered as a candidate but scores 6.3 SAP,
so the Optimiser correctly drops it, and the fabric is already done no lever
left); pid 742265 (community-heated flat, SAP code 301: HHRSH and ASHP are
gated out by the heat-network topology, leaving IWI its only real lever); pids
742210 / 742347 (flats that move ~5-8 SAP but never clear E). Legitimate-but-
trapped and real-bug cases both surface here the deep-dive
(``run_modelling_e2e`` + the candidates CSV) tells them apart. Budget-capped
scenarios are excluded: staying low is expected once the money runs out.
Band-keyed, not a SAP-points threshold, so it needs no tuned magic number
"post band E/F/G and no band improvement" is the whole rule. The strictly-
worse case (post below baseline) is left to ``plan-below-baseline-band`` so
the two partition cleanly."""
post, base = _band_rank(a.post_band), _band_rank(a.effective_band)
if post is None or base is None:
return None
if post < _BANDS.index("E"): # AD: out of the low tail, not "stuck"
return None
if post != base: # band moved (a lift, or the worse case above) — not stuck
return None
if a.scenario_budget is not None: # budget-capped: staying low is expected
return None
return (
f"still {a.post_band} ({a.post_sap}) after works — no band lift from "
f"baseline {a.effective_band} ({a.effective_sap}) on an unlimited-budget "
f"scenario (cost_of_works £{a.cost_of_works or 0:.0f})"
)
@check("post-band-score-mismatch", Severity.MEDIUM)
def _post_band_score_mismatch(a: PropertyAudit) -> Optional[str]:
"""The persisted post band disagrees with the band the post SAP implies — a