Merge pull request #1344 from Hestia-Homes/tooling/modelling-anomaly-audit

feat(audit): pluggable modelling-anomaly audit + audit-ara-portfolio skill
This commit is contained in:
KhalimCK 2026-06-29 11:18:26 +01:00 committed by GitHub
commit 29a0cc7f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 989 additions and 0 deletions

View file

@ -0,0 +1,122 @@
---
name: audit-ara-portfolio
description: Audit a modelled Ara portfolio for odd results — run the deterministic anomaly checks over the DB, review the groups, deep-dive a sample of each, characterise sub-classes, and cross-reference open PRs/ADRs. Use when the user wants to audit or review a portfolio's modelling output, hunt for dodgy plans / baselines / SAP scores / recommendations, or triage modelling anomalies. Asks for portfolio id and scenario id.
---
# Audit Ara portfolio
Turn the deterministic anomaly scan into a triaged, root-caused review. The
deterministic checks (`scripts/audit/anomalies.py`) find KNOWN anti-patterns
exhaustively and cheaply; this skill adds the judgement — confirm, characterise,
cross-reference existing work, and feed novel patterns back as new checks.
## Input
Ask for **portfolio_id** and **scenario_id** if the user didn't give them.
`scenario_id` is optional — without it, each Property's *default* plan (the one
shown in the FE) is audited.
## Phase 1 — Build the dataset
```
python -m scripts.audit.anomalies --portfolio <portfolio_id> --scenario <scenario_id>
```
Writes `modelling_audit.md` (grouped, ranked by severity) and
`modelling_audit.csv` (every flagged row: property_id, uprn, severity, check,
detail). Read the printed summary and `modelling_audit.md`.
## Phase 2 — Review the high-level results
For each check group, HIGH severity first: note the count, read a few example
rows. State, in one line each, a **root-cause hypothesis** and whether it is a
real bug, a known/expected effect, or a threshold that needs tuning.
## Phase 3 — Deep-dive a sample per group
For 23 representative properties in each meaningful group, reproduce
end-to-end (NO DB writes — never pass `--persist`):
```
python -m scripts.run_modelling_e2e <property_id> --scenario-id <scenario_id>
```
Read the printed overrides + effective SAP + measure-by-measure plan table.
Confirm the anomaly and isolate **where** it goes wrong: the baseline, a specific
measure, the SAP calc, or the bill. For SAP-value bugs, the `SapResult`
worksheet / `intermediate` trail and the `/diagnose` loop are the tools.
## Phase 4 — Characterise sub-classes
A group of thousands is usually several distinct causes. Within each meaningful
group, cluster the flagged properties by a distinguishing trait — EPC source
(lodged / predicted), `rebaseline_reason`, property type, the dominant measure,
fuel — using SQL against the DB. Report the sub-classes and their sizes.
## Phase 5 — Cross-reference open work
Before proposing fixes, check whether one is already in flight:
```
gh pr list --repo Hestia-Homes/Model --state open
gh pr list --repo Hestia-Homes/Model --state all --search "<keyword>"
```
Map each sub-class to an existing PR / ADR where one applies (e.g.
baseline-vs-plan divergence → the override-aware-rebaseline + persistence-fidelity
work; oversized solar → ADR-0038 dwelling-roof cap). Flag sub-classes with no
coverage as new work.
## Output
A triage report. Per check group:
- count + root-cause hypothesis,
- sub-classes, each sized,
- example property ids (to reproduce),
- existing-PR/ADR coverage,
- recommended action: **fix** / **tune threshold** / **accept (expected)** / **new ticket**.
Then run Phase 6 to make the audit permanently better.
## Phase 6 — Self-improve (the compounding loop)
When the review confirms a **novel, systematic** problem, codify it so every
future run catches it automatically. This is what makes the audit get better each
time it runs. Apply the gates below — they keep the registry sharp, not noisy.
**Gates (all must hold before adding a check):**
1. **Systematic** — reproduced on **≥ 5** properties and root-caused, not a
one-off. (A single weird property is a ticket, not a check.)
2. **Not already covered** — no existing check fires on it, and no open/merged PR
or ADR already addresses the cause (you checked in Phase 5).
3. **Pressure-tested** — for any non-trivial check (a threshold, a heuristic),
run `/grill-me` on the proposed check first: what's the false-positive rate on
this portfolio? is the threshold defensible against the real distribution? does
it overlap an existing check? Tune from the answers before committing.
**What to change, smallest first:**
- **A check** — add one decorated `(PropertyAudit) -> Optional[str]` function to
`scripts/audit/anomalies.py`. Its docstring MUST record **provenance**: the
motivating property ids and the one-line root cause, so the check is traceable
and re-verifiable later. If it needs a field not on `PropertyAudit`, extend the
bundle + query.
- **The skill** — if the review revealed a new *expectation* (a pattern that is
expected-not-a-bug, or a new deep-dive technique), add it to this file's Notes
/ phases so the next reviewer starts ahead.
- **Docs** — if the cause is a load-bearing modelling decision, an ADR may be
warranted (rare; only when hard-to-reverse + surprising + a real trade-off).
Commit each codified check on its own with the motivating run referenced, then
**re-run Phase 1** to confirm the new check fires on the cases that motivated it
and nothing else surprising. The check registry — with provenance — is the
durable, compounding output of every audit.
## Notes
- Read-only on the DB. `run_modelling_e2e` is a dry run.
- **Expected, not bugs** (until the override-aware-rebaseline + persistence-fidelity
PR deploys and the portfolio is re-modelled): much of `zero-works-post-differs`
and `plan-score-below-baseline` is the pre-fix baseline-vs-plan divergence and
should shrink after re-model — note it, don't re-debug it.
- Adding a check is one decorated `(PropertyAudit) -> Optional[str]` function in
`scripts/audit/anomalies.py`; see its module docstring.

463
modelling_audit.md Normal file
View file

