"""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
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())