mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
- 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>
311 lines
14 KiB
Python
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())
|