@ -0,0 +1,463 @@
# Modelling anomaly audit
Scanned **31919** properties · flagged **10563** anomalies across **10** checks.
## impossible-sap-over-100 (HIGH) — 1
- property **726993** (uprn 100061757571): SAP > 100: effective 102.0
## plan-below-baseline-band (HIGH) — 363
- property **709810** (uprn 10096028301): post C (78.02813) worse than effective baseline B (85)
- property **709846** (uprn 10096399556): post C (79.7374) worse than effective baseline A (93)
- property **709847** (uprn 10096028354): post C (78.695816) worse than effective baseline B (91)
- property **709850** (uprn 10096028348): post C (78.750984) worse than effective baseline B (89)
- property **709959** (uprn 10096028306): post C (78.14567) worse than effective baseline B (84)
- property **710011** (uprn 10096028349): post C (78.750984) worse than effective baseline B (89)
- property **710071** (uprn 10002918889): post C (76.07915) worse than effective baseline B (82)
- property **710075** (uprn 10002918890): post C (79.84036) worse than effective baseline B (82)
- property **710117** (uprn 10096399566): post C (79.86282) worse than effective baseline A (93)
- property **710121** (uprn 10096028307): post C (78.790596) worse than effective baseline B (90)
- property **710131** (uprn 10096028350): post C (80.142784) worse than effective baseline A (94)
- property **710201** (uprn 10090944222): post C (74.58814) worse than effective baseline B (82)
- property **710222** (uprn 10096399567): post C (79.84616) worse than effective baseline A (93)
- property **710225** (uprn 10096028308): post C (78.78894) worse than effective baseline B (90)
- property **710241** (uprn 10096028351): post C (80.142784) worse than effective baseline A (94)
- property **710326** (uprn 10096028309): post C (78.610855) worse than effective baseline B (89)
- property **710335** (uprn 10096028352): post C (79.9757) worse than effective baseline A (94)
- property **710395** (uprn 10096028310): post C (79.46583) worse than effective baseline B (91)
- property **710474** (uprn 10096028355): post C (78.47655) worse than effective baseline B (91)
- property **710479** (uprn 10096028353): post C (80.291725) worse than effective baseline A (95)
- property **710537** (uprn 10096028311): post C (79.46583) worse than effective baseline B (91)
- property **710540** (uprn 100060714157): post D (68.24699) worse than effective baseline C (71)
- property **710760** (uprn 10096028302): post C (78.28315) worse than effective baseline B (85)
- property **710800** (uprn 10096399558): post C (79.7374) worse than effective baseline A (93)
- property **710802** (uprn 10096028356): post C (78.47655) worse than effective baseline B (91)
- property **710946** (uprn 44012846): post C (79.819626) worse than effective baseline B (81)
- property **711027** (uprn 10096028357): post C (77.96618) worse than effective baseline B (90)
- property **711178** (uprn 10091636026): post C (76.05113) worse than effective baseline B (81)
- property **711195** (uprn 10096028303): post C (80.00826) worse than effective baseline B (86)
- property **711236** (uprn 10096399560): post C (79.7374) worse than effective baseline A (93)
- property **711238** (uprn 10096028358): post C (78.695816) worse than effective baseline B (91)
- property **711312** (uprn 10096028346): post C (80.1876) worse than effective baseline A (93)
- property **711376** (uprn 10096028359): post C (78.47655) worse than effective baseline B (91)
- property **711489** (uprn 10096028304): post C (80.3187) worse than effective baseline B (86)
- property **711523** (uprn 10096399562): post C (79.7374) worse than effective baseline A (93)
- property **711524** (uprn 10096028360): post C (78.47655) worse than effective baseline B (91)
- property **711539** (uprn 68159801): post D (66.09142) worse than effective baseline C (71)
- property **711575** (uprn 10096028347): post C (80.1876) worse than effective baseline A (94)
- property **711639** (uprn 10096028361): post C (78.88584) worse than effective baseline B (91)
- property **711706** (uprn 10096028305): post C (78.71836) worse than effective baseline B (85)
- property **711715** (uprn 10013924857): post C (79.91303) worse than effective baseline B (81)
- property **711732** (uprn 100062190435): post C (77.935425) worse than effective baseline B (82)
- property **711795** (uprn 10090343115): post E (52.785263) worse than effective baseline C (73)
- property **711821** (uprn 10096399575): post C (80.209465) worse than effective baseline A (93)
- property **711824** (uprn 10094615095): post C (78.01443) worse than effective baseline A (92)
- property **711858** (uprn 22245014): post C (79.352325) worse than effective baseline B (81)
- property **711881** (uprn 10023444014): post C (79.504456) worse than effective baseline B (81)
- property **711897** (uprn 10012025246): post C (76.70494) worse than effective baseline B (81)
- property **711898** (uprn 10094615103): post C (79.97074) worse than effective baseline A (92)
- property **711917** (uprn 10012027840): post C (72.92677) worse than effective baseline B (81)
- … and 313 more (see CSV)
## plan-score-below-baseline (HIGH) — 1631
- property **709790** (uprn 10023443426): post SAP 74.2 below effective baseline 76.0 (Δ-1.8)
- property **709810** (uprn 10096028301): post SAP 78.0 below effective baseline 85.0 (Δ-7.0)
- property **709846** (uprn 10096399556): post SAP 79.7 below effective baseline 93.0 (Δ-13.3)
- property **709847** (uprn 10096028354): post SAP 78.7 below effective baseline 91.0 (Δ-12.3)
- property **709850** (uprn 10096028348): post SAP 78.8 below effective baseline 89.0 (Δ-10.2)
- property **709959** (uprn 10096028306): post SAP 78.1 below effective baseline 84.0 (Δ-5.9)
- property **709970** (uprn 10013924859): post SAP 79.2 below effective baseline 80.0 (Δ-0.8)
- property **709975** (uprn 200001466609): post SAP 72.3 below effective baseline 79.0 (Δ-6.7)
- property **710011** (uprn 10096028349): post SAP 78.8 below effective baseline 89.0 (Δ-10.2)
- property **710071** (uprn 10002918889): post SAP 76.1 below effective baseline 82.0 (Δ-5.9)
- property **710075** (uprn 10002918890): post SAP 79.8 below effective baseline 82.0 (Δ-2.2)
- property **710117** (uprn 10096399566): post SAP 79.9 below effective baseline 93.0 (Δ-13.1)
- property **710121** (uprn 10096028307): post SAP 78.8 below effective baseline 90.0 (Δ-11.2)
- property **710122** (uprn 100090187902): post SAP 72.4 below effective baseline 74.0 (Δ-1.6)
- property **710131** (uprn 10096028350): post SAP 80.1 below effective baseline 94.0 (Δ-13.9)
- property **710140** (uprn 10023302889): post SAP 76.9 below effective baseline 79.0 (Δ-2.1)
- property **710201** (uprn 10090944222): post SAP 74.6 below effective baseline 82.0 (Δ-7.4)
- property **710222** (uprn 10096399567): post SAP 79.8 below effective baseline 93.0 (Δ-13.2)
- property **710225** (uprn 10096028308): post SAP 78.8 below effective baseline 90.0 (Δ-11.2)
- property **710241** (uprn 10096028351): post SAP 80.1 below effective baseline 94.0 (Δ-13.9)
- property **710274** (uprn 10013924864): post SAP 74.8 below effective baseline 76.0 (Δ-1.2)
- property **710326** (uprn 10096028309): post SAP 78.6 below effective baseline 89.0 (Δ-10.4)
- property **710335** (uprn 10096028352): post SAP 80.0 below effective baseline 94.0 (Δ-14.0)
- property **710362** (uprn 10090317710): post SAP 75.5 below effective baseline 79.0 (Δ-3.5)
- property **710395** (uprn 10096028310): post SAP 79.5 below effective baseline 91.0 (Δ-11.5)
- property **710472** (uprn 10013918445): post SAP 76.9 below effective baseline 79.0 (Δ-2.1)
- property **710474** (uprn 10096028355): post SAP 78.5 below effective baseline 91.0 (Δ-12.5)
- property **710479** (uprn 10096028353): post SAP 80.3 below effective baseline 95.0 (Δ-14.7)
- property **710525** (uprn 10012028784): post SAP 69.6 below effective baseline 79.0 (Δ-9.4)
- property **710537** (uprn 10096028311): post SAP 79.5 below effective baseline 91.0 (Δ-11.5)
- property **710540** (uprn 100060714157): post SAP 68.2 below effective baseline 71.0 (Δ-2.8)
- property **710544** (uprn 10008052006): post SAP 81.1 below effective baseline 83.0 (Δ-1.9)
- property **710552** (uprn 100060706143): post SAP 83.0 below effective baseline 85.0 (Δ-2.0)
- property **710569** (uprn 200000546526): post SAP 70.8 below effective baseline 73.0 (Δ-2.2)
- property **710576** (uprn 100090187795): post SAP 72.4 below effective baseline 75.0 (Δ-2.6)
- property **710645** (uprn 10009433145): post SAP 82.4 below effective baseline 83.0 (Δ-0.6)
- property **710650** (uprn 200001530089): post SAP 72.9 below effective baseline 74.0 (Δ-1.1)
- property **710686** (uprn 10009433147): post SAP 82.1 below effective baseline 83.0 (Δ-0.9)
- property **710760** (uprn 10096028302): post SAP 78.3 below effective baseline 85.0 (Δ-6.7)
- property **710785** (uprn 44006007): post SAP 75.3 below effective baseline 76.0 (Δ-0.7)
- property **710792** (uprn 5300059024): post SAP 75.7 below effective baseline 79.0 (Δ-3.3)
- property **710800** (uprn 10096399558): post SAP 79.7 below effective baseline 93.0 (Δ-13.3)
- property **710802** (uprn 10096028356): post SAP 78.5 below effective baseline 91.0 (Δ-12.5)
- property **710841** (uprn 200003688154): post SAP 70.3 below effective baseline 72.0 (Δ-1.7)
- property **710895** (uprn 100020475290): post SAP 72.4 below effective baseline 73.0 (Δ-0.6)
- property **710911** (uprn 10010221820): post SAP 82.2 below effective baseline 84.0 (Δ-1.8)
- property **710946** (uprn 44012846): post SAP 79.8 below effective baseline 81.0 (Δ-1.2)
- property **710955** (uprn 10090844951): post SAP 78.1 below effective baseline 79.0 (Δ-0.9)
- property **710988** (uprn 10023371802): post SAP 73.3 below effective baseline 75.0 (Δ-1.7)
- property **711014** (uprn 5300088717): post SAP 72.5 below effective baseline 74.0 (Δ-1.5)
- … and 1581 more (see CSV)
## already-meets-goal-with-works (MEDIUM) — 1162
- property **709775** (uprn 100020933699): already C >= goal C but cost_of_works £32
- property **709875** (uprn 100020973465): already C >= goal C but cost_of_works £38
- property **709986** (uprn 200000539408): already C >= goal C but cost_of_works £32
- property **709992** (uprn 100022908998): already C >= goal C but cost_of_works £555
- property **710005** (uprn 100090178307): already C >= goal C but cost_of_works £21
- property **710033** (uprn 100022920891): already C >= goal C but cost_of_works £855
- property **710084** (uprn 200003444301): already C >= goal C but cost_of_works £505
- property **710185** (uprn 100020650847): already C >= goal C but cost_of_works £455
- property **710221** (uprn 6701328): already C >= goal C but cost_of_works £655
- property **710247** (uprn 100020432467): already C >= goal C but cost_of_works £555
- property **710298** (uprn 100020220397): already C >= goal C but cost_of_works £4515
- property **710331** (uprn 100020492824): already C >= goal C but cost_of_works £855
- property **710400** (uprn 100021011618): already C >= goal C but cost_of_works £3725
- property **710484** (uprn 100060316083): already C >= goal C but cost_of_works £24
- property **710517** (uprn 100020951618): already C >= goal C but cost_of_works £1029
- property **710525** (uprn 10012028784): already C >= goal C but cost_of_works £11539
- property **710539** (uprn 100021973960): already C >= goal C but cost_of_works £505
- property **710540** (uprn 100060714157): already C >= goal C but cost_of_works £11600
- property **710555** (uprn 100060718869): already C >= goal C but cost_of_works £28
- property **710574** (uprn 100020947162): already C >= goal C but cost_of_works £28
- property **710690** (uprn 200003688150): already C >= goal C but cost_of_works £505
- property **710703** (uprn 100020438146): already C >= goal C but cost_of_works £32
- property **710707** (uprn 100061820799): already C >= goal C but cost_of_works £32
- property **710713** (uprn 200003497667): already C >= goal C but cost_of_works £11082
- property **710828** (uprn 100060714174): already C >= goal C but cost_of_works £21
- property **710889** (uprn 200003469113): already C >= goal C but cost_of_works £455
- property **711058** (uprn 5300059062): already C >= goal C but cost_of_works £3962
- property **711096** (uprn 100020455891): already C >= goal C but cost_of_works £555
- property **711099** (uprn 100020229279): already C >= goal C but cost_of_works £1029
- property **711334** (uprn 5300036435): already C >= goal C but cost_of_works £476
- property **711390** (uprn 100023263012): already C >= goal C but cost_of_works £505
- property **711539** (uprn 68159801): already C >= goal C but cost_of_works £2436
- property **711679** (uprn 100021005287): already C >= goal C but cost_of_works £455
- property **711694** (uprn 100062191221): already C >= goal C but cost_of_works £18
- property **711795** (uprn 10090343115): already C >= goal C but cost_of_works £662
- property **712249** (uprn 100021939292): already C >= goal C but cost_of_works £1029
- property **712279** (uprn 10090343152): already C >= goal C but cost_of_works £1029
- property **712426** (uprn 100062208849): already C >= goal C but cost_of_works £530
- property **712446** (uprn 10090343167): already C >= goal C but cost_of_works £1029
- property **712690** (uprn 10035061455): already C >= goal C but cost_of_works £2790
- property **712697** (uprn 100060742520): already C >= goal C but cost_of_works £416
- property **712764** (uprn 100060675900): already C >= goal C but cost_of_works £32
- property **712766** (uprn 100021961011): already C >= goal C but cost_of_works £2566
- property **712801** (uprn 100061734876): already C >= goal C but cost_of_works £555
- property **712831** (uprn 100061736377): already C >= goal C but cost_of_works £1282
- property **712864** (uprn 100061738178): already C >= goal C but cost_of_works £455
- property **712865** (uprn 100061738325): already C >= goal C but cost_of_works £14266
- property **712869** (uprn 100061738604): already C >= goal C but cost_of_works £18
- property **712874** (uprn 100061757360): already C >= goal C but cost_of_works £755
- property **712899** (uprn 100061739860): already C >= goal C but cost_of_works £28
- … and 1112 more (see CSV)
## excessive-solar-sap (MEDIUM) — 51
- property **710374** (uprn 10090342188): solar PV alone earns 26.1 SAP points (likely oversized array)
- property **713238** (uprn 200004784741): solar PV alone earns 32.6 SAP points (likely oversized array)
- property **714227** (uprn 100061738603): solar PV alone earns 33.9 SAP points (likely oversized array)
- property **714257** (uprn 100061740105): solar PV alone earns 29.8 SAP points (likely oversized array)
- property **714511** (uprn 100061761168): solar PV alone earns 32.3 SAP points (likely oversized array)
- property **714977** (uprn 100061761946): solar PV alone earns 31.4 SAP points (likely oversized array)
- property **715563** (uprn 100061761947): solar PV alone earns 28.5 SAP points (likely oversized array)
- property **715595** (uprn 10002468656): solar PV alone earns 29.3 SAP points (likely oversized array)
- property **715740** (uprn 100061764300): solar PV alone earns 27.3 SAP points (likely oversized array)
- property **716734** (uprn 10002469029): solar PV alone earns 31.0 SAP points (likely oversized array)
- property **716740** (uprn 100061753334): solar PV alone earns 31.1 SAP points (likely oversized array)
- property **717201** (uprn 100091206047): solar PV alone earns 31.3 SAP points (likely oversized array)
- property **718323** (uprn 10002472608): solar PV alone earns 25.2 SAP points (likely oversized array)
- property **718371** (uprn 100061764075): solar PV alone earns 35.5 SAP points (likely oversized array)
- property **718890** (uprn 100062344151): solar PV alone earns 31.9 SAP points (likely oversized array)
- property **719064** (uprn 100021004675): solar PV alone earns 25.6 SAP points (likely oversized array)
- property **719080** (uprn 100091206033): solar PV alone earns 31.3 SAP points (likely oversized array)
- property **719829** (uprn 100091206052): solar PV alone earns 31.6 SAP points (likely oversized array)
- property **720039** (uprn 100061741993): solar PV alone earns 32.6 SAP points (likely oversized array)
- property **720104** (uprn 100061764078): solar PV alone earns 29.9 SAP points (likely oversized array)
- property **721044** (uprn 100061764081): solar PV alone earns 33.9 SAP points (likely oversized array)
- property **721222** (uprn 100062480566): solar PV alone earns 27.0 SAP points (likely oversized array)
- property **722986** (uprn 100061742571): solar PV alone earns 29.8 SAP points (likely oversized array)
- property **723096** (uprn 100061763625): solar PV alone earns 34.5 SAP points (likely oversized array)
- property **723516** (uprn 100061741124): solar PV alone earns 31.0 SAP points (likely oversized array)
- property **723528** (uprn 100061742003): solar PV alone earns 30.9 SAP points (likely oversized array)
- property **724609** (uprn 100061742008): solar PV alone earns 27.0 SAP points (likely oversized array)
- property **725224** (uprn 100062480573): solar PV alone earns 27.6 SAP points (likely oversized array)
- property **725266** (uprn 10034506076): solar PV alone earns 38.5 SAP points (likely oversized array)
- property **725603** (uprn 100061761161): solar PV alone earns 35.6 SAP points (likely oversized array)
- property **727422** (uprn 200001645537): solar PV alone earns 29.4 SAP points (likely oversized array)
- property **727688** (uprn 100061761162): solar PV alone earns 28.8 SAP points (likely oversized array)
- property **727699** (uprn 200004784746): solar PV alone earns 27.3 SAP points (likely oversized array)
- property **727968** (uprn 100061740165): solar PV alone earns 34.5 SAP points (likely oversized array)
- property **728344** (uprn 100061764582): solar PV alone earns 26.9 SAP points (likely oversized array)
- property **729028** (uprn 100061735428): solar PV alone earns 32.0 SAP points (likely oversized array)
- property **729329** (uprn 100061749297): solar PV alone earns 30.2 SAP points (likely oversized array)
- property **729415** (uprn 100091206037): solar PV alone earns 31.5 SAP points (likely oversized array)
- property **731052** (uprn 100021958934): solar PV alone earns 29.3 SAP points (likely oversized array)
- property **731481** (uprn 100021935669): solar PV alone earns 25.1 SAP points (likely oversized array)
- property **731812** (uprn 200001645541): solar PV alone earns 32.1 SAP points (likely oversized array)
- property **731994** (uprn 10023371767): solar PV alone earns 25.3 SAP points (likely oversized array)
- property **732041** (uprn 100061761165): solar PV alone earns 30.1 SAP points (likely oversized array)
- property **732050** (uprn 100061764295): solar PV alone earns 30.5 SAP points (likely oversized array)
- property **732860** (uprn 100061733678): solar PV alone earns 32.7 SAP points (likely oversized array)
- property **733394** (uprn 100061748753): solar PV alone earns 25.1 SAP points (likely oversized array)
- property **739556** (uprn 100061754547): solar PV alone earns 31.3 SAP points (likely oversized array)
- property **739814** (uprn 200003724253): solar PV alone earns 31.6 SAP points (likely oversized array)
- property **739998** (uprn 100090224018): solar PV alone earns 25.9 SAP points (likely oversized array)
- property **740214** (uprn 100061762820): solar PV alone earns 30.4 SAP points (likely oversized array)
- … and 1 more (see CSV)
## low-solar-bill-savings (MEDIUM) — 83
- property **710178** (uprn 100020465019): solar PV bill saving only £49/yr — check self-consumption / SEG export
- property **711663** (uprn 100020481859): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **712943** (uprn 100061741910): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **713299** (uprn 100060331540): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **713607** (uprn 100090108855): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **713631** (uprn 10002470912): solar PV bill saving only £49/yr — check self-consumption / SEG export
- property **713743** (uprn 100060359906): solar PV bill saving only £36/yr — check self-consumption / SEG export
- property **715182** (uprn 100060726237): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **715701** (uprn 100021001715): solar PV bill saving only £50/yr — check self-consumption / SEG export
- property **716152** (uprn 100090108859): solar PV bill saving only £43/yr — check self-consumption / SEG export
- property **716268** (uprn 100061763778): solar PV bill saving only £36/yr — check self-consumption / SEG export
- property **716289** (uprn 200003688201): solar PV bill saving only £43/yr — check self-consumption / SEG export
- property **716659** (uprn 100061747951): solar PV bill saving only £41/yr — check self-consumption / SEG export
- property **716693** (uprn 100061733911): solar PV bill saving only £39/yr — check self-consumption / SEG export
- property **717392** (uprn 100020450723): solar PV bill saving only £45/yr — check self-consumption / SEG export
- property **717661** (uprn 6176751): solar PV bill saving only £42/yr — check self-consumption / SEG export
- property **718073** (uprn 100021017285): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **718298** (uprn 100061741870): solar PV bill saving only £43/yr — check self-consumption / SEG export
- property **718567** (uprn 100091206050): solar PV bill saving only £50/yr — check self-consumption / SEG export
- property **719647** (uprn 100020590930): solar PV bill saving only £49/yr — check self-consumption / SEG export
- property **720335** (uprn 100020594071): solar PV bill saving only £36/yr — check self-consumption / SEG export
- property **720677** (uprn 100020969500): solar PV bill saving only £30/yr — check self-consumption / SEG export
- property **721018** (uprn 202140958): solar PV bill saving only £44/yr — check self-consumption / SEG export
- property **721529** (uprn 100060696301): solar PV bill saving only £43/yr — check self-consumption / SEG export
- property **721664** (uprn 100061765249): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **721895** (uprn 100060726253): solar PV bill saving only £48/yr — check self-consumption / SEG export
- property **722155** (uprn 100061758903): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **722390** (uprn 100020944918): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **722910** (uprn 10009428749): solar PV bill saving only £41/yr — check self-consumption / SEG export
- property **723298** (uprn 202140961): solar PV bill saving only £45/yr — check self-consumption / SEG export
- property **723578** (uprn 100020986231): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **723748** (uprn 100061757556): solar PV bill saving only £48/yr — check self-consumption / SEG export
- property **723965** (uprn 100061761860): solar PV bill saving only £50/yr — check self-consumption / SEG export
- property **724311** (uprn 100061765331): solar PV bill saving only £30/yr — check self-consumption / SEG export
- property **724331** (uprn 100022008224): solar PV bill saving only £22/yr — check self-consumption / SEG export
- property **724850** (uprn 100060719545): solar PV bill saving only £50/yr — check self-consumption / SEG export
- property **725287** (uprn 100062187008): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **725585** (uprn 100061752475): solar PV bill saving only £44/yr — check self-consumption / SEG export
- property **725807** (uprn 100020996065): solar PV bill saving only £40/yr — check self-consumption / SEG export
- property **725969** (uprn 202141567): solar PV bill saving only £31/yr — check self-consumption / SEG export
- property **726419** (uprn 100061809723): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **726507** (uprn 100061752706): solar PV bill saving only £48/yr — check self-consumption / SEG export
- property **726627** (uprn 100061152652): solar PV bill saving only £42/yr — check self-consumption / SEG export
- property **726735** (uprn 100061809725): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **726998** (uprn 100061809727): solar PV bill saving only £46/yr — check self-consumption / SEG export
- property **727114** (uprn 100061809728): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **727143** (uprn 202141571): solar PV bill saving only £45/yr — check self-consumption / SEG export
- property **727820** (uprn 100061809729): solar PV bill saving only £47/yr — check self-consumption / SEG export
- property **727994** (uprn 202141572): solar PV bill saving only £45/yr — check self-consumption / SEG export
- property **728027** (uprn 10090265602): solar PV bill saving only £45/yr — check self-consumption / SEG export
- … and 33 more (see CSV)
## zero-works-post-differs (MEDIUM) — 5624
- property **709772** (uprn 10093116528): £0 works but post SAP 80.3 != effective 79.0
- property **709773** (uprn 10093116543): £0 works but post SAP 78.5 != effective 77.0
- property **709774** (uprn 10093116529): £0 works but post SAP 76.8 != effective 75.0
- property **709777** (uprn 10094601392): £0 works but post SAP 77.2 != effective 74.0
- property **709778** (uprn 10023444324): £0 works but post SAP 76.9 != effective 75.0
- property **709779** (uprn 10092970673): £0 works but post SAP 70.9 != effective 61.0
- property **709780** (uprn 10094601287): £0 works but post SAP 77.7 != effective 75.0
- property **709783** (uprn 10094601162): £0 works but post SAP 78.1 != effective 74.0
- property **709784** (uprn 10090844948): £0 works but post SAP 76.4 != effective 73.0
- property **709786** (uprn 10093114053): £0 works but post SAP 81.3 != effective 76.0
- property **709787** (uprn 10091568921): £0 works but post SAP 79.4 != effective 77.0
- property **709788** (uprn 10093718424): £0 works but post SAP 80.0 != effective 78.0
- property **709790** (uprn 10023443426): £0 works but post SAP 74.2 != effective 76.0
- property **709791** (uprn 10093412452): £0 works but post SAP 80.2 != effective 79.0
- property **709792** (uprn 6199384): £0 works but post SAP 80.5 != effective 78.0
- property **709793** (uprn 10014314798): £0 works but post SAP 73.8 != effective 72.0
- property **709794** (uprn 10094601294): £0 works but post SAP 78.1 != effective 76.0
- property **709795** (uprn 10090343335): £0 works but post SAP 84.0 != effective 82.0
- property **709796** (uprn 10093115480): £0 works but post SAP 78.8 != effective 77.0
- property **709798** (uprn 10094601226): £0 works but post SAP 77.3 != effective 75.0
- property **709800** (uprn 6701369): £0 works but post SAP 82.5 != effective 78.0
- property **709801** (uprn 202211152): £0 works but post SAP 81.8 != effective 79.0
- property **709803** (uprn 10093394010): £0 works but post SAP 79.9 != effective 78.0
- property **709806** (uprn 10090341811): £0 works but post SAP 80.2 != effective 78.0
- property **709808** (uprn 10093117227): £0 works but post SAP 77.6 != effective 76.0
- property **709809** (uprn 10023444170): £0 works but post SAP 80.3 != effective 78.0
- property **709810** (uprn 10096028301): £0 works but post SAP 78.0 != effective 85.0
- property **709813** (uprn 10094601280): £0 works but post SAP 77.9 != effective 75.0
- property **709814** (uprn 10093386418): £0 works but post SAP 78.8 != effective 76.0
- property **709817** (uprn 10094895444): £0 works but post SAP 79.6 != effective 77.0
- property **709818** (uprn 10092973960): £0 works but post SAP 77.6 != effective 74.0
- property **709819** (uprn 10012028763): £0 works but post SAP 83.3 != effective 82.0
- property **709820** (uprn 10093049867): £0 works but post SAP 78.7 != effective 75.0
- property **709821** (uprn 10093116336): £0 works but post SAP 79.9 != effective 78.0
- property **709823** (uprn 10093116334): £0 works but post SAP 79.0 != effective 78.0
- property **709824** (uprn 44042992): £0 works but post SAP 79.9 != effective 79.0
- property **709825** (uprn 10014314853): £0 works but post SAP 72.0 != effective 70.0
- property **709828** (uprn 10091636116): £0 works but post SAP 75.8 != effective 71.0
- property **709829** (uprn 10094601381): £0 works but post SAP 78.4 != effective 74.0
- property **709830** (uprn 10093049853): £0 works but post SAP 81.5 != effective 78.0
- property **709831** (uprn 10093390790): £0 works but post SAP 74.9 != effective 72.0
- property **709832** (uprn 10093116330): £0 works but post SAP 80.1 != effective 79.0
- property **709833** (uprn 10093116326): £0 works but post SAP 80.1 != effective 79.0
- property **709834** (uprn 10094601351): £0 works but post SAP 79.0 != effective 76.0
- property **709835** (uprn 10090317693): £0 works but post SAP 77.5 != effective 76.0
- property **709836** (uprn 10090034872): £0 works but post SAP 81.4 != effective 79.0
- property **709837** (uprn 10093115985): £0 works but post SAP 79.5 != effective 77.0
- property **709842** (uprn 202211170): £0 works but post SAP 82.1 != effective 79.0
- property **709845** (uprn 6701311): £0 works but post SAP 81.4 != effective 78.0
- property **709846** (uprn 10096399556): £0 works but post SAP 79.7 != effective 93.0
- … and 5574 more (see CSV)
## effective-lodged-divergence (LOW) — 1527
- property **709779** (uprn 10092970673): effective 61 vs lodged 86 (Δ-25, reason=pre_sap10)
- property **709802** (uprn 100020665611): effective 52 vs lodged 37 (Δ+15, reason=pre_sap10)
- property **709804** (uprn 10093388044): effective 33 vs lodged 93 (Δ-60, reason=pre_sap10)
- property **709815** (uprn 100090108846): effective 57 vs lodged 79 (Δ-22, reason=pre_sap10)
- property **709828** (uprn 10091636116): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10)
- property **709860** (uprn 100061086424): effective 60 vs lodged 83 (Δ-23, reason=pre_sap10)
- property **709863** (uprn 10090342180): effective 39 vs lodged 78 (Δ-39, reason=pre_sap10)
- property **709874** (uprn 10093388053): effective 35 vs lodged 94 (Δ-59, reason=pre_sap10)
- property **709892** (uprn 10090343767): effective 75 vs lodged 91 (Δ-16, reason=pre_sap10)
- property **709945** (uprn 10090342181): effective 42 vs lodged 81 (Δ-39, reason=pre_sap10)
- property **709993** (uprn 15043874): effective 68 vs lodged 49 (Δ+19, reason=pre_sap10)
- property **710003** (uprn 10091636410): effective 71 vs lodged 86 (Δ-15, reason=pre_sap10)
- property **710009** (uprn 100022895379): effective 62 vs lodged 80 (Δ-18, reason=pre_sap10)
- property **710019** (uprn 10090342182): effective 44 vs lodged 80 (Δ-36, reason=pre_sap10)
- property **710025** (uprn 10093388055): effective 55 vs lodged 93 (Δ-38, reason=pre_sap10)
- property **710037** (uprn 100090108857): effective 56 vs lodged 86 (Δ-30, reason=pre_sap10)
- property **710091** (uprn 100061086427): effective 60 vs lodged 83 (Δ-23, reason=pre_sap10)
- property **710114** (uprn 10096026315): effective 74 vs lodged 89 (Δ-15, reason=pre_sap10)
- property **710134** (uprn 10096026271): effective 73 vs lodged 88 (Δ-15, reason=pre_sap10)
- property **710139** (uprn 10090342183): effective 41 vs lodged 77 (Δ-36, reason=pre_sap10)
- property **710144** (uprn 10014314832): effective 63 vs lodged 83 (Δ-20, reason=pre_sap10)
- property **710148** (uprn 10093388056): effective 57 vs lodged 94 (Δ-37, reason=pre_sap10)
- property **710191** (uprn 10090342184): effective 49 vs lodged 79 (Δ-30, reason=pre_sap10)
- property **710198** (uprn 10012138502): effective 65 vs lodged 85 (Δ-20, reason=pre_sap10)
- property **710199** (uprn 10093388057): effective 38 vs lodged 95 (Δ-57, reason=pre_sap10)
- property **710202** (uprn 10090342864): effective 68 vs lodged 84 (Δ-16, reason=pre_sap10)
- property **710244** (uprn 10010249676): effective 74 vs lodged 90 (Δ-16, reason=pre_sap10)
- property **710246** (uprn 100061086430): effective 59 vs lodged 83 (Δ-24, reason=pre_sap10)
- property **710250** (uprn 10090342185): effective 62 vs lodged 81 (Δ-19, reason=pre_sap10)
- property **710260** (uprn 10093388058): effective 47 vs lodged 95 (Δ-48, reason=pre_sap10)
- property **710261** (uprn 10090341825): effective 75 vs lodged 91 (Δ-16, reason=pre_sap10)
- property **710300** (uprn 10090341826): effective 75 vs lodged 91 (Δ-16, reason=pre_sap10)
- property **710342** (uprn 10090342187): effective 37 vs lodged 77 (Δ-40, reason=pre_sap10)
- property **710348** (uprn 10090341827): effective 76 vs lodged 91 (Δ-15, reason=pre_sap10)
- property **710429** (uprn 10093388045): effective 49 vs lodged 93 (Δ-44, reason=pre_sap10)
- property **710449** (uprn 100020993501): effective 56 vs lodged 5 (Δ+51, reason=pre_sap10)
- property **710491** (uprn 10014314835): effective 69 vs lodged 84 (Δ-15, reason=pre_sap10)
- property **710536** (uprn 100062482536): effective 38 vs lodged 53 (Δ-15, reason=pre_sap10)
- property **710640** (uprn 10014316004): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10)
- property **710658** (uprn 10014316005): effective 70 vs lodged 88 (Δ-18, reason=pre_sap10)
- property **710659** (uprn 22061763): effective 50 vs lodged 70 (Δ-20, reason=pre_sap10)
- property **710664** (uprn 10096026322): effective 74 vs lodged 89 (Δ-15, reason=pre_sap10)
- property **710672** (uprn 200003378407): effective 48 vs lodged 28 (Δ+20, reason=pre_sap10)
- property **710683** (uprn 10014316006): effective 70 vs lodged 88 (Δ-18, reason=pre_sap10)
- property **710704** (uprn 10014316007): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10)
- property **710745** (uprn 10093386244): effective 50 vs lodged 83 (Δ-33, reason=pre_sap10)
- property **710752** (uprn 10093388046): effective 47 vs lodged 94 (Δ-47, reason=pre_sap10)
- property **710779** (uprn 10091636118): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10)
- property **710814** (uprn 10014316008): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10)
- property **710837** (uprn 10014316009): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10)
- … and 1477 more (see CSV)
## negative-bill-savings (LOW) — 100
- property **712847** (uprn 100061737192): energy_bill_savings £-30/yr on £16110 of works
- property **713453** (uprn 100061739869): energy_bill_savings £-68/yr on £14533 of works
- property **713721** (uprn 100061753373): energy_bill_savings £-18/yr on £14554 of works
- property **713743** (uprn 100060359906): energy_bill_savings £-98/yr on £18823 of works
- property **713791** (uprn 10013528635): energy_bill_savings £-130/yr on £1029 of works
- property **713970** (uprn 10013528640): energy_bill_savings £-130/yr on £1029 of works
- property **714001** (uprn 10013528641): energy_bill_savings £-130/yr on £1029 of works
- property **714038** (uprn 10013528642): energy_bill_savings £-130/yr on £1029 of works
- property **714373** (uprn 100062185281): energy_bill_savings £-113/yr on £14800 of works
- property **714420** (uprn 10010242243): energy_bill_savings £-162/yr on £1029 of works
- property **715227** (uprn 100061753375): energy_bill_savings £-18/yr on £14554 of works
- property **715561** (uprn 100061739874): energy_bill_savings £-69/yr on £14533 of works
- property **715999** (uprn 100061737204): energy_bill_savings £-13/yr on £14533 of works
- property **716169** (uprn 100061747915): energy_bill_savings £-53/yr on £14533 of works
- property **716251** (uprn 100061753377): energy_bill_savings £-18/yr on £14554 of works
- property **716548** (uprn 100061765632): energy_bill_savings £-70/yr on £14533 of works
- property **716811** (uprn 22106963): energy_bill_savings £-3/yr on £4785 of works
- property **717209** (uprn 100061753379): energy_bill_savings £-20/yr on £14554 of works
- property **717675** (uprn 6176752): energy_bill_savings £-75/yr on £15067 of works
- property **717998** (uprn 100061749651): energy_bill_savings £-7/yr on £14533 of works
- property **719002** (uprn 10002476550): energy_bill_savings £-101/yr on £14533 of works
- property **719091** (uprn 100061753365): energy_bill_savings £-46/yr on £14533 of works
- property **719449** (uprn 100061753383): energy_bill_savings £-18/yr on £14554 of works
- property **719680** (uprn 100061741246): energy_bill_savings £-34/yr on £17072 of works
- property **719811** (uprn 100060327562): energy_bill_savings £-92/yr on £14533 of works
- property **720349** (uprn 100021812910): energy_bill_savings £-122/yr on £1029 of works
- property **720364** (uprn 100061741682): energy_bill_savings £-51/yr on £15070 of works
- property **720800** (uprn 100061753387): energy_bill_savings £-20/yr on £14554 of works
- property **720889** (uprn 10023224588): energy_bill_savings £-172/yr on £1029 of works
- property **720989** (uprn 100061764553): energy_bill_savings £-143/yr on £14533 of works
- property **721034** (uprn 100061758484): energy_bill_savings £-145/yr on £15105 of works
- property **721070** (uprn 100061749663): energy_bill_savings £-69/yr on £14800 of works
- property **722402** (uprn 100061737194): energy_bill_savings £-73/yr on £14266 of works
- property **722448** (uprn 100061739862): energy_bill_savings £-68/yr on £14533 of works
- property **722732** (uprn 100061750203): energy_bill_savings £-155/yr on £16445 of works
- property **722808** (uprn 100062186150): energy_bill_savings £-61/yr on £14533 of works
- property **723367** (uprn 100061753394): energy_bill_savings £-56/yr on £14533 of works
- property **723503** (uprn 10010242632): energy_bill_savings £-112/yr on £1029 of works
- property **723540** (uprn 100061743541): energy_bill_savings £-35/yr on £14533 of works
- property **723771** (uprn 200001645519): energy_bill_savings £-58/yr on £14533 of works
- property **723782** (uprn 100061743542): energy_bill_savings £-30/yr on £17032 of works
- property **725039** (uprn 100061748331): energy_bill_savings £-4/yr on £14800 of works
- property **725350** (uprn 100061743145): energy_bill_savings £-189/yr on £15067 of works
- property **725715** (uprn 100061736694): energy_bill_savings £-42/yr on £14533 of works
- property **725731** (uprn 100061763962): energy_bill_savings £-71/yr on £14533 of works
- property **725988** (uprn 100061749671): energy_bill_savings £-13/yr on £14533 of works
- property **726184** (uprn 100061752891): energy_bill_savings £-66/yr on £14533 of works
- property **726268** (uprn 100061741268): energy_bill_savings £-76/yr on £14266 of works
- property **726271** (uprn 100061741692): energy_bill_savings £-63/yr on £14533 of works
- property **726302** (uprn 202141568): energy_bill_savings £-73/yr on £15721 of works
- … and 50 more (see CSV)
## unusually-high-post-sap (LOW) — 21
- property **714511** (uprn 100061761168): post SAP 100.0 (near band A) — confirm not over-credited
- property **716734** (uprn 10002469029): post SAP 100.0 (near band A) — confirm not over-credited
- property **716740** (uprn 100061753334): post SAP 100.0 (near band A) — confirm not over-credited
- property **717201** (uprn 100091206047): post SAP 100.0 (near band A) — confirm not over-credited
- property **718890** (uprn 100062344151): post SAP 100.0 (near band A) — confirm not over-credited
- property **719080** (uprn 100091206033): post SAP 100.0 (near band A) — confirm not over-credited
- property **719829** (uprn 100091206052): post SAP 100.0 (near band A) — confirm not over-credited
- property **721222** (uprn 100062480566): post SAP 98.6 (near band A) — confirm not over-credited
- property **723516** (uprn 100061741124): post SAP 100.0 (near band A) — confirm not over-credited
- property **725224** (uprn 100062480573): post SAP 95.3 (near band A) — confirm not over-credited
- property **725440** (uprn 100061746447): post SAP 100.0 (near band A) — confirm not over-credited
- property **725627** (uprn 10008885879): post SAP 99.6 (near band A) — confirm not over-credited
- property **726993** (uprn 100061757571): post SAP 100.0 (near band A) — confirm not over-credited
- property **727688** (uprn 100061761162): post SAP 97.6 (near band A) — confirm not over-credited
- property **727699** (uprn 200004784746): post SAP 100.0 (near band A) — confirm not over-credited
- property **729329** (uprn 100061749297): post SAP 99.2 (near band A) — confirm not over-credited
- property **729415** (uprn 100091206037): post SAP 100.0 (near band A) — confirm not over-credited
- property **732041** (uprn 100061761165): post SAP 97.9 (near band A) — confirm not over-credited
- property **732050** (uprn 100061764295): post SAP 98.1 (near band A) — confirm not over-credited
- property **740214** (uprn 100061762820): post SAP 99.4 (near band A) — confirm not over-credited
- property **740234** (uprn 100061761814): post SAP 100.0 (near band A) — confirm not over-credited

