Model/scripts/hyde/elmhurst_fill.py
Jun-te Kim 2f0eb49eee Checkpoint: UPRN 10093116543 Elmhurst build + devcontainer VNC/Playwright + perms
- Add SAP-accuracy sample for uprn_10093116543 (epc.json, elmhurst_inputs.md,
  summary/worksheet PDFs)
- Persist hyde viewer stack (xvfb/fluxbox/x11vnc/novnc/websockify) and Playwright
  chromium in the backend devcontainer; forward noVNC 6080
- Broaden .claude/settings.local.json allowlist (display/python/grep/tail)
- In-progress campaign mapper/cert_to_inputs work carried from prior cert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:21:56 +00:00

311 lines
14 KiB
Python

"""Fill Elmhurst RdSAP-10-Online pages from known field values (POC).
Re-keys UPRN 10092973954 (the flat in elmhurst_inputs.md) into Elmhurst's web
entry tool. Reuses the saved persistent session and the viewer display.
# dry-run one page (fills, screenshots, does NOT navigate away => not saved):
DISPLAY=:99 python scripts/hyde/elmhurst_fill.py --page dimensions
# commit it (clicks Next > which saves AND advances):
DISPLAY=:99 python scripts/hyde/elmhurst_fill.py --page dimensions --commit
Mechanics learned from the live DOM (see elmhurst_explore.py):
* ASP.NET WebForms. Almost every control **AutoPostBacks** — the whole page
reloads on change — so we set ONE field, wait for the postback, then
re-locate the next field (old handles go stale). A short settle after each
interaction stops the next change being missed.
* Enter via the Guid URL; section nav is a full-form __doPostBack. Navigating
to another section **saves** the current page (so Next > = save + advance).
* NEVER re-click the section tab you're already on — it double-postbacks and
misbehaves. goto_section() checks the current URL and only clicks if the
target is a different page.
* Dropdown option values carry a code prefix ("F Flat", "L 2012-2022").
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Literal
from playwright.sync_api import (
Page,
TimeoutError as PlaywrightTimeoutError,
sync_playwright,
)
HERE = Path(__file__).parent
SESSION_DIR = HERE / ".elmhurst-session"
SHOT_DIR = HERE / "elmhurst_dom"
ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105"
ENTRY_URL = (
"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx"
f"?Guid={ASSESSMENT_GUID}"
)
FIELD_PREFIX = "ContentBody_ContentPlaceHolder1_"
NAV_PREFIX = "ContentBody_buttonAction"
# WebForms reattaches client JS after each postback; act too fast and the next
# change is missed. A short settle after every interaction fixes the flake.
SETTLE_MS = 600
@dataclass
class Field:
"""One value to set. ``suffix`` is appended to FIELD_PREFIX to form the id.
``kind`` selects how we set it; ``autopostback`` whether the page reloads."""
suffix: str
kind: Literal["text", "select", "checkbox"]
value: str # for checkbox: "on" to check, "off" to uncheck
autopostback: bool
note: str = ""
@dataclass
class PageSpec:
"""A section: how to reach it, how to know we're on it, and what to set."""
nav: str # the buttonAction<Section> id segment, e.g. "PropertyDescription"
url_fragment: str # appears in page.url when this section is showing
fields: list[Field]
# Dimensions Main-tab controls sit under a deep WebUserControl path.
_DIM_MAIN = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_"
# Walls main external-wall controls sit under an even deeper nested-tab path.
_WALL_MAIN = (
"TabContainer_TabPanelMain_InnerTabContainerMain_"
"TabPanelExternalWallMain_WebUserControlWallMain_"
)
# UPRN 10092973954 (flat) — values from elmhurst_inputs.md.
# NOTE age band: 2020 build => Elmhurst "L 2012-2022" (by year), though our
# engine labels construction_age_band "M".
PAGES: dict[str, PageSpec] = {
"property_description": PageSpec(
nav="PropertyDescription",
url_fragment="WebFormPropertyDescription.aspx",
fields=[
Field("DropDownListPropertyType1", "select", "F Flat", True, "property_type 2"),
Field("DropDownListPropertyType2", "select", "D Detached", True, "built_form 1"),
Field("TextBoxStoreys", "text", "1", True, "single storey"),
Field("TextBoxHabitableRooms", "text", "3", False, "habitable_rooms 3"),
Field("TextBoxHeatedHabitableRooms", "text", "3", True, "all heated"),
Field("DropDownListDateBuiltMain", "select", "L 2012-2022", True, "2020 build"),
Field("DropDownListDateBuiltFirst", "select", "", True, "no 1st extension — clear"),
Field("DropDownListRoomInRoofMain", "select", "", True, "no room in roof — clear"),
],
),
"dimensions": PageSpec(
nav="Dimensions",
url_fragment="WebFormDimensions.aspx",
fields=[
# Single-storey ground-floor flat => everything on the Lowest Floor
# row of the Main tab. Heat-loss perimeter 38.14 m, no party wall.
# (Type is already "Internal" and locks once dimensions exist — skip.)
Field(f"{_DIM_MAIN}TextBoxFloorAreaLowestFloor", "text", "67.84", True, "engine TFA"),
Field(f"{_DIM_MAIN}TextBoxRoomHeightLowestFloor", "text", "2.40", True, "vol/area"),
Field(f"{_DIM_MAIN}TextBoxWallPerimeterLowestFloor", "text", "38.14", True, "heat-loss perimeter"),
Field(f"{_DIM_MAIN}TextBoxPartyWallLengthLowestFloor", "text", "0", True, "no party wall"),
# NB: Room-in-Roof area field is auto-DISABLED once Property
# Description declares no room-in-roof, so we don't (can't) set it.
],
),
"flats": PageSpec(
nav="Flats",
url_fragment="WebFormFlats.aspx",
fields=[
Field("DropDownListPositionOfFlat", "select", "Ground Floor", True, "ground-floor flat"),
Field("TextBoxFloor", "text", "0", True, "ground = storey 0"),
# Corridor None => all-exposed build (matches engine: stair wall is
# exposed, not sheltered). Leaving "Unheated corridor" forces a
# sheltered alternative wall on the Walls page (validation ✖).
Field("RadioButtonListFlatCoridor", "select", "None", True, "no unheated corridor — all-exposed"),
# 🔸 StoriesInBlock is assessor-only (EPC can't supply).
],
),
"space_heating": PageSpec(
nav="SpaceHeating",
url_fragment="WebFormSpaceHeating.aspx",
fields=[
# NB: PCDF boiler reference is READ-ONLY (set via SelectBoilerDialog,
# not typeable) — boiler PCDB 17929 must be driven through the dialog.
# Room-sealed boiler => Balanced flue (cert is_flue_fan_present true).
Field(
"TabPanelMainHeating1_MainHeating1_RadioButtonListFlueType",
"select", "Balanced", True, "room-sealed; main_heating_flue_type 2",
),
# secondary_heating_category 1 = None — clears the placeholder 'RWM'.
Field("DropDownListSecondaryHeatingPresent", "select", "No", True, "no secondary heating"),
# 🔸 boiler PCDB 17929 + control 2110 go via search dialogs (manual).
],
),
"water_heating": PageSpec(
nav="WaterHeating",
url_fragment="WebFormWaterHeating.aspx",
fields=[
# Combi / instantaneous — no cylinder (clears CylinderSize/Insulated/…).
Field("TabPanelWaterHeating_CheckBoxHotWaterCylinder", "checkbox", "off", True, "no cylinder"),
# 🔸 water-heating code (from main, 901) via its dialog (manual).
],
),
"walls": PageSpec(
nav="Walls",
url_fragment="WebFormWalls.aspx",
fields=[
# Main wall is already correct (TI Timber Frame / As Built). Just
# clear the leftover Alternative Wall 1 (Solid Brick placeholder) by
# zeroing its area — our wall is a single timber-frame element.
Field(f"{_WALL_MAIN}TextBoxAddWallArea", "text", "0", True, "clear placeholder alt wall"),
],
),
"roofs": PageSpec(
nav="Roofs",
url_fragment="WebFormRoofs.aspx",
fields=[
# roof = "(other premises above)" => no roof heat loss. Selecting
# this disables the insulation/thickness fields (nothing to clear).
Field(
"TabContainer_TabPanelMain_WebUserControlRoofMain_DropDownListType",
"select", "A Another dwelling above", True, "roof_w_per_k = 0",
),
],
),
}
def _commit(page: Page, action: Callable[[], object]) -> None:
"""Run a control change that triggers an ASP.NET postback and wait for the
page to come back: full-form AutoPostBack => navigation; fall back to
networkidle for a partial/no postback."""
try:
with page.expect_navigation(wait_until="load", timeout=8_000):
action()
except PlaywrightTimeoutError:
page.wait_for_load_state("networkidle", timeout=8_000)
def _set_field(page: Page, f: Field) -> None:
loc = page.locator(f"#{FIELD_PREFIX}{f.suffix}") # re-located each call
if f.kind == "select":
if f.autopostback:
_commit(page, lambda: loc.select_option(f.value))
else:
loc.select_option(f.value)
elif f.kind == "checkbox":
check = loc.check if f.value == "on" else loc.uncheck
if f.autopostback:
_commit(page, check)
else:
check()
else: # text
loc.fill(f.value)
if f.autopostback:
_commit(page, lambda: loc.press("Tab")) # blur fires onchange
page.wait_for_timeout(SETTLE_MS) # half-beat so WebForms reattaches
print(f" set {f.suffix.split('_')[-1]:32s} = {f.value!r} ({f.note})", flush=True)
def select_boiler_combi(page: Page) -> None:
"""Drive the Main-Heating cascade dialog to a mains-gas condensing combi
(EES code BGW = SAP 104). The exact-model PCDB path is disabled
(aspNetDisabled), so the cascade's generic combi is the automatable choice;
its efficiency barely moves SAP for fabric-dominated dwellings. Selecting a
combi also drops the HW cylinder on the Water Heating page."""
mh1 = (
"ContentBody_ContentPlaceHolder1_TabContainer_TabPanelMainHeating1_"
"WebUserControlMainHeating1_"
)
dd6 = "ContentBody_OutsideUpdatePanel_SelectHeatingDialog_DropDownList6"
page.click(f"#{mh1}ButtonMainHeatingCode", timeout=10_000) # open cascade
page.wait_for_timeout(SETTLE_MS)
# Levels 1-5 (Gas/Mains gas/Boilers/Post 1998/Condensing) are already the
# default path; only the leaf differs (regular BGB -> combi BGW).
page.select_option(f"#{dd6}", label="BGW Post 98 Combi condens. with auto ign.")
page.wait_for_timeout(SETTLE_MS)
page.get_by_text("OK", exact=True).first.click(timeout=10_000) # apply
page.wait_for_timeout(SETTLE_MS * 2)
print(" boiler -> BGW (SAP 104, condensing combi)", flush=True)
def goto_section(page: Page, spec: PageSpec) -> None:
"""Navigate to a section — but never re-click the tab we're already on
(that double-postbacks and misbehaves)."""
if spec.url_fragment in page.url:
print(f"already on {spec.nav}", flush=True)
return
print(f"navigating to {spec.nav} ...", flush=True)
with page.expect_navigation(wait_until="networkidle", timeout=60_000):
page.click(f"#{NAV_PREFIX}{spec.nav}_Link", timeout=15_000)
page.wait_for_timeout(SETTLE_MS)
def fill_page(page_key: str, commit: bool, commit_mode: str = "saveclose") -> None:
spec = PAGES[page_key]
SHOT_DIR.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=str(SESSION_DIR),
headless=False,
accept_downloads=True,
viewport={"width": 1400, "height": 1000},
)
page = context.pages[0] if context.pages else context.new_page()
page.on("dialog", lambda d: d.accept()) # auto-dismiss confirm dialogs
print(f"entering assessment {ASSESSMENT_GUID} ...", flush=True)
page.goto(ENTRY_URL, wait_until="networkidle", timeout=60_000)
goto_section(page, spec)
if page_key == "space_heating":
# Boiler is a cascade-dialog field — set it before the row fields
# (it postbacks and can reset flue, so flue is set after).
select_boiler_combi(page)
print(f"filling {page_key}:", flush=True)
for f in spec.fields:
_set_field(page, f)
page.screenshot(path=str(SHOT_DIR / f"fill_{page_key}.png"))
print(f"screenshot: {SHOT_DIR / f'fill_{page_key}.png'}", flush=True)
if commit:
# Save & Close commits to the DATABASE (visible to other sessions);
# Next > only saves into the in-progress server session. Default to
# Save & Close so saved results show up elsewhere.
# The body button's container path varies per page (Address nests
# it, content pages don't), so match by id suffix. The top-bar copy
# is a *Link*ButtonSaveClose, so "_ButtonSaveClose" hits only body.
suffix = "_ButtonSaveClose" if commit_mode == "saveclose" else "_ButtonNext"
print(f"committing ({commit_mode}) ...", flush=True)
with page.expect_navigation(wait_until="networkidle", timeout=60_000):
page.click(f"[id$='{suffix}']", timeout=15_000)
page.wait_for_timeout(SETTLE_MS)
print(f"saved. now on: {page.url}", flush=True)
page.screenshot(path=str(SHOT_DIR / f"after_{page_key}.png"))
else:
print("DRY-RUN: not saved. Re-run with --commit to persist.", flush=True)
context.close()
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Fill an Elmhurst RdSAP page (POC).")
ap.add_argument("--page", default="property_description", choices=sorted(PAGES))
ap.add_argument("--commit", action="store_true", help="persist (else dry-run)")
ap.add_argument(
"--commit-mode",
default="saveclose",
choices=("saveclose", "next"),
help="saveclose = DB commit (visible elsewhere); next = save + advance",
)
args = ap.parse_args(argv)
fill_page(args.page, commit=args.commit, commit_mode=args.commit_mode)
return 0
if __name__ == "__main__":
sys.exit(main())