diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index f9ab31566..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(python -c ' *)", - "Bash(python -m pytest tests/ -v --no-cov)", - "Bash(git -C /workspaces/model diff --stat)", - "Bash(python -c \"import moto; print\\('moto installed:', moto.__version__\\)\")", - "Bash(grep -E \"\\\\.\\(py|sql\\)$\")", - "Bash(xargs basename -a)", - "Bash(ls -la /workspaces/home 2>/dev/null | head -20)", - "Read(//workspaces/home/**)", - "Bash(command -v uv)", - "Bash(uv --version)", - "Bash(echo \"uv: $\\(uv --version\\)\")", - "Bash(python -m pyright --version)", - "Bash(npx --no-install pyright --version)", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js --outputjson etl/hubspot/hubspotClient.py etl/hubspot/hubspotDataTodB.py etl/hubspot/project_data.py etl/hubspot/scripts/scraper/main.py backend/app/db/models/hubspot_project_data.py backend/app/db/models/hubspot_deal_data.py etl/hubspot/tests/test_scraper_handler.py etl/hubspot/tests/test_hubspot_data_to_db.py etl/hubspot/tests/test_hubspot_client_integration.py)", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); s=d['summary']; print\\('errors',s['errorCount'],'warnings',s['warningCount']\\); [print\\(f\\\\\"{x['severity']}: {x['file'].split\\('/'\\)[-1]}:{x['range']['start']['line']+1} {x['rule'] if 'rule' in x else ''} -- {x['message'].splitlines\\(\\)[0]}\\\\\"\\) for x in d['generalDiagnostics'] if x['severity']=='error']\")", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js etl/hubspot/project_data.py backend/app/db/models/hubspot_project_data.py)", - "Bash(python -c \"import sqlmodel, sqlalchemy, hubspot; print\\('deps importable'\\)\")", - "Bash(python -m pytest --version)", - "Bash(python -m pytest etl/hubspot/tests/ -m \"not integration\" -p no:cacheprovider -o addopts=\"\" -q)", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js --pythonpath /usr/local/bin/python etl/hubspot/project_data.py backend/app/db/models/hubspot_project_data.py)", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js --pythonpath /usr/local/bin/python etl/hubspot/company_data.py)", - "Bash(python /tmp/inspect_project.py)", - "Read(//home/vscode/github/**)", - "Bash(find / -maxdepth 6 -type d -name assessment-model)", - "Bash(terraform fmt *)", - "Bash(terraform init *)", - "Bash(terraform validate *)", - "Bash(python -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py -q)", - "Bash(python -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py tests/repositories/landlord_overrides -q --no-cov)", - "Bash(python -m py_compile tests/infrastructure/chatgpt/test_chatgpt_column_classifier.py)", - "Bash(git add *)", - "Bash(git status *)", - "Bash(cp pyrightconfig.json /tmp/pyrightconfig.bak.json)", - "Bash(python3 -)", - "Bash(npx --yes pyright infrastructure/postgres/landlord_overrides_postgres_repository.py applications/landlord_description_overrides/handler.py tests/repositories/landlord_overrides/postgres/test_landlord_overrides_postgres_repository.py orchestration/classifiable_column.py)", - "Bash(git stash *)", - "Bash(npx --yes pyright applications/landlord_description_overrides/handler.py)", - "Bash(python3 -m pytest tests/repositories/landlord_overrides/postgres/test_landlord_overrides_postgres_repository.py -q)", - "Bash(python3 -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py tests/repositories/landlord_overrides/ -q -p no:cov)", - "Bash(python3 -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py tests/repositories/landlord_overrides/ -q)", - "Bash(cp pyrightconfig.json /tmp/pyrightconfig.bak2.json)", - "Bash(npx --yes pyright infrastructure/landlord_overrides/landlord_overrides_postgres_repository.py tests/repositories/landlord_overrides/postgres/test_landlord_overrides_postgres_repository.py)", - "Bash(GIT_EDITOR=true git rebase --continue)", - "Bash(git worktree *)", - "Bash(git branch *)", - "Bash(echo \"exit: $?\")", - "Bash(git reset *)", - "Bash(echo \"fetch-exit: $?\")", - "Bash(sed -n *)", - "Bash(set -e)", - "Bash(git rm *)", - "Bash(git ls-tree *)", - "Bash(command -v pyright)", - "Bash(git merge *)", - "Bash(git rev-list *)", - "Bash(git remote *)", - "Bash(git ls-remote *)", - "Bash(grep -v '\\\\.sample$')", - "Bash([ -f \".git/hooks/$h\" ])", - "Bash(python3 -m pytest tests/test_lambda_packaging.py -p no:cacheprovider --no-header -q)", - "Bash(python3 -m pytest tests/test_lambda_packaging.py -p no:cov --no-header -q)", - "Bash(python3 -m pytest tests/test_lambda_packaging.py --no-cov -p no:cacheprovider -q)", - "Bash(python3 -m pytest tests/test_lambda_packaging.py --no-cov -q)", - "Bash(python3 -m pyright tests/test_lambda_packaging.py)", - "Bash(python3 -m pytest tests/ --collect-only --no-cov -q)", - "Bash(python3 -c \"import yaml; [print\\(f, 'OK'\\) for f in ['.github/workflows/ddd_tests.yml','.github/workflows/unit_tests.yml'] if yaml.safe_load\\(open\\(f\\)\\)]\")", - "Bash(python3 -m pytest tests/ -q --no-cov)", - "Bash(python3 -c ' *)", - "Bash(python3 -m pyright --outputjson scripts/hyde/main.py)", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); s=d['summary']; print\\('errors:',s['errorCount'],'warnings:',s['warningCount']\\); [print\\(f\\\\\" L{e['range']['start']['line']+1}: {e['message'].splitlines\\(\\)[0]}\\\\\"\\) for e in d['generalDiagnostics'] if e['severity']=='error']\")", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); s=d['summary']; print\\('pyright errors:',s['errorCount'],'warnings:',s['warningCount']\\); [print\\(f\\\\\" L{e['range']['start']['line']+1}: {e['message'].splitlines\\(\\)[0]}\\\\\"\\) for e in d['generalDiagnostics'] if e['severity']=='error']\")", - "Bash(python3 main.py)", - "Bash(netstat -ltnp)", - "Bash(fuser 8000/tcp)", - "Bash(kill 351610 685390 351625)", - "Read(//proc/531213/net/**)", - "Bash(ps -p 351610 -o pid=)", - "Bash(python -)", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis::test_cert_omitting_sap_windows_maps_without_missing_required_field\")", - "Bash(python -m pytest datatypes/epc/domain/tests/test_from_rdsap_schema.py infrastructure/epc_client/tests/test_mapper_corpus.py)", - "Bash(git -C /workspaces/model stash push -u -- datatypes/epc/schema/rdsap_schema_20_0_0.py datatypes/epc/domain/mapper.py datatypes/epc/domain/tests/test_from_rdsap_schema.py)", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestFromRdSapSchema21_0_1::test_total_floor_area\")", - "Bash(git -C /workspaces/model stash pop)", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis::test_band_more_than_typical_scales_glazing_by_1_25\")", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis\")", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis::test_synthesised_glazing_type_routed_through_cascade\")", - "Bash(python -m pytest infrastructure/epc_client/tests/test_mapper_corpus.py -k \"wip_schema_20\" -q -p no:cov --no-header -rN)", - "Bash(python -m pytest infrastructure/epc_client/tests/test_mapper_corpus.py -k \"wip_schema_20\" -q --no-header)", - "Bash(timeout 150 python scripts/eon/find_epc_data.py)", - "Bash(python -m scripts.run_modelling_e2e --scenario-id 1266 --portfolio-id 785 --measures high_heat_retention_storage_heaters,solar_pv 709634 709635 709636 709638 709639 709640 709641 709642 709644)", - "Read(//home/vscode/.claude/skills/grill-me/**)", - "Bash(awk '{print $9, $5}')", - "Bash(grep -E \"\\\\.py$\")", - "Bash(python3 scripts/eon/harvest_certs.py)", - "Bash(python scripts/eon/harvest_certs.py)", - "Bash(git commit -q -m ' *)", - "Bash(timeout 250 npx -y pyright@1.1.410 datatypes/epc/domain/mapper.py)", - "Bash(git check-ignore *)", - "Bash(timeout 250 npx -y pyright@1.1.410 datatypes/epc/schema/rdsap_schema_18_0.py datatypes/epc/domain/mapper.py)", - "Bash(timeout 250 npx -y pyright@1.1.410 datatypes/epc/schema/rdsap_schema_17_1.py datatypes/epc/domain/mapper.py)", - "Bash(python -m scripts.run_modelling_e2e --scenario-id 1266 --portfolio-id 785 --measures high_heat_retention_storage_heaters,solar_pv 709634 709635 709636 709638 709639 709640 709641 709642 709643 709644 709645 709637)", - "Read(//workspaces/**)", - "Bash(python -m scripts.run_modelling_e2e --scenario-id 1266 --portfolio-id 785 --measures high_heat_retention_storage_heaters,solar_pv 709634)", - "Bash(python 2_export_data.py)", - "Bash(git commit -q -m 'Map RdSAP-Schema-17.0 certs to EpcPropertyData πŸŸ₯ *)", - "Bash(python -m py_compile tests/utilities/floats.py backend/app/db/functions/tests/test_portfolio_functions.py backend/documents_parser/tests/test_summary_pdf_mapper_chain.py backend/documents_parser/tests/test_heating_systems_corpus.py)", - "Bash(PYTHONPATH=/workspaces/model python -)", - "Bash(npx --no-install pyright datatypes/epc/schema/sap_schema_17_1.py datatypes/epc/domain/mapper.py datatypes/epc/domain/tests/test_from_sap_schema.py)", - "Bash(git commit -q -m 'Map full-SAP cert identity and scalar fields to EpcPropertyData 🟩 *)", - "Bash(npx --no-install pyright --outputjson datatypes/epc/domain/mapper.py)", - "Bash(git commit -q -m 'Carry full-SAP measured fabric U-value descriptions into the domain model 🟩 *)", - "Bash(python scripts/hyde/elmhurst_session.py --help)", - "Bash(python scripts/hyde/elmhurst_session.py status)", - "Bash(DISPLAY=:99 import -window root /tmp/hyde-viewer/elm_now2.png)", - "Bash(DISPLAY=:99 timeout 90 python scripts/hyde/elmhurst_explore.py \"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105&Referrer=https%3a%2f%2fmembers.elmhurstenergy.co.uk%2fRdsapZone%2fHome.aspx\")", - "Bash(DISPLAY=:99 timeout 90 python scripts/hyde/elmhurst_explore.py \"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormPropertyDescription.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105\")", - "Bash(DISPLAY=:99 timeout 90 python scripts/hyde/elmhurst_explore.py \"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105\")", - "Bash(DISPLAY=:99 timeout 150 python scripts/hyde/elmhurst_fill.py)", - "Bash(pkill -f \"rdsap10\\\\|for-testing\")", - "Bash(rm -f scripts/hyde/.elmhurst-session/Singleton*)", - "Bash(DISPLAY=:99 timeout 280 python -u scripts/hyde/elmhurst_fill.py --page property_description --commit)", - "Bash(npx --no-install pyright scripts/hyde/elmhurst_download.py)", - "Edit(/.claude/skills/expand-sap-accuracy-corpus/**)", - "Bash(PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py 10093116528)", - "Bash(PYTHONPATH=/workspaces/model python -c ' *)", - "Bash(bash scripts/hyde/start_viewer.sh)", - "Bash(DISPLAY=:99 ELMHURST_URL=\"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105\" python scripts/hyde/elmhurst_session.py login)", - "Bash(break)", - "Bash(DISPLAY=:99 timeout 90 python /tmp/check_session.py)", - "Bash(pkill -9 -f \"elm_login_hold.py\")", - "Bash(pkill -9 -f \"elmhurst-session\")", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 150 python /tmp/elm_build_dims.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_dump_full.py Walls WebFormWalls.aspx)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_walls_disc.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_roof_disc.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_dump_full.py Openings WebFormOpenings.aspx)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_win_probe.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 220 python /tmp/elm_build_openings.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_tab_probe.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_del_probe.py)", - "Bash(npx --no-install pyright datatypes/epc/domain/epc_property_data.py datatypes/epc/domain/mapper.py domain/sap10_calculator/rdsap/cert_to_inputs.py)", - "Bash(PYTHONPATH=/workspaces/model python -m pytest domain/sap10_calculator/tests/test_sap_accuracy_corpus.py -q --no-cov)", - "Bash(PYTHONPATH=/workspaces/model python -m pytest tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py -q --no-cov)", - "Edit(/.claude/skills/epc-to-elmhurst-rdsap-inputs/**)", - "Bash(python3 -c \"import sys; sys.path.insert\\(0,'scripts/hyde'\\); import elmhurst_lib; print\\('elmhurst_lib imports OK:', [f for f in dir\\(elmhurst_lib\\) if not f.startswith\\('_'\\)][:12]\\)\")", - "Bash(PYTHONPATH=/workspaces/model python -m pytest tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py tests/infrastructure/epc_client/test_sap_accuracy_corpus.py -q --no-cov)", - "Bash(PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py 10093116543)", - "Bash(echo \"---EXIT $?---\")", - "Bash(sudo apt-get update -o Dir::Etc::sourceparts=-)", - "Bash(apt-cache policy *)", - "Bash(sudo apt-get update)", - "Bash(bash scripts/hyde/start_viewer.sh restart)", - "Bash(echo \"=== listen sockets \\(port 6080 / 5900\\) ===\"; ss -ltnp 2>/dev/null | grep -E ':6080|:5900' || netstat -ltnp 2>/dev/null | grep -E ':6080|:5900' || echo \"ss/netstat unavailable\" *)", - "Read(//proc/5476/**)", - "Bash(sudo python -m playwright install-deps chromium)", - "Bash(python -m playwright install chromium)", - "Bash(python -m playwright --version)", - "Bash(pip show *)" - ], - "additionalDirectories": [ - "/tmp", - "/workspaces/model/scripts/hyde/.elmhurst-session" - ] - } -} diff --git a/.claude/skills/audit-ara-portfolio/SKILL.md b/.claude/skills/audit-ara-portfolio/SKILL.md new file mode 100644 index 000000000..6621a9f33 --- /dev/null +++ b/.claude/skills/audit-ara-portfolio/SKILL.md @@ -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 --scenario +``` + +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 2–3 representative properties in each meaningful group, reproduce +end-to-end (NO DB writes β€” never pass `--persist`): + +``` +python -m scripts.run_modelling_e2e --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 "" +``` + +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. diff --git a/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md b/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md index 1aab02980..245b357a2 100644 --- a/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md +++ b/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md @@ -180,12 +180,23 @@ Table 32 unit costs, p/kWh (`domain/sap10_calculator/tables/table_32.py`): **`main_fuel_type` / `water_heating_fuel` 29 = off-peak (7-hour) electricity** β†’ Elmhurst Electricity meter type = **Dual-rate / Economy 7 (7-hour)**, NOT Single. -⚠️ **Known over-rating bug:** the engine prices **100% of off-peak space heating -AND hot water at the 5.50p low rate** (`inputs.space_heating_fuel_cost_gbp_per_kwh` -= 0.055), instead of the SAP **Table 12a high/low split** (a portion at the 15.29p -high rate). This under-costs all-electric Economy-7 dwellings and inflates the SAP -score. Always surface this in the output's "Known divergences". Canonical case: -UPRN 10002468137 β€” lodged 55, engine 62. +βœ… **Economy-7 high/low split β€” FIXED (PR #1217).** The engine now applies the SAP +**Table 12a Grid 1** (space) + **Table 13** (immersion DHW) high/low split rather +than pricing 100% at the 5.50p low rate. Electric STORAGE heaters legitimately get +a 0.00 SH high-rate fraction (100% low β€” spec value, not a bug); immersion HW takes +the cylinder-volume/occupancy/single-dual Table 13 blend (applied in +`cert_to_inputs._hot_water_fuel_cost_gbp_per_kwh` when volume + occupancy + +single/dual are all resolved; absent any of them it still falls back to 100% low β€” +a rarer edge). Verified: canonical UPRN 10002468137 engine 60.92 = Elmhurst 61 to +the penny (its lodged 55 is the OLD SAP-2012 schema, not comparable); UPRN +10022893721 engine 79 = lodged 79, Elmhurst (Dual meter) 81. + +⚠️ **When building in Elmhurst you MUST set the Economy-7 meter** (`main_fuel_type` +/ `water_heating_fuel` 29 = off-peak 7-hour β†’ Electricity meter type **Dual**, NOT +Single). Elmhurst silently defaults to Single/Standard and prices at the 13.19p +standard rate, collapsing the worksheet SAP ~13 points β€” which can masquerade as an +engine "over-rating". The control is a hidden Meters sub-tab on the SpaceHeating +page (`TabPanelMeters_RadioButtonListElectricityType`). ## Water Heating diff --git a/.claude/skills/expand-sap-accuracy-corpus/SKILL.md b/.claude/skills/expand-sap-accuracy-corpus/SKILL.md index 9c2d40626..954264e70 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/SKILL.md +++ b/.claude/skills/expand-sap-accuracy-corpus/SKILL.md @@ -19,6 +19,74 @@ score the property would get unchanged. Elmhurst is the accredited ground truth; its Input Summary (parsed back to `EpcPropertyData`) exposes mapper holes, and its worksheet exposes calculator holes. +## ⏩ Resume in a fresh context (autonomous run) + +If the user says "continue" / "next" / "keep going through the worklist", run this +loop **continuously without asking between certs** β€” report only `eng X / elm Y` +per cert and tick the worklist line as each is pinned. The build automation is +fully working (see `scripts/hyde/elmhurst_lib.py` helpers + the `build_.py` +templates); most certs build unattended end-to-end. + +**State (2026-06-19):** pinned this campaign β€” SAP-17.1 cohort + RdSAP-17.1/18.0 +(older) plus: **16.1** `100021943298` (76/75), **19.1.0** `10096028301` (82/82), +**16.3** `44012843` (79/78), **17.0** `10023444324` (80/80) + `10023444320` +(81/82), **RdSAP-20.0.0** `10090844932` (78/77), **16.2** `100090182288` (69/71, +semi house). Latest run (2026-06-19): **16.2** `100021985993` (74/72, end-terrace +bungalow), **17.1** `10091568921` (82/80, full-SAP end-terrace house combi 17615) ++ `10093718424` (81/80, semi sibling), **RdSAP-18.0** `10022893721` (79/81, first +NON-BOILER cert β€” electric storage heaters + immersion; storage-heater automation +now SOLVED incl. the Economy-7 Dual-meter step, see banked findings; engine 79 = +lodged, NO bug), **RdSAP-21.0.1** `10023443426` (76/79, native schema, combi house; +engine 76 = lodged EXACTLY; Elmhurst +3 = omitted-secondary build gap). Next `[ ]`: +`10093412452` (SAP-17.1), then `10090343335` (17.0) / `10093115480` (17.1) / +`68151071` (RdSAP-17.0). Skip `100020933699` (user said skip), `[β›”]` (NOT +MAPPABLE), `[⚠]` (flagged engine bugs: MVHR / heat-pump fuel-39). + +**Per-UPRN recipe** (all commands `DISPLAY=:99`, cwd `scripts/hyde`; run +`bash scripts/hyde/start_viewer.sh` once; creds in `.elmhurst-creds.json`; shared +assessment GUID `B44A0DB4-4C08-4241-B818-86F060172105`): +1. `PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py ` + then scope: dwelling_type/built_form, age band, walls/roof/floor descriptions, + heating `main_heating_index_number`/category/`has_hot_water_cylinder`, window + total area (`sum(sap_windows w*h)`), party_wall_length, lighting %, MEV/AP50. +2. **Copy the closest `build_.py` template** and adjust values: + - combi flat β†’ `build_100021943298.py`; regular-boiler+cylinder flat β†’ + `build_44012843.py`; full-SAP combi flat (MEV+AP50) β†’ `build_10096028301.py` + / `build_10023444324.py` (+party wall) / `build_10023444320.py` (mid-floor); + - combi house β†’ `build_10090844932.py` (end-terrace, party wall) / + `build_100090182288.py` (semi, no party wall). + Adjust: property type/built-form, band (`_pick` by year, e.g. "1950"/"2012"/ + "2023"), two-floor dims + party wall, wall insulation, roof, floor, window mΒ², + doors, lighting, boiler PCDB ref + search query, MEV/AP50 if present. +3. Run pages: `for p in property_description [flats] dimensions walls roofs floors + openings ventilation; do … build_.py $p; done` (one Save&Close each, + ~1 min/page; flats only for Flat property type). Then a window-verify/fix + snippet (re-add the combined window if the grid shows 0.00), then + `build_.py space_heating` and `water_heating`. +4. Heating uses the `elmhurst_lib` helpers: `E.select_boiler(page, "", + "")` (look up id/type in `domain/elmhurst/pcdb_gas_oil_boiler_codes.csv`; + the lodged `main_heating_index_number` IS the id); control + `E.set_heating_dialog(page, "…ButtonMainHeatingControls", "^Boilers", + "^Standard", "CBE Programmer, room thermostat and TRVs")` (=2106); water + `E.set_heating_dialog(page, "…ButtonWaterHeatingCode", "From Space Heating", + "From the primary heating system")`; combi β†’ `E.clear_hot_water_cylinder(page)`. +5. Download: edit `elmhurst_download.py` `SAMPLE_DIR` to the cert's + `/uprn_` dir; first confirm Recommendations is clean (parse + `[id*=ContentPlaceHolder1] a` link text β€” Summary silently redirects to Address + until zero errors); `python scripts/hyde/elmhurst_download.py` (retry once; the + nav goes Addressβ†’Recommendationsβ†’Summary). +6. `PYTHONPATH=/workspaces/model python scripts/compare_epc_paths.py ` β†’ + read **"gov-API inputs β†’ SAP"** (engine) and **"Elmhurst's OWN engine + (worksheet …)"** (Elmhurst ground truth). Target ≀0.5–1. +7. **Pin** the engine value: add a `RealCertExpectation(schema, sample=uprn_, + cert_num, sap_score=)` in `tests/.../test_real_cert_sap_accuracy.py`; + run `…::test_real_cert_sap_score`. +8. Tick the worklist line `[x] … Β· eng X / elm Y (lodged Z) Β· PINNED …`. Next cert. + +See **Banked findings** below for the modal-dialog mechanics (all already encoded +in the helpers). New schema not mappable β†’ add a dedicated `from_*_schema_*` +mapper first (per-schema convention) + guard with the RdSAP-21.0.1 corpus gauge. + ## The loop (one UPRN) 1. **Pick** the first `[ ]` UPRN in [worklist.md](worklist.md). @@ -83,6 +151,14 @@ Pattern: `with E.session() as (ctx,page): E.goto(...); E.set_text/set_select(... 2022" β‰ˆ U1.4/g0.72) β€” the "...known data" option demands per-row Frame-Factor/ Data-Source/Location/Building-Part and fails validation. Trust the grid only after Save & Close + reload (the live grid mis-shows older rows as 0.00). +- **Window-width `.fill()` can DOUBLE the value** β†’ "Openings: Width has to be less + or equal to 50" validation error. `_add_window`'s `page.locator(TextBoxExtWidth) + .fill(str(area))` went through Elmhurst's keystroke/numeric formatter and rendered + `11.1` as `111.10` (uprn_10010215568) β€” the Recommendations gate then blocks the + download. After building Openings, VERIFY the grid width (reload + read the row); + if wrong, JS-set it: `el.value='11.1'; dispatch input+change+blur`, then Save&Close + (the entry boxes mirror the single row = edit-in-place). JS-set bypasses the + formatter and sticks. Belt-and-braces: set the width by JS in `_add_window` too. - **U-value-known overrides** (walls/roof/floor) don't toggle via automation β†’ accept the natural RdSAP age-band U-value; note the divergence for reconciliation. - **Recommendations page = validation gate.** It must show ZERO errors before the @@ -93,6 +169,112 @@ Pattern: `with E.session() as (ctx,page): E.goto(...); E.set_text/set_select(... lines 17/18). This drove the first campaign mapper fix β€” see Banked findings. ## Banked findings (fold new ones in here as the corpus grows) +- **MAPPER GAP β€” cylinder insulation thickness dropped (RdSAP-17.0+):** the mapper + carries `cylinder_size` + `cylinder_insulation_type` but NOT + `cylinder_insulation_thickness` β†’ `EpcPropertyData.sap_heating.cylinder_ + insulation_thickness_mm` stays None even when the cert lodges it (e.g. 50 mm). + The engine then assumes a poorly-insulated cylinder β†’ over-counts HW cylinder + loss β†’ under-rates. Confirmed uprn_68151071 (raw 50 mm β†’ mapped None; engine HW + 3446 vs Elmhurst 2911 kWh; engine 68 vs lodged 70 / Elmhurst 71). FIX: map the + thickness in the RdSAP per-schema mapper; check blast radius (any pinned cylinder + cert may shift) + regress the RdSAP-21.0.1 corpus gauge. Leverage point β€” likely + improves every cylinder-with-lodged-thickness cert. Flagged, not yet fixed. +- **Party-wall type `_pick` gotcha:** matching `"filled"` ALSO matches "Cavity + masonry **UN**filled" (CU, Uβ‰ˆ0.5) β€” the wrong type for a cert whose party wall is + Uβ‰ˆ0. Match `"masonry filled"` to hit CF (Cavity masonry filled, Uβ‰ˆ0). Affects + cavity builds (10090844932 / 10091568921 / 10093718424 / 10093412452 used the + loose `"filled"` and may have got CU in Elmhurst β€” only the Elmhurst cross-check, + not the pinned engine value which is validated against lodged). Fixed for + uprn_10093115480. +- **Shared-assessment reset: storage/electric β†’ boiler cert.** The shared + assessment carries the PRIOR cert's heating system. Going storageβ†’combi, the + boiler search dialog won't open while a SAP-table `MainHeatingCode` (e.g. SEB) is + set. Fix: JS-clear `MH1.TextBoxMainHeatingCode` to `''` + dispatch change, Save & + Close, then `select_boiler` works. Also RESET the electricity meter on the Meters + sub-tab (a prior off-peak cert leaves it Dual; gas certs want Single) and the + SECONDARY heating (a prior cert's secondary persists even when the calc shows + presence=No β€” set it explicitly or it pollutes the worksheet). +- **Secondary heating must be built in Elmhurst when lodged.** Certs lodge a + secondary (`sap_heating.secondary_heating_type`, e.g. 612 = mains-gas room heater + @ Table 4a seasonal efficiency 0.20 β€” a low-eff decorative/old gas fire). The + engine models it (fraction 0.1 from Table 11 Γ· the secondary efficiency β†’ e.g. + 3065 kWh for code 612); omitting it in Elmhurst inflates the worksheet (uprn_ + 10023443426: omitted β†’ 79 vs engine/lodged 76). Build via Secondary present=Yes β†’ + `ButtonSecondaryHeatingCode` cascade (title "Select secondary heating"): fuel β†’ + sub-fuel β†’ appliance β†’ type, e.g. Gas β†’ Mains gas β†’ Room Heaters β†’ RGx (pick the + RGx whose efficiency matches the lodged code, ~0.20 = decorative/old gas fire). + ⚠ For a NATIVE RdSAP-21.0.1 cert, engine = lodged (exact, all components) is the + authoritative validation β€” if the Elmhurst rebuild diverges, suspect an omitted + lodged feature (secondary / meter), confirm via engine-on-Elmhurst-inputs β‰ˆ + worksheet, and pin the engine = lodged value. + ⚠ **`present=No` does NOT clear the shared assessment's leftover secondary** β€” the + prior cert's secondary SYSTEM (fuel + SAP code) persists server-side even when the + UI dropdown reads "No", and it silently re-enters the worksheet. On a storage-heater + cert (uprn_100020665611, RdSAP-20.0.0 end-terrace house) a leftover House-Coal + closed-room-heater (SAP 633, cheap solid fuel) inflated the Elmhurst worksheet to + 44 vs engine 36 / lodged 37 until OVERWRITTEN. Always set the secondary EXPLICITLY + to the lodged appliance: storage-heater certs lodge "Portable electric heaters + (assumed)" β†’ present=Yes β†’ `ButtonSecondaryHeatingCode` cascade Electric β†’ Electric + β†’ Room Heaters β†’ "REA Panel, convector or radiant heaters" (= SAP 691, eff 100%). + That dropped the worksheet 44 β†’ 35 ≑ engine-on-Elmhurst-inputs 35 (faithful). +- **Openings: standard double/triple-glazing bands REQUIRE Frame Type + Glazing Gap.** + The window grid's `DropDownListExtFrameType` (PVC/Wood/Metal) and + `DropDownListExtGlazingGap` (6 mm / 12 mm / 16 mm or more) are required for any + band other than the new-build "Double post or during 2022" default β€” leaving them + empty fails the Recommendations gate ("Openings: Frame Type / Glazing Gap must be + entered"). Set both in `_add_window` BEFORE clicking Add (cert `pvc_window_frames` + β†’ PVC; `glazing_gap` mm β†’ the band). Glazing bands available: Single / Double|Triple + {pre 2002, between 2002 and 2021, post or during 2022, with unknown install date} / + Secondary / *…known data*. For RdSAP double-glazing with no lodged install year + (engine U 2.8) pick "Double pre 2002" (~U3.1, sub-1-SAP diff) β€” the "…known data" + options demand per-row U/g values. +- **Non-boiler (storage-heater) main heating β€” SOLVED** (uprn_10022893721, RdSAP- + 18.0 GF flat, electric storage heaters + immersion). The SpaceHeating page has NO + inline system-type selector and a `ButtonMainHeatingCode` button only APPEARS + once the bound PCDF boiler is cleared. Two-pass recipe (one Save & Close each): + 1. **Clear the leftover PCDB boiler**: set `MH1.TextBoxPCDFBoilerReference` to + `"0"` via JS + dispatch `change`, then `save_close`. (It doesn't AutoPostBack; + the Save commits it. After reload, boilerRef="0", the boiler fuel/flue fields + vanish, and `MH1.ButtonMainHeatingCode` is now present.) + 2. **Select the SAP-table system** via `set_heating_dialog(..ButtonMainHeating + Code, "^Electric","^Electric","Storage","SEB Modern slimline")` (title "Select + heating code"; L1 Gas/Oil/Solid Fuel/Electric/Community/No heating; storage L4 + = SEA old large-volume / SEB modern slimline / SED fan / SEJ integrated / SEK + high-heat-retention). **Match the cert's `sap_main_heating_code`**: 402 = SEB. + Then the CONTROL via `set_heating_dialog(..ButtonMainHeatingControls, + "Storage Radiator","CSA Manual charge control")` (= SAP 2401). Secondary No. + Water: `set_heating_dialog(..ButtonWaterHeatingCode, "Water Heater","^Electric", + "Immersion")` (β†’ code HEI) β€” then the immersion code REQUIRES a cylinder: CHECK + `WH.CheckBoxHotWaterCylinder` (JS click+change, AutoPostBacks) or Recommendations + errors "must have a Hot Water Cylinder"; set `WH.DropDownListCylinderSize` + (size 2β†’Normal/110L, 3β†’Medium/160L, 4β†’Large/210L), `WH.DropDownListInsulated` + (Foam/Jacket), `WH.DropDownListInsulationThickness`, and `WH.RadioButton + ListImmersionHeater` (off-peak meter β†’ Dual). See `build_10022893721.py` as the + template. ⚠ **CRITICAL β€” set the electricity meter type.** All-electric off-peak + certs MUST set the Economy-7 meter or Elmhurst silently defaults to Single / + Standard and prices everything at the 13.19p standard rate (worksheet collapses + ~13 SAP). The control is a HIDDEN sub-tab on the SpaceHeating page: + `E.click_tab(page,"TabContainer_TabPanelMeters")` then `E.set_select(page, + "TabContainer_TabPanelMeters_RadioButtonListElectricityType","Dual")` (options + Single/Dual/18 Hour/24 Hour/Unknown; cert `meter_type` 1β†’Dual=7-hour off-peak). + Verify in Elmhurst summary Β§14.2 "Electricity meter type" / worksheet + "Electricity Tariff" before trusting the worksheet SAP. +- **Economy-7 off-peak pricing is CORRECT β€” do NOT "fix" it** (was a real bug, FIXED + in PR #1217 via the Table 13 off-peak water-heating split + window-U fix). The + engine applies SAP Table 12a Grid 1 (space) + Table 13 (immersion DHW) high/low + splits properly: storage heaters' SH high-rate fraction is legitimately 0.00 + (100% low rate), immersion HW takes the volume/occupancy/single-dual blend. Proof: + uprn_10002468137 (canonical) engine 60.92 = Elmhurst 61 to the penny; + uprn_10022893721 engine 79 = lodged 79, Elmhurst (Dual meter) 81. ⚠ If you see a + big engine-over-Elmhurst gap on an all-electric off-peak cert, SUSPECT THE BUILD + (Elmhurst meter left on Single β€” see meter step above), not the calculator. The + `reference/mapping.md` "known over-rating bug (engine 62)" note is STALE (pre-PR + #1217). `cert_to_inputs.py` `_hot_water_fuel_cost_gbp_per_kwh` applies the immersion + Table 13 blend when cylinder volume + occupancy resolve; when `immersion_heating_ + type` is UNLODGED on an off-peak meter it now defaults to DUAL per RdSAP Β§10.5 + (was a 100%-low-rate fallback that under-costs β€” fixed, +regression test + `test_off_peak_immersion_unlodged_type_defaults_to_dual_table13_blend`). Only an + unresolvable cylinder volume / occupancy still falls back to 100% low. - **Air-permeability AP50 fix** (uprn_10093116528, the first campaign cert): the full-SAP mapper was routing the lodged q50 to the engine's AP4/Pulse formula (`0.263Γ—AP4^0.924`) instead of the AP50 `/20` path β€” a big infiltration @@ -105,13 +287,78 @@ Pattern: `with E.session() as (ctx,page): E.goto(...); E.set_text/set_select(... default. Pin the engine's observed value; document the Elmhurst delta (don't tune). - **FGHRS** (full-SAP `has_fghrs`/`fghrs_index_number`) is dropped by the mapper and not yet modelled β€” omit it on BOTH sides so the comparison stays clean. +- **Validation errors live on the Recommendations page as LINKS** (red βœ— anchors), + not coloured spans β€” parse `[id*=ContentPlaceHolder1] a` text, not CSS colour. + Until Recommendations shows ZERO errors the **Energy Report Summary nav silently + redirects to the Address page** and the worksheet/Results PDFs won't generate + (Results renders empty). Two errors that bit the 16.1/19.1.0 builds: *"Walls + (Main): Insulation Thickness must be entered"* (set the insulation-thickness + dropdown β€” "Unknown" is fine for a reduced cert) and *"Incorrect Controls (NNNN + ctrl-group-X) for Heating System (idx ctrl-group-Y)"* (the heating control's + system-type must match the boiler's β€” see the controls dialog below). +- **All Elmhurst modal dialogs sit above a `modalBackground` that defeats element + clicks** (Playwright sees it "intercepting pointer events", even with force). + The cracked pattern is now in `elmhurst_lib.py`: open via JS click, set cascade + ` at FP+suffix β€” lets a + sweep enumerate the *real* Elmhurst options instead of hard-coding label + strings that drift between RdSAP releases. Skips the blank placeholder.""" + return page.evaluate( + """(id)=>{const s=document.getElementById(id); if(!s)return []; + return [...s.options].map(o=>[o.value,(o.text||'').trim()]) + .filter(([v,t])=>v!=='' && t!=='');}""", + f"{FP}{suffix}", + ) + + +def select_by_contains(page: Page, suffix: str, needle: str, autopostback: bool = True) -> Optional[str]: + """Select the option whose visible text contains `needle` (case-insensitive), + by its value. Returns the chosen text, or None if no option matched β€” robust + to label drift where the exact string ("CA Cavity" vs "Cavity (CA)") is + uncertain until seen live.""" + for value, text in select_options(page, suffix): + if needle.lower() in text.lower(): + set_select(page, suffix, value, autopostback) + return text + return None + + def save_close(page: Page) -> None: """Save & Close β€” commits the whole server session to the DB (so the data survives the next fresh-login run). The body button id-suffix avoids the @@ -226,3 +269,173 @@ def delete_first_window(page: Page) -> None: page.wait_for_timeout(200) if window_row_count(page) < before: break + + +# --- Heating dialogs (boiler search + cascade) --------------------------- +# Hard-won (campaign certs 100021943298 / 10096028301): every Elmhurst modal +# dialog (PCDF boiler search, the SelectHeatingDialog cascade for water-heating +# method and main-heating controls) sits ABOVE a `modalBackground` overlay that +# makes Playwright's actionability hit-test see the background "intercepting +# pointer events" β€” so element .click()/.fill()/.select_option() all fail even +# with force. The reliable pattern is: +# * open the dialog with a JS .click() on its button, +# * set