diff --git a/.claude/skills/epc-to-elmhurst-rdsap-inputs/SKILL.md b/.claude/skills/epc-to-elmhurst-rdsap-inputs/SKILL.md new file mode 100644 index 00000000..65fa7526 --- /dev/null +++ b/.claude/skills/epc-to-elmhurst-rdsap-inputs/SKILL.md @@ -0,0 +1,129 @@ +--- +name: epc-to-elmhurst-rdsap-inputs +description: Convert an EPC certificate (by UPRN, certificate number, or local epc.json) into a markdown sheet of Elmhurst Energy RdSAP entry-tool inputs, page by page, so a human can key them in and compare Elmhurst's SAP score against this repo's engine. Use when verifying SAP-calculator accuracy against Elmhurst, reproducing a lodged cert in Elmhurst, or when the user mentions Elmhurst, RdSAP inputs, or checking the SAP score for a UPRN/certificate. +--- + +# EPC → Elmhurst RdSAP inputs + +Produces a markdown crib sheet for re-keying a real EPC certificate into +Elmhurst Energy's RdSAP entry tool, so the operator can read off Elmhurst's +SAP score and compare it to this engine's. The accuracy comparison is the +whole point — the markdown leads with **our engine's SAP score** as the +number to beat, and flags known divergences. + +This is prompt-driven: you read the cert's real values, look up each Elmhurst +field in [reference/mapping.md](reference/mapping.md), and format the result. +**Ground every number in the loaded `EpcPropertyData` and the engine's +computed values — never guess a code or area.** Codes you can't find in the +mapping reference must be looked up in the cited source file, not invented. + +## Workflow + +1. **Resolve the cert to an `EpcPropertyData`** (one of): + - **UPRN** — `scripts/fetch_real_life_epc_sample.py ` (fetches, saves to + `backend/epc_api/json_samples/real_life_examples//uprn_/epc.json`, + prints schema + lodged rating + engine output), or + `EpcClientService(auth_token=...).get_by_uprn()`. + - **Certificate number** — `EpcClientService.get_by_certificate_number()`. + - **Local json** — `EpcPropertyDataMapper.from_api_response(json.load(...))`. + + Token is in `backend/.env` (`OPEN_EPC_API_TOKEN`, else `EPC_AUTH_TOKEN`). + For a saved json, mock `httpx.get` to return `{"data": }` (see the + fetch script), or call the mapper directly. + +2. **Compute the engine's view** so the sheet shows real numbers, not guesses: + ```python + from domain.sap10_calculator.calculator import Sap10Calculator + from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + inputs = cert_to_inputs(epc) # window areas, U-values, fuel costs, cylinder + result = Sap10Calculator().calculate(epc) # our SAP score + per-end-use kWh + ``` + Pull window area from `inputs.heat_transmission.windows_w_per_k` + the + synthesised `epc.sap_windows`; U-values from `inputs.heat_transmission`; + fuel £/kWh from `inputs.*_fuel_cost_gbp_per_kwh`. + +3. **Write the markdown**, one section per Elmhurst page, in this order: + Property Description · Dimensions · Conservatory · Walls (incl. Party wall) · + Roofs · Floors · Openings (Windows, Doors) · Ventilation & Lighting + (Ventilation, Mechanical Ventilation, Air Pressure Test, Lighting) · + Space Heating (Main Heating 1, Main Heating 2, Community Heating, Meters) · + Water Heating (Water Heating + cylinder, Community Hot Water, Solar Water + Heating, WWHRS, FGHRS) · New Technologies (PV, Wind, Hydro, Special Features). + For each field give the **Elmhurst label**, the **value to enter**, and where + useful the **EES dropdown path** and **SAP code**. Use the lookup tables and + gotchas in [reference/mapping.md](reference/mapping.md). + +4. **Flag what must NOT be mapped / must be cleared.** Elmhurst pages carry + defaults and (when re-keying into an existing assessment) stale values that + silently survive and corrupt the result — a phantom 1st extension, a + room-in-roof area, a party-wall length, leftover window rows. For every + element the cert does **not** have, say so explicitly with the Elmhurst + field to blank and the value to set (usually empty or 0). The automated + filler (`scripts/hyde/elmhurst_fill.py`) and a human both rely on this list + — an unflagged absent element defaults to "present" and skews SAP. See the + **Fields to clear** section in Output shape below. + +5. **Save** the file next to the cert json as `elmhurst_inputs.md` + (e.g. `.../real_life_examples//uprn_/elmhurst_inputs.md`). + +6. **Tell the operator**: key it into Elmhurst, then report the SAP score (and + heating cost £ if shown). If it differs from our engine's score, that's a + calculator finding — capture it. + +## Output shape + +Start the file with a header block: +``` +# Elmhurst RdSAP inputs — UPRN (cert , ) +**Lodged SAP:** **Our engine:** ← compare Elmhurst against this +**Known divergences:** +``` +Then the page sections as tables: `| Elmhurst field | Value | Notes (SAP code / EES path) |`. + +End the file with a **Fields to clear in Elmhurst (do NOT map)** section — one +table listing every element the cert lacks but Elmhurst defaults or stale data +would otherwise assert. This is the explicit "absent" set the filler and the +operator clear: +``` +## Fields to clear in Elmhurst (do NOT map) +| Elmhurst field | Set to | Why absent | +|---|---|---| +| Property Description · 1st–4th Extension age band | (blank) | no extensions lodged | +| Property Description · Room-in-Roof age band | (blank) | no room-in-roof building part | +| Dimensions · Room(s) in Roof area | (blank) | ditto — leave empty, not 0 | +| Dimensions · 1st–4th Ext. sub-tabs | 0 / blank | single building part | +| Walls · Party wall | none/blank | `party_walls_w_per_k` = 0 | +| Openings · surplus window rows | delete | window count from glazed-area band only | +| Water Heating · cylinder | none | combi / instantaneous | +| Conservatory / PV / Wind / Hydro / WWHRS / FGHRS | none | not lodged | +``` +Give the **Elmhurst page · field**, the **cleared value** (prefer *blank* over +0 where the field is "not applicable"; some validators reject 0), and the +one-line reason. Absent ≠ zero everywhere — note which. + +## Critical gotchas (full detail in reference) + +- **Economy-7 / off-peak electricity** (`main_fuel_type`/`water_heating_fuel` 29): + Elmhurst meter type **must be Dual-rate / Economy 7 (7-hour)**, not Single. + Our engine has a **known over-rating bug** here — it prices 100% of off-peak + space heating + hot water at the 5.50p low rate instead of the SAP Table 12a + high/low split. Always flag this in the output for all-electric off-peak certs. +- **WWHRS**: `sap_heating.instantaneous_wwhrs` is **bath/shower ROOM counts** + (ADR-0028), NOT a heat-recovery device → WWHRS = **No** unless a real unit is lodged. +- **Party wall** code 1 = Solid (U=0, Elmhurst "Solid"), not "Unable to determine". +- **Cylinder insulation** type 1 = Foam, 2 = Jacket. +- **Water heating** 903 = Electric immersion off-peak → Elmhurst "Water Heater" + category, not "Boiler Circulator" (901 = from main system). +- **Windows** are synthesised from `glazed_area` band × TFA — not real geometry. +- **Age band — pick by construction YEAR, not the engine's band letter.** The + live RdSAP-10 tool bands differently from `mapping.md`'s older table: it + offers `…K 2007-2011`, **`L 2012-2022`**, **`M 2023 onwards`**. A 2020 build + is **L** even though the engine labels `construction_age_band` "M". Map the + year to Elmhurst's on-screen band; flag the letter divergence. + +## Canonical example + +UPRN **10002468137** (cert `0215-2818-7357-9703-2145`, RdSAP-Schema-17.1): +all-electric high-heat-retention storage heaters on Economy 7, solid-brick +uninsulated end-terrace. **Lodged SAP 55 vs engine 62** — the over-rating that +motivated this skill. Use it to sanity-check output. diff --git a/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md b/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md new file mode 100644 index 00000000..245b357a --- /dev/null +++ b/.claude/skills/epc-to-elmhurst-rdsap-inputs/reference/mapping.md @@ -0,0 +1,242 @@ +# EpcPropertyData → Elmhurst RdSAP field mapping + +Complete code→value reference for the `epc-to-elmhurst-rdsap-inputs` skill. +Every mapping cites its source in this repo. When a lodged code isn't listed +here, look it up in the cited source file — do **not** guess. + +Field names below are the GOV.UK API / `EpcPropertyData` cert fields. Elmhurst +labels are the entry-tool's on-screen labels. + +--- + +## Property Description + +| Cert field | Code → value | +|---|---| +| `property_type` | 0=House, 1=Bungalow, 2=Flat, 3=Maisonette, 4=Park home | +| `built_form` | 1=Detached, 2=Semi-detached, 3=End-terrace, 4=Mid-terrace, 5=Enclosed end-terrace, 6=Enclosed mid-terrace | +| `construction_age_band` (England/Wales) | A=before 1900, B=1900–1929, C=1930–1949, D=1950–1966, E=1967–1975, F=1976–1982, G=1983–1990, H=1991–1995, I=1996–2002, J=2003–2006, K=2007 onwards | +| Storeys | count of distinct floors in `sap_building_parts[].sap_floor_dimensions` | +| Habitable / Heated rooms | `habitable_room_count` / `heated_room_count` | +| Extensions / Rooms in roof | `extensions_count`; rooms-in-roof only if a room-in-roof building part is lodged | + +## Dimensions + +From `sap_building_parts[].sap_floor_dimensions[]`. Type = `measurement_type` +(1 = Internal). One row per floor (`floor` 0 = ground/Lowest, 1 = 1st, …): +- Floor Area = `total_floor_area` +- Room Height = `room_height` +- Heat Loss Perimeter = `heat_loss_perimeter` +- Party Wall Length = `party_wall_length` +- Heated Basement only if a basement floor is lodged. + +## Conservatory + +`conservatory_type` 1 = none → unchecked. `has_heated_separate_conservatory`. + +## Walls (Main + Party) + +**External / Main wall:** +| Cert field | Mapping | +|---|---| +| `wall_construction` | 3 = Solid brick (look up others in source if not 3) | +| Insulation | `wall_insulation_thickness` "NI" or `wall_insulation_type` NONE → **As Built**; genuine retrofit (External/Internal/Filled) → that type | +| `wall_dry_lined` | Y/N → Dry-lining Yes/No | +| `wall_thickness` (mm) | Wall Thickness; `wall_thickness_measured` Y → "Wall Thickness Unknown" unchecked | +| `sap_alternative_wall` | enter as Alternative Wall 1 (`wall_area`, same code mapping) | + +**Party wall** — `party_wall_construction` → SAP10 wall code → U-value +(source: `datatypes/epc/domain/mapper.py` `_API_PARTY_WALL_CONSTRUCTION_TO_SAP10`): +| Code | Meaning | U | Elmhurst "Type" | +|---|---|---|---| +| 0 / None | no lodging | — | (cascade default) | +| 1 | Solid masonry / timber / system | **0.0** | **Solid** | +| 2 | Cavity masonry, unfilled | 0.5 | Cavity (unfilled) | +| 3 | Cavity masonry, filled | 0.2 | Cavity (filled) | +| 4 (house) / 5 (flat) | Unable to determine | 0.25 | Unable to determine | + +⚠️ Do **not** leave a code-1 party wall as "Unable to determine" — that wrongly +adds ~0.25 × area of heat loss and depresses the Elmhurst score. + +## Roofs + +| Cert field | Mapping | +|---|---| +| Type | loft access → "PA Pitched (slates/tiles), access to loft"; flat / room-in-roof per description | +| `roof_insulation_location` | 2 = loft (at joists) → Insulation "Joists" | +| `roof_insulation_thickness` | string e.g. "200mm" → Insulation Thickness 200 mm (drives the default U) | + +## Floors + +| Cert field | Mapping | +|---|---| +| `floor_construction` | 1 = Solid; 4 = Solid (no-insulation variant); a "Suspended"-prefixed type only if genuinely suspended (timber vs not-timber matters for infiltration) | +| Insulation | "no insulation (assumed)" / `floor_insulation` absent → **As built** | + +Source: `domain/sap10_calculator/worksheet/heat_transmission.py` floor logic. + +## Openings — Windows (RdSAP reduced-data, synthesised) + +RdSAP certs carry **no real window geometry**. The engine synthesises it +(`datatypes/epc/domain/mapper.py` `_synthesise_reduced_field_windows`): +``` +total_glazing_area = 0.148 × total_floor_area × band_multiplier # _RDSAP20_GLAZING_RATIO = 0.148 +split 4-way across orientations (1,3,5,7) = N, E, S, W # _RDSAP20_SYNTH_ORIENTATIONS +each window: width = area/4, height = 1.0 (height=1 so width carries the area) +``` +`glazed_area` band multiplier (`_RDSAP20_GLAZED_AREA_BAND_MULTIPLIER`): +| Code | Band | × | +|---|---|---| +| 1 | Normal | 1.00 | +| 2 | More than typical | 1.25 | +| 3 | Less than typical | 0.81 | +| 4 | Much more than typical | 1.51 | +| 5 | Much less than typical | 0.62 | + +Per-window fields: +- Glazing Type: from `multiple_glazing_type`; when no explicit install date is + lodged use **"Double with unknown install date"** (don't assert a date band). + ⚠️ RdSAP-17.x/18/19 inherit RdSAP-20.0.0 glazing coefficients — the code→date-band + translation across the 17.1 ↔ RdSAP-10 boundary is a known fidelity risk; report the + U-value Elmhurst assigns vs the engine's effective `windows_w_per_k ÷ total area`. +- Frame Type: `pvc_window_frames` true → PVC +- Glazing Gap: `glazing_gap` mm +- Orientation: not lodged — spread evenly N/E/S/W (matches the engine) +- Location: **External wall** +- Draught Proofed: `percent_draughtproofed` (100 → checked) +- U-value / g-value: leave Elmhurst defaults; note them for comparison. + +## Openings — Doors + +`door_count` (Total), `insulated_door_count` (Insulated), draughtproofed = +`door_count` when `percent_draughtproofed` 100. Engine default uninsulated door +U = 3.0 W/m²K, area 1.85 m² → `doors_w_per_k` ≈ count × 1.85 × 3.0. + +## Air permeability (CRITICAL — q50 is AP50, not AP4) + +A lodged "Air permeability X m³/h.m² (as tested)" is a **q50** result (Blower-Door, +at 50 Pa). SAP/RdSAP infiltration line `(18) = AP50/20 + (8)` — Elmhurst's worksheet +labels it **"Measured/design AP50"** and divides by 20 (worksheet lines 17/18). + +⚠️ **Do not confuse with AP4** (air permeability at 4 Pa, from a *Pulse* test), +whose cascade is `(18) = 0.263 × AP4^0.924 + (8)` — a much larger number for the +same value. Mapping a q50 onto the AP4 path massively over-counts infiltration +(e.g. q50 4.5 → 1.0 ach vs the correct 4.5/20 = 0.225). The full-SAP mapper now +routes the lodged air permeability to `SapVentilation.air_permeability_ap50_m3_h_m2` +(the `/20` path); fixed in the uprn_10093116528 campaign (gov-API SAP 78→82, vs +Elmhurst worksheet 81). In Elmhurst enter it via Air Pressure Test → **Blower Door** +→ result (and a certificate number, which the validation requires). + +## Ventilation & Lighting + +- **Ventilation**: open chimneys = `open_fireplaces_count`; flues/passive + vents/flueless gas fires/extract fans = lodged counts (0 if not lodged); + Fixed space cooling = `has_fixed_air_conditioning`. Draught Lobby not in RdSAP + house reduced-data → leave default. +- **Mechanical Ventilation**: `mechanical_ventilation` 0 = natural → unchecked. +- **Air Pressure Test**: RdSAP certs → "Not available" (uses % draughtproofing). +- **Lighting**: SAP-2012 certs lodge **outlets**, not bulbs — + Total bulbs = `fixed_lighting_outlets_count`, + low energy = `low_energy_fixed_lighting_outlets_count`. RdSAP-10's bulb + methodology differs slightly, but lighting is a minor energy term. + +## Space Heating + +**Main Heating** — `sap_heating.main_heating_details[]`: +- `sap_main_heating_code` is the SAP code. e.g. **409 = High heat retention + storage heaters** (EES path: Electric → Electric → Storage → High heat retention). +- `main_heating_control` is the controls SAP code. e.g. **2404 = Controls for + high heat retention storage heaters** (EES: Storage Radiator Systems → CSD). +- `main_heating_fraction` → Percentage of Heat (1 = 100%). +- `storage_heaters[]`: count + `high_heat_retention` flag → the heater list. +- Only one main system → leave Main Heating 2 empty. PCDF refs 0 unless a PCDF + boiler/control is lodged. Heat Emitter / Flue / Pump Age are wet-system fields — + N/A once a storage-heater code is chosen. + +**Secondary** — e.g. "Portable electric heaters (assumed)" → Electric → +Electric → Room Heaters → Panel/convector/radiant (SAP 691), standard-tariff +electricity (fuel 30). `secondary_heating_fraction` default 0.1. + +**Community Heating**: None unless community-heating lodged. + +**Meters** — `sap_energy_source`: +- `mains_gas` Y/N → "Mains gas supply available" checkbox. +- Electricity meter type from the heating/HW **fuel code** (see below). +- `meter_type`; smart-meter flags if lodged. + +### Fuel codes & Economy-7 (CRITICAL — known engine bug) + +Table 32 unit costs, p/kWh (`domain/sap10_calculator/tables/table_32.py`): +| Code | Fuel | p/kWh | +|---|---|---| +| 30 | Electricity, standard tariff | 13.19 | +| 31 | 7-hour tariff **low / off-peak** | 5.50 | +| 32 | 7-hour tariff **high** | 15.29 | +| 33 | 10-hour low | 7.50 | +| 34 | 10-hour high | 14.68 | +| 35 | 24-hour heating tariff | 6.61 | +| 38 / 40 | 18-hour high / low | 13.67 / 7.41 | + +**`main_fuel_type` / `water_heating_fuel` 29 = off-peak (7-hour) electricity** → +Elmhurst Electricity meter type = **Dual-rate / Economy 7 (7-hour)**, NOT Single. + +✅ **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 + +| Cert field | Mapping | +|---|---| +| `water_heating_code` | 901 = From main heating system (Elmhurst "Boiler Circulator"); **903 = Electric immersion, off-peak → Elmhurst "Water Heater" category** (NOT Boiler Circulator) | +| `water_heating_fuel` | as Fuel codes above (29 = off-peak) | +| `has_hot_water_cylinder` | → "Hot Water Cylinder Present" | +| `cylinder_size` | **code 2 = Normal / 110 L, code 3 = Medium / 160 L, code 4 = Large / 210 L** (RdSAP 10 §10.5 Table 28; source: `cert_to_inputs.py` `_CYLINDER_SIZE_CODE_TO_LITRES`). In Elmhurst pick the **litre value**, NOT the label — "Normal" = 110 L. | +| `cylinder_insulation_type` | **1 = factory Foam, 2 = loose Jacket** (source: `cert_to_inputs.py` `_CYLINDER_INSULATION_TYPE_LOOSE_JACKET = 2`) | +| `cylinder_insulation_thickness` | mm (38 mm ≈ factory foam; jackets 80 mm+) | +| `immersion_heating_type` | **code 1 = DUAL, code 2 = SINGLE** (source: `cert_to_inputs.py` ~L5288, per RdSAP 10 §10.5 "assume dual on a dual/off-peak meter" + the API cohort). ⚠️ Do NOT read 1 as "single" — single vs dual flips the Table 13 high-rate fraction and can swing the SAP score several points (e.g. cert 10002468137: dual 0.131 → SAP 61, single 0.571 → SAP 57). Storage-heater / off-peak certs are almost always code 1 = dual. | + +- **Community Hot Water**: 0 unless lodged. +- **Solar Water Heating**: `solar_water_heating` Y/N. +- **Number of baths** (Elmhurst tab: **Water Heating → WWHRS sub-tab → "Total no. of Baths"**, NOT the main Water Heating sub-tab): the gov-API derives it from `sap_heating.instantaneous_wwhrs` ROOM counts — `number_baths = rooms_with_bath_and_or_shower + rooms_with_bath_and_mixer_shower`. ⚠️ Elmhurst defaults this to 0; set it to the derived count or the gov-API and Elmhurst hot-water demand diverge (e.g. cert 10002468137: 2 baths = +165 kWh HW ≈ +£11 ≈ +0.7 SAP). Keep WWHRS itself **No**. +- **WWHRS**: ⚠️ `sap_heating.instantaneous_wwhrs` holds **bath/shower ROOM + counts** (ADR-0028: `rooms_with_bath_and_or_shower`, `rooms_with_mixer_shower_no_bath`, + `rooms_with_bath_and_mixer_shower`) — it is **NOT** a heat-recovery device. + Set WWHRS = **No / not present** unless a genuine WWHRS unit is lodged. A + phantom WWHRS recovers heat and wrongly raises the Elmhurst score. +- **FGHRS**: `main_heating_details[].has_fghrs` Y/N (per main heating system). + +## New Technologies + +- **PV**: `sap_energy_source.photovoltaic_supply` — `none_or_no_details` → None. +- **Wind**: `wind_turbines_count` 0 → not present (terrain type irrelevant then). +- **Hydro**: 0 unless lodged. +- **Special Features (Appendix Q)**: none unless lodged. +- "Export capable meter" has no effect with no generation. + +--- + +## Source files + +| Concern | File | +|---|---| +| API → EpcPropertyData mapper, party-wall & window synthesis | `datatypes/epc/domain/mapper.py` | +| cert → calculator inputs, cylinder insulation, fuel costs | `domain/sap10_calculator/rdsap/cert_to_inputs.py` | +| heat transmission (U-values, floors, party walls) | `domain/sap10_calculator/worksheet/heat_transmission.py` | +| fuel unit costs | `domain/sap10_calculator/tables/table_32.py` | +| EPC fetch by UPRN | `scripts/fetch_real_life_epc_sample.py` | +| EPC client | `infrastructure/epc_client/epc_client_service.py` | diff --git a/.claude/skills/expand-sap-accuracy-corpus/SKILL.md b/.claude/skills/expand-sap-accuracy-corpus/SKILL.md new file mode 100644 index 00000000..1d12ed5b --- /dev/null +++ b/.claude/skills/expand-sap-accuracy-corpus/SKILL.md @@ -0,0 +1,356 @@ +--- +name: expand-sap-accuracy-corpus +description: Drive the per-UPRN SAP-accuracy corpus campaign. For each UPRN ensure the EPC-API→EpcPropertyData mapping exists (extend the mapper if the schema isn't covered), build the cert in Elmhurst (hyde Playwright automation), download its Input Summary + SAP Worksheets, reconcile our SAP calculator to within 0.5 of Elmhurst's accredited worksheet by fixing mapper gaps (not tuning), and pin a RealCertExpectation regression. Use when expanding SAP-calculator schema coverage/accuracy across many real certs, working the 100-UPRN worklist one at a time, validating the EPC mapper against Elmhurst, or when the user mentions the SAP accuracy corpus / Elmhurst worksheets / the UPRN campaign. +--- + +# Expand SAP accuracy corpus + +A campaign wrapper around **`validate-cert-sap-accuracy`**, run **one UPRN at a +time** over [worklist.md](worklist.md). Why a UPRN at a time: we are rewriting / +extending the mapper incrementally, and each pass should (a) widen EPC-schema +coverage, (b) fix one real mapper gap, (c) leave the skills/tests sharper for the +next UPRN. + +**Goal per UPRN:** our SAP calculator within **0.5 SAP** of Elmhurst's +accredited worksheet, the agreed score pinned as a `RealCertExpectation`. + +The big picture: an old EPC → `EpcPropertyData` → our SAP calculator predicts the +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). +2. **Capture** — `PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py ` + → `epc.json` + schema + engine SAP under `…/real_life_examples//uprn_/`. + - **`NOT MAPPABLE`** (schema uncovered, e.g. full SAP) → this *is* the coverage + work: **extend the mapper** for that schema (`EpcPropertyDataMapper` / per-cert + mapper — Khalim's domain). Mark `🔧 mapper-gap`, build it, re-run. Guard with + the RdSAP-21.0.1 corpus gauge (`test_sap_accuracy_corpus.py`). +3. **Inputs** — run the **`epc-to-elmhurst-rdsap-inputs`** skill on the UPRN → + complete `elmhurst_inputs.md` (+ its **Fields to clear** section). +4. **Build in Elmhurst** (`scripts/hyde/`, mechanics in `elmhurst_README.md`): + `bash scripts/hyde/start_viewer.sh` (noVNC on :99) → log in once + (`elmhurst_session.py login`) → **create a NEW assessment for this UPRN** → + fill from `elmhurst_inputs.md`: `elmhurst_fill.py` auto-fills the derivable + pages (incl. the boiler cascade) + the clears; key the 🔸 fields by hand. Then + `elmhurst_download.py` saves **`elmhurst_summary.pdf`** + **`elmhurst_worksheet.pdf`** + into the sample dir. ⚠ The filler hard-codes one assessment + one cert's values + today — see **Limitations**. +5. **Compare** — `PYTHONPATH=/workspaces/model python scripts/compare_epc_paths.py ` + → deep-diffs gov-API vs Elmhurst-parsed `EpcPropertyData`, runs **both** through + the engine, and prints Elmhurst's own worksheet SAP. Read it: engine-on-Elmhurst- + inputs ≈ worksheet ⇒ calculator faithful; gov-API vs Elmhurst gap ⇒ input diffs. +6. **Reconcile to ≤0.5** — triage every field diff (use the + `epc-to-elmhurst-rdsap-inputs` `reference/mapping.md` for code semantics): + - **Elmhurst data-entry error** → fix in Elmhurst, re-export, re-run step 5. + - **gov-API mapper gap** → the leverage point. Fix the mapper **generically**; + never tune to one cert. Cosmetic diffs (codes vs strings, empty lists) are noise. + - **Genuine ground-truth** → align both sides to the lodged data. +7. **Pin** — add a `RealCertExpectation` to + `tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py`; run it. If a + known engine bug still blocks, use `known_bug_xfail="…"` (strict xfail) — don't widen. +8. **Bank** — fold what you learned into `mapping.md` / this skill / the tests so + the next UPRN is easier, and set the worklist status (see legend). + +## Worklist status legend +`[ ]` todo · `[x]` pinned (≤0.5) · `🔧` mapper extended · `⚠` xfail (engine bug) · +`⛔` blocked. Annotate each done line: `— · eng / elm · `. + +## Elmhurst build playbook (READ THIS before driving the browser) +Use **`scripts/hyde/elmhurst_lib.py`** — it encodes the auth + DOM gotchas below. +Pattern: `with E.session() as (ctx,page): E.goto(...); E.set_text/set_select(...); E.save_close(page)`. + +- **Auth is a SESSION cookie** — Playwright's persistent context does NOT restore + it across process launches, so the old "log in once, reuse session" flow breaks. + Instead **log in fresh every run** (`E.session()` reads creds from `ELM_ACCESS`/ + `ELM_PWD` or gitignored `scripts/hyde/.elmhurst-creds.json`). Assessment DATA + persists server-side via **Save & Close**, so you can build/verify ONE page per + run across separate processes. Start the viewer first: `bash scripts/hyde/start_viewer.sh`. +- **Build page-by-page, Save & Close each** — overwrite the reused assessment's + prior-UPRN state (incl. property type House↔Flat, which reveals/hides fields). + After each `set_*`, the page AutoPostBacks — re-locate the next control. +- **Sub-tabs hide fields** — Doors, Air Pressure Test, Lighting, FGHRS etc. are + Ajax tabs; their inputs exist in the DOM but are invisible until you + `E.click_tab(page, "TabContainer_TabPanelPanel")`. Forgetting this → "element + not visible" timeouts AND missing-required-field validation errors. +- **Window grid is the hard part.** (a) delete is a Yes/No MODAL + (`E.delete_first_window`); (b) it WON'T delete the last row; (c) **each Add wipes + the previously-added row** → multi-row entry doesn't stick. So enter **ONE + combined window**: `E.add_combined_window(page, total_glazed_area, orientation)` + then delete the prior rows. Use a standard band glazing ("Double post or during + 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). +- **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 + summary/worksheet PDFs download. Usual culprits: insulated-door U-value, and the + air-pressure-test **certificate number** (required when method=Blower Door). +- **Air permeability**: enter the lodged q50 via Air Pressure Test → Blower Door → + result. Elmhurst treats it as **AP50** and uses `(18)=AP50/20+(8)` (worksheet + 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 + over-count. Fixed via `SapVentilation.air_permeability_ap50_m3_h_m2` + + `_sap_17_1_ventilation` + `cert_to_inputs`. Re-pinned uprn_10092973954 77→80. + See `epc-to-elmhurst-rdsap-inputs/reference/mapping.md` (Air permeability). +- **Full-SAP-through-RdSAP residual ≈ +1–2 SAP** is expected and usually NOT a + bug: the engine uses the cert's *measured* U-values (often better than RdSAP + age-band proxies) and Elmhurst RdSAP uses age-band defaults + a party-wall + 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 + top-bar LinkButton copy. NEVER touches Submit.""" + with page.expect_navigation(wait_until="networkidle", timeout=60_000): + page.click("#ContentBody_ButtonSaveClose", timeout=15_000) + page.wait_for_timeout(SETTLE_MS) + + +# --- Window grid helpers (Openings page) --------------------------------- +_WP = "TabContainer_TabPanelWindowsPanel_" +_GRID = "GridViewExtendedWidows" + + +def window_row_count(page: Page) -> int: + return page.evaluate( + f"""()=>document.querySelectorAll("[id*='{_GRID}_DeleteButton_']").length""" + ) + + +def _jsclick(page: Page, element_id: str) -> None: + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", element_id) + + +def add_combined_window( + page: Page, total_area_m2: float, orientation: str, + glazing: str = "Double post or during 2022", +) -> None: + """Add ONE window row of `total_area_m2` (width=area, height=1.0). Sets the + required per-row fields (orientation/location/building-part) so the + Recommendations validation passes. Caller then deletes the prior rows.""" + set_select(page, f"{_WP}DropDownListExtGlazing", glazing) + page.locator(f"#{FP}{_WP}TextBoxExtWidth").fill(str(total_area_m2)) + h = page.locator(f"#{FP}{_WP}TextBoxExtHeight") + h.fill("1.00") + commit(page, lambda: h.press("Tab")) + page.locator(f"#{FP}{_WP}DropDownListExtOrientation").select_option(orientation) + page.locator(f"#{FP}{_WP}DropDownListExtBuildingPartId").select_option("Main") + page.locator(f"#{FP}{_WP}DropDownListExtLocation").select_option("External wall") + page.wait_for_timeout(200) + before = window_row_count(page) + _jsclick(page, f"{FP}{_WP}ButtonAddWindow") + for _ in range(20): + page.wait_for_timeout(200) + if window_row_count(page) > before: + break + + +def delete_first_window(page: Page) -> None: + """Delete the first window row via the Yes/No modal. Will refuse to drop the + LAST row (Elmhurst blocks it) — so add the replacement BEFORE deleting.""" + before = window_row_count(page) + first = page.evaluate( + f"""()=>{{const e=document.querySelector("[id*='{_GRID}_DeleteButton_']"); return e?e.id:null;}}""" + ) + if not first: + return + _jsclick(page, first) + page.wait_for_selector(f"#{FP}DeleteWindowDialog_LinkButtonYes", state="visible", timeout=5_000) + _jsclick(page, f"{FP}DeleteWindowDialog_LinkButtonYes") + for _ in range(20): + 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