View file

404
scripts/audit/anomalies.py Normal file
View file

@ -0,0 +1,404 @@
"""Audit modelled Properties for *odd results* — a growing, pluggable set of
checks that read the DB and flag plans / baselines / recommendations that look
wrong, so the team can triage them instead of hunting by hand in the FE.
Run:
python -m scripts.audit_modelling_anomalies --portfolio 796
python -m scripts.audit_modelling_anomalies --portfolio 796 --severity high
python -m scripts.audit_modelling_anomalies --property 725634
Writes ``modelling_audit.md`` + ``modelling_audit.csv`` and prints a summary.
ADDING A CHECK: write a function ``(a: PropertyAudit) -> Optional[str]`` that
returns a one-line reason when the Property looks wrong (else None), and decorate
it with ``@check("kebab-name", Severity.HIGH)``. That is the whole contract the
runner discovers it, runs it over every Property, and reports the reasons. Keep
each check small and single-purpose; lean on the shared `PropertyAudit` bundle
rather than re-querying.
This registry is meant to **compound**: each audit that confirms a new
systematic problem should leave behind a check (see the `audit-ara-portfolio`
skill's self-improve phase). So every check's docstring records its
**provenance** the motivating cause and example properties so a future reader
can re-verify it and judge whether it still earns its place. A threshold should
be justified against the real distribution, not guessed.
Read-only: this script never writes to the DB.
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass
from enum import IntEnum
from typing import Callable, Optional
from sqlalchemy import text
from datatypes.epc.domain.epc import Epc
from scripts.e2e_common import build_engine, load_env
# A..G, A best — index is the rank (lower = better) for band comparisons.
_BANDS = "ABCDEFG"
def _band_rank(band: Optional[str]) -> Optional[int]:
if band is None or band not in _BANDS:
return None
return _BANDS.index(band)
def _band_of(score: Optional[float]) -> Optional[str]:
if score is None:
return None
return Epc.from_sap_score(round(score)).value
class Severity(IntEnum):
LOW = 1
MEDIUM = 2
HIGH = 3
@dataclass(frozen=True)
class PropertyAudit:
"""Everything a check needs about one modelled Property, joined once.
The *default* plan is the one shown in the FE; ``None`` when the Property has
no plan for the scenario. All performance figures are the persisted ones.
"""
property_id: int
uprn: Optional[int]
portfolio_id: int
scenario_id: Optional[int]
scenario_goal_band: Optional[str]
lodged_sap: Optional[float]
lodged_band: Optional[str]
effective_sap: Optional[float]
effective_band: Optional[str]
rebaseline_reason: Optional[str]
post_sap: Optional[float]
post_band: Optional[str]
cost_of_works: Optional[float]
energy_bill_savings: Optional[float]
energy_consumption_savings: Optional[float]
# Recommendation-level rollups for the default plan.
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
@dataclass(frozen=True)
class Anomaly:
property_id: int
uprn: Optional[int]
check: str
severity: Severity
detail: str
Check = Callable[[PropertyAudit], Optional[str]]
_REGISTRY: list[tuple[str, Severity, Check]] = []
def check(name: str, severity: Severity) -> Callable[[Check], Check]:
def register(fn: Check) -> Check:
_REGISTRY.append((name, severity, fn))
return fn
return register
# ───────────────────────── checks ─────────────────────────
# Each returns a reason string when the Property looks wrong, else None.
@check("plan-below-baseline-band", Severity.HIGH)
def _plan_below_baseline_band(a: PropertyAudit) -> Optional[str]:
"""The default plan's post-works band is WORSE than the baseline band — a
retrofit plan should never end below where the property started."""
base, post = _band_rank(a.effective_band), _band_rank(a.post_band)
if base is None or post is None or post <= base:
return None
return f"post {a.post_band} ({a.post_sap}) worse than effective baseline {a.effective_band} ({a.effective_sap})"
@check("plan-score-below-baseline", Severity.HIGH)
def _plan_score_below_baseline(a: PropertyAudit) -> Optional[str]:
"""Post-works SAP is materially BELOW the baseline SAP — works that lower the
score, or a plan/baseline computed from different pictures."""
if a.effective_sap is None or a.post_sap is None:
return None
if a.post_sap >= a.effective_sap - 0.5:
return None
return f"post SAP {a.post_sap:.1f} below effective baseline {a.effective_sap:.1f}{a.post_sap - a.effective_sap:.1f})"
@check("already-meets-goal-with-works", Severity.MEDIUM)
def _already_meets_goal_with_works(a: PropertyAudit) -> Optional[str]:
"""The property already meets/exceeds the scenario's goal band, yet the plan
spends money on measures nothing should be recommended."""
goal, base = _band_rank(a.scenario_goal_band), _band_rank(a.effective_band)
if goal is None or base is None or base > goal:
return None
if (a.cost_of_works or 0.0) <= 0.0:
return None
return f"already {a.effective_band} >= goal {a.scenario_goal_band} but cost_of_works £{a.cost_of_works:.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
rounding/derivation bug between score and rating."""
implied = _band_of(a.post_sap)
if implied is None or a.post_band is None or implied == a.post_band:
return None
return f"post_epc_rating {a.post_band} but post_sap_points {a.post_sap:.1f} implies {implied}"
@check("zero-works-post-differs", Severity.MEDIUM)
def _zero_works_post_differs(a: PropertyAudit) -> Optional[str]:
"""A no-op plan (£0 of works) whose post SAP differs from the baseline — the
baseline and the plan's starting point disagree (stale or inconsistent)."""
if a.effective_sap is None or a.post_sap is None:
return None
if (a.cost_of_works or 0.0) > 0.0:
return None
if abs(a.post_sap - a.effective_sap) <= 0.5:
return None
return f"£0 works but post SAP {a.post_sap:.1f} != effective {a.effective_sap:.1f}"
@check("effective-lodged-divergence", Severity.LOW)
def _effective_lodged_divergence(a: PropertyAudit) -> Optional[str]:
"""The Effective baseline is far from the lodged accredited figure (≥15 SAP).
Often legitimate (overrides / pre-SAP10 rebaseline), but worth a look a big
gap can also mean a bad override or a calculator divergence."""
if a.effective_sap is None or a.lodged_sap is None:
return None
gap = a.effective_sap - a.lodged_sap
if abs(gap) < 15:
return None
return f"effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f}{gap:+.0f}, reason={a.rebaseline_reason})"
@check("impossible-sap-over-100", Severity.HIGH)
def _impossible_sap_over_100(a: PropertyAudit) -> Optional[str]:
"""A SAP score above 100 is impossible (SAP caps at 100) — a calculator /
aggregation bug, or an oversized solar array pushing the score past the cap."""
offenders = [
f"{label} {value:.1f}"
for label, value in (("post", a.post_sap), ("effective", a.effective_sap))
if value is not None and value > 100.0
]
if not offenders:
return None
return "SAP > 100: " + ", ".join(offenders)
@check("excessive-solar-sap", Severity.MEDIUM)
def _excessive_solar_sap(a: PropertyAudit) -> Optional[str]:
"""A single solar PV measure earns an implausibly large slice of SAP (> 25
points; cohort avg 12.5). Usually an oversized array Google footprint
conflation borrowing a neighbour's / the whole building's roof (ADR-0038)."""
if a.solar_sap_points is None or a.solar_sap_points <= 25.0:
return None
return f"solar PV alone earns {a.solar_sap_points:.1f} SAP points (likely oversized array)"
@check("unusually-high-post-sap", Severity.LOW)
def _unusually_high_post_sap(a: PropertyAudit) -> Optional[str]:
"""Post-works SAP at the very top of the scale (>= 95, near band A) — rare for
a retrofit of existing stock; worth confirming it isn't an over-credit."""
if a.post_sap is None or a.post_sap < 95.0 or a.post_sap > 100.0:
return None
return f"post SAP {a.post_sap:.1f} (near band A) — confirm not over-credited"
@check("low-solar-bill-savings", Severity.MEDIUM)
def _low_solar_bill_savings(a: PropertyAudit) -> Optional[str]:
"""A solar PV measure that barely cuts the bill (< £50/yr, or negative). Solar
reliably saves on electricity, so a near-zero / negative figure points at a
pricing bug e.g. self-consumption or SEG export not credited (the Saltmead
case: solar, DC, but only £62/yr)."""
if a.solar_bill_savings is None or a.solar_bill_savings >= 50.0:
return None
return f"solar PV bill saving only £{a.solar_bill_savings:.0f}/yr — check self-consumption / SEG export"
@check("negative-bill-savings", Severity.LOW)
def _negative_bill_savings(a: PropertyAudit) -> Optional[str]:
"""The plan INCREASES the annual bill — can be legitimate on a fuel-switch
(gasASHP), but a recommended plan that costs more to run is worth review."""
if a.energy_bill_savings is None or a.energy_bill_savings >= 0:
return None
if (a.cost_of_works or 0.0) <= 0.0:
return None
return f"energy_bill_savings £{a.energy_bill_savings:.0f}/yr on £{a.cost_of_works:.0f} of works"
# ─────────────────────── runner ───────────────────────
_QUERY = text(
"""
SELECT p.id, p.uprn, p.portfolio_id,
pl.scenario_id, s.goal_value AS goal_band,
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
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
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
"""
)
_ROLLUP_QUERY = text(
"""
SELECT r.property_id,
MAX(r.sap_points) FILTER (WHERE r.type = 'solar_pv') AS solar_sap,
MAX(r.energy_cost_savings) FILTER (WHERE r.type = 'solar_pv') AS solar_bill,
COUNT(*) AS n_measures
FROM recommendation r
JOIN plan pl ON pl.id = r.plan_id AND pl.is_default = TRUE
AND (:scenario_id IS NULL OR pl.scenario_id = :scenario_id)
JOIN property p ON p.id = r.property_id
WHERE (:portfolio_id IS NULL OR p.portfolio_id = :portfolio_id)
AND (:property_id IS NULL OR p.id = :property_id)
GROUP BY r.property_id
"""
)
def _load(
portfolio_id: Optional[int],
property_id: Optional[int],
scenario_id: Optional[int],
) -> list[PropertyAudit]:
engine = build_engine()
out: list[PropertyAudit] = []
params = {
"portfolio_id": portfolio_id,
"property_id": property_id,
"scenario_id": scenario_id,
}
with engine.connect() as conn:
rollups: dict[int, tuple[Optional[float], Optional[float], int]] = {
m["property_id"]: (m["solar_sap"], m["solar_bill"], m["n_measures"])
for m in (
row._mapping for row in conn.execute(_ROLLUP_QUERY, params)
)
}
for r in conn.execute(_QUERY, params):
m = r._mapping
solar_sap, solar_bill, n_measures = rollups.get(m["id"], (None, None, 0))
out.append(
PropertyAudit(
property_id=m["id"],
uprn=m["uprn"],
portfolio_id=m["portfolio_id"],
scenario_id=m["scenario_id"],
scenario_goal_band=m["goal_band"],
lodged_sap=m["lodged_sap_score"],
lodged_band=m["lodged_epc_band"],
effective_sap=m["effective_sap_score"],
effective_band=m["effective_epc_band"],
rebaseline_reason=m["rebaseline_reason"],
post_sap=m["post_sap_points"],
post_band=m["post_epc_rating"],
cost_of_works=m["cost_of_works"],
energy_bill_savings=m["energy_bill_savings"],
energy_consumption_savings=m["energy_consumption_savings"],
solar_sap_points=solar_sap,
solar_bill_savings=solar_bill,
n_measures=n_measures,
)
)
return out
def run(audits: list[PropertyAudit], min_severity: Severity) -> list[Anomaly]:
found: list[Anomaly] = []
for a in audits:
for name, severity, fn in _REGISTRY:
if severity < min_severity:
continue
detail = fn(a)
if detail is not None:
found.append(Anomaly(a.property_id, a.uprn, name, severity, detail))
found.sort(key=lambda x: (-x.severity, x.check, x.property_id))
return found
def _write_reports(anomalies: list[Anomaly], scanned: int) -> None:
with open("modelling_audit.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["property_id", "uprn", "severity", "check", "detail"])
for a in anomalies:
w.writerow([a.property_id, a.uprn, a.severity.name, a.check, a.detail])
by_check: dict[str, list[Anomaly]] = {}
for a in anomalies:
by_check.setdefault(a.check, []).append(a)
lines = [
"# Modelling anomaly audit",
"",
f"Scanned **{scanned}** properties · flagged **{len(anomalies)}** anomalies "
f"across **{len(by_check)}** checks.",
"",
]
for name in sorted(by_check, key=lambda n: (-by_check[n][0].severity, n)):
rows = by_check[name]
lines.append(f"## {name} ({rows[0].severity.name}) — {len(rows)}")
lines.append("")
for a in rows[:50]:
lines.append(f"- property **{a.property_id}** (uprn {a.uprn}): {a.detail}")
if len(rows) > 50:
lines.append(f"- … and {len(rows) - 50} more (see CSV)")
lines.append("")
with open("modelling_audit.md", "w") as f:
f.write("\n".join(lines))
def main() -> None:
load_env()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--portfolio", type=int, default=None, help="portfolio_id to scan")
parser.add_argument("--property", type=int, default=None, help="a single property_id")
parser.add_argument(
"--scenario", type=int, default=None, help="restrict to one scenario_id"
)
parser.add_argument(
"--severity",
choices=[s.name.lower() for s in Severity],
default="low",
help="minimum severity to report (default: low — all)",
)
args = parser.parse_args()
min_severity = Severity[args.severity.upper()]
audits = _load(args.portfolio, args.property, args.scenario)
anomalies = run(audits, min_severity)
_write_reports(anomalies, len(audits))
print(f"scanned {len(audits)} properties · {len(anomalies)} anomalies "
f"(>= {min_severity.name})")
counts: dict[str, int] = {}
for a in anomalies:
counts[a.check] = counts.get(a.check, 0) + 1
for name, severity, _ in _REGISTRY:
if severity >= min_severity:
print(f" [{severity.name:>6}] {name}: {counts.get(name, 0)}")
print("wrote modelling_audit.md / modelling_audit.csv")
if __name__ == "__main__":
main()