diff --git a/.claude/skills/find-weird-recommendations/SKILL.md b/.claude/skills/find-weird-recommendations/SKILL.md index 04e0ee36c..b51723957 100644 --- a/.claude/skills/find-weird-recommendations/SKILL.md +++ b/.claude/skills/find-weird-recommendations/SKILL.md @@ -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 diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 7e9f627a8..532108f7c 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -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"): # A–D: 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