mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
423 lines
19 KiB
Python
423 lines
19 KiB
Python
"""Reusable Elmhurst RdSAP-10-Online automation helpers (campaign edition).
|
||
|
||
Why this exists: the per-cert build in the `expand-sap-accuracy-corpus` loop is
|
||
the slow, flaky part. `elmhurst_fill.py` hard-codes one cert's values and relies
|
||
on a *persisted* login session — but Elmhurst's auth is a **session cookie** that
|
||
Playwright's persistent context does NOT reliably restore across process launches.
|
||
This module sidesteps both problems:
|
||
|
||
* **Log in fresh each run** (creds from env `ELM_ACCESS`/`ELM_PWD`, else a
|
||
gitignored `scripts/hyde/.elmhurst-creds.json` {"access","pwd"}). Assessment
|
||
DATA persists server-side via *Save & Close*, so re-login per run is fine and
|
||
you can build/verify one page at a time across separate processes.
|
||
* **Headed on the noVNC display** (`DISPLAY=:99`; run `start_viewer.sh` first).
|
||
|
||
Hard-won Elmhurst-DOM gotchas baked into the helpers below (see SKILL.md too):
|
||
* Most controls AutoPostBack — set one field, wait for the postback, re-locate.
|
||
* Sub-tabs (Doors, Air Pressure Test, Lighting, Water-Heating FGHRS…) are
|
||
AjaxControlToolkit tabs: their fields are in the DOM but NOT visible until the
|
||
tab span `#__tab_<panelId>` is clicked. Use `click_tab()`.
|
||
* Delete is a Yes/No MODAL (not a native dialog): click the row delete, then
|
||
`#..._LinkButtonYes`. Its ModalBackground intercepts clicks — use JS .click().
|
||
* The window grid will NOT delete the LAST row, and **each Add wipes the
|
||
previously-added row's editable cells** — so multi-row window entry via the
|
||
add-form does not stick. Enter ONE combined window row (total glazed area,
|
||
one orientation); per worked-ref guidance that's an accepted approximation.
|
||
* Window glazing: "...known data" enables per-window U/g but then DEMANDS
|
||
Frame Factor + Data-Source + Location + Building Part per row (Recommendations
|
||
validation fails otherwise). Prefer a standard band, e.g.
|
||
"Double post or during 2022" (auto-derives U≈1.4/g≈0.72), and still set
|
||
Orientation + Location="External wall" + Building Part="Main".
|
||
* The "U-value known" override checkboxes (walls/roof/floor) don't reliably
|
||
toggle via automation — accept the natural RdSAP age-band U-value.
|
||
* The **Recommendations** page is the validation gate; it MUST show zero errors
|
||
before the Energy Report Summary / worksheet PDFs are downloadable.
|
||
Common required-but-blank fields: insulated-door U-value, air-pressure-test
|
||
certificate number (when method=Blower Door).
|
||
* Truth only after Save & Close + reload: the live (unsaved) grid mis-displays
|
||
older rows as 0.00 — never delete a row by its *live* width; reload first.
|
||
|
||
Usage:
|
||
import elmhurst_lib as E
|
||
with E.session() as (ctx, page):
|
||
E.goto(page, "Dimensions", "WebFormDimensions.aspx")
|
||
E.set_text(page, E.FP + "...TextBoxFloorAreaLowestFloor", "44.93")
|
||
E.save_close(page)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
from typing import Callable, Generator, Optional
|
||
|
||
from playwright.sync_api import (
|
||
BrowserContext,
|
||
Page,
|
||
TimeoutError as PlaywrightTimeoutError,
|
||
sync_playwright,
|
||
)
|
||
|
||
HERE = Path(__file__).parent
|
||
SESSION_DIR = HERE / ".elmhurst-session"
|
||
CREDS_FILE = HERE / ".elmhurst-creds.json" # gitignored; {"access":..,"pwd":..}
|
||
|
||
# The single reusable campaign assessment ("Khalim-test"). Overwrite it per UPRN.
|
||
ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105"
|
||
ENTRY_URL = (
|
||
"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx"
|
||
f"?Guid={ASSESSMENT_GUID}"
|
||
)
|
||
FP = "ContentBody_ContentPlaceHolder1_" # control-id prefix
|
||
NAV = "ContentBody_buttonAction" # left-rail section nav id prefix
|
||
SETTLE_MS = 700
|
||
|
||
|
||
def _creds() -> tuple[str, str]:
|
||
if CREDS_FILE.exists():
|
||
d = json.loads(CREDS_FILE.read_text())
|
||
return d["access"], d["pwd"]
|
||
access = os.environ.get("ELM_ACCESS")
|
||
pwd = os.environ.get("ELM_PWD")
|
||
if not access or not pwd:
|
||
raise SystemExit(
|
||
"No Elmhurst creds: set ELM_ACCESS/ELM_PWD or write "
|
||
f"{CREDS_FILE} = {{'access':..,'pwd':..}}"
|
||
)
|
||
return access, pwd
|
||
|
||
|
||
@contextmanager
|
||
def session() -> Generator[tuple[BrowserContext, Page], None, None]:
|
||
"""Launch headed on :99, log in fresh, land on the assessment. Closes on exit."""
|
||
with sync_playwright() as p:
|
||
ctx = p.chromium.launch_persistent_context(
|
||
user_data_dir=str(SESSION_DIR),
|
||
headless=False,
|
||
accept_downloads=True,
|
||
viewport={"width": 1400, "height": 1000},
|
||
)
|
||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||
page.on("dialog", lambda d: d.accept())
|
||
page.goto(ENTRY_URL, wait_until="networkidle", timeout=60_000)
|
||
if "WebFormLogin" in page.url:
|
||
access, pwd = _creds()
|
||
page.fill("#ctl00_ctl00_ContentBody_ContentBody_TextBoxAccessCode", access)
|
||
page.fill("#ctl00_ctl00_ContentBody_ContentBody_TextBoxPassword", pwd)
|
||
with page.expect_navigation(wait_until="networkidle", timeout=60_000):
|
||
page.click("#ctl00_ctl00_ContentBody_ContentBody_ImageButtonEnter")
|
||
page.wait_for_timeout(1000)
|
||
try:
|
||
yield ctx, page
|
||
finally:
|
||
ctx.close()
|
||
|
||
|
||
def commit(page: Page, action: Callable[[], object]) -> None:
|
||
"""Run a control change that triggers an ASP.NET postback; wait for it."""
|
||
try:
|
||
with page.expect_navigation(wait_until="load", timeout=8_000):
|
||
action()
|
||
except PlaywrightTimeoutError:
|
||
page.wait_for_load_state("networkidle", timeout=8_000)
|
||
page.wait_for_timeout(SETTLE_MS)
|
||
|
||
|
||
def goto(page: Page, nav: str, url_fragment: str) -> None:
|
||
"""Navigate to a left-rail section (no-op if already on it — re-clicking
|
||
double-postbacks). Navigating saves the current page into the server session."""
|
||
if url_fragment in page.url:
|
||
return
|
||
with page.expect_navigation(wait_until="networkidle", timeout=60_000):
|
||
page.click(f"#{NAV}{nav}_Link", timeout=15_000)
|
||
page.wait_for_timeout(SETTLE_MS)
|
||
|
||
|
||
def click_tab(page: Page, panel_id: str) -> None:
|
||
"""Activate an AjaxControlToolkit sub-tab by its panel id, e.g.
|
||
'TabContainer_TabPanelDoorsPanel'. Client-side; no postback."""
|
||
page.evaluate(
|
||
"(id)=>{const e=document.getElementById('__tab_'+id); if(e)e.click();}",
|
||
f"{FP}{panel_id}",
|
||
)
|
||
page.wait_for_timeout(500)
|
||
|
||
|
||
def set_text(page: Page, suffix: str, value: str, autopostback: bool = True) -> None:
|
||
loc = page.locator(f"#{FP}{suffix}")
|
||
loc.fill(value)
|
||
if autopostback:
|
||
commit(page, lambda: loc.press("Tab"))
|
||
else:
|
||
page.wait_for_timeout(150)
|
||
|
||
|
||
def set_select(page: Page, suffix: str, value: str, autopostback: bool = True) -> None:
|
||
loc = page.locator(f"#{FP}{suffix}")
|
||
if autopostback:
|
||
commit(page, lambda: loc.select_option(value))
|
||
else:
|
||
loc.select_option(value)
|
||
|
||
|
||
def select_options(page: Page, suffix: str) -> list[tuple[str, str]]:
|
||
"""Return [(value, visible_text), …] for the <select> 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 <select> values by JS (set .value + dispatch 'change') — this drives
|
||
# the AutoPostBack cascade without a real click,
|
||
# * commit by a raw `page.mouse.click(x, y)` at the OK/Select control's centre
|
||
# (coordinate clicks land on the topmost element = the dialog, not the
|
||
# background).
|
||
# Typing into the boiler search box needs force-click-then-keyboard (the box is
|
||
# overlapped); result cells are clicked by coordinate (the model column is
|
||
# display:none at >=1024px, so match the row by its Ref-No cell and click the
|
||
# first VISIBLE cell). Direct-writing the PCDF reference number is COSMETIC — it
|
||
# does NOT re-resolve the boiler type/efficiency; only the search dialog does.
|
||
MH1 = (
|
||
"ContentBody_ContentPlaceHolder1_TabContainer_TabPanelMainHeating1_"
|
||
"WebUserControlMainHeating1_"
|
||
)
|
||
HEATING_DIALOG = "ContentBody_OutsideUpdatePanel_SelectHeatingDialog_"
|
||
|
||
|
||
def dialog_commit(page: Page, label: str = "OK") -> bool:
|
||
"""Coordinate-click a dialog's commit control (a styled <span>, e.g. 'OK' or
|
||
'Select') — bypasses the modalBackground that defeats element clicks."""
|
||
pt = page.evaluate(
|
||
"""(lbl)=>{const e=[...document.querySelectorAll('[id*=SelectHeatingDialog] span,[id*=SelectHeatingDialog] a,[id*=SelectHeatingDialog] input,[id*=SelectBoilerDialog] span')]
|
||
.find(x=>new RegExp('^'+lbl+'$','i').test((x.value||x.innerText||'').trim()) && x.offsetParent);
|
||
if(!e)return null;const c=e.getBoundingClientRect();return {x:c.x+c.width/2,y:c.y+c.height/2};}""",
|
||
label,
|
||
)
|
||
if not pt:
|
||
return False
|
||
page.mouse.click(pt["x"], pt["y"])
|
||
page.wait_for_timeout(SETTLE_MS * 3)
|
||
return True
|
||
|
||
|
||
def set_heating_dialog(page: Page, open_button_suffix: str, *level_regexes: str) -> None:
|
||
"""Open a SelectHeatingDialog cascade (water-heating method or main-heating
|
||
controls) via the button at FP+`open_button_suffix`, set DropDownList1..N to
|
||
the options matching each regex (JS, fires the cascade), then coordinate-OK.
|
||
e.g. water (combi): set_heating_dialog(page, MH..ButtonWaterHeatingCode,
|
||
'From Space Heating', 'From the primary heating system');
|
||
control 2106: set_heating_dialog(page, ..ButtonMainHeatingControls,
|
||
'^Boilers', '^Standard', 'CBE Programmer, room thermostat and TRVs')."""
|
||
page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{FP}{open_button_suffix}")
|
||
page.wait_for_timeout(SETTLE_MS * 3)
|
||
for i, rx in enumerate(level_regexes, start=1):
|
||
page.evaluate(
|
||
"""(a)=>{const s=document.getElementById(a[0]);if(!s)return;
|
||
const o=[...s.options].find(o=>new RegExp(a[1],'i').test(o.text));
|
||
if(o){s.value=o.value;s.dispatchEvent(new Event('change',{bubbles:true}));}}""",
|
||
[f"{HEATING_DIALOG}DropDownList{i}", rx],
|
||
)
|
||
page.wait_for_timeout(SETTLE_MS * 4) # AutoPostBack re-renders the next level
|
||
dialog_commit(page, "OK")
|
||
|
||
|
||
def select_boiler(page: Page, query: str, ref: str) -> str:
|
||
"""Set the main-heating boiler to PCDB `ref` via the search dialog. `query`
|
||
is a full-text search (brand/model) that surfaces `ref` on page 1. TWO-STEP
|
||
commit: click the result ROW (highlights it) then the top 'Select' span."""
|
||
page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{MH1}ButtonBoilerReference")
|
||
page.wait_for_selector("[id*='SelectBoilerDialog_TextBoxFullSearch']", state="visible", timeout=10_000)
|
||
page.locator("[id*='SelectBoilerDialog_TextBoxFullSearch']").click(force=True)
|
||
page.keyboard.type(query)
|
||
page.locator("[id*='SelectBoilerDialog_HyperLinkActionBoilerSearch']").click(force=True)
|
||
page.wait_for_timeout(SETTLE_MS * 4)
|
||
pt = page.evaluate(
|
||
"""(ref)=>{const t=document.querySelector("[id*=SelectBoilerDialog] [id*=GridView]");if(!t)return null;
|
||
for(const r of t.querySelectorAll('tr')){const tds=[...r.querySelectorAll('td')];
|
||
if(tds.length>2 && tds[2].innerText.replace(/\\s+/g,' ').trim()===ref){
|
||
const v=tds.find(td=>td.offsetParent&&td.getClientRects().length);
|
||
if(v){const c=v.getBoundingClientRect();return {x:c.x+c.width/2,y:c.y+c.height/2};}}}return null;}""",
|
||
ref,
|
||
)
|
||
if not pt:
|
||
return "row-not-found"
|
||
page.mouse.click(pt["x"], pt["y"]) # highlight the row
|
||
page.wait_for_timeout(SETTLE_MS)
|
||
page.locator("[id*=SelectBoilerDialog] span", has_text="Select").filter(
|
||
visible=True
|
||
).first.click(force=True, timeout=8_000)
|
||
page.wait_for_timeout(SETTLE_MS * 3)
|
||
return "ok"
|
||
|
||
|
||
def read_default_uvalue(
|
||
page: Page, suffix: Optional[str] = None, label_regex: str = r"Default U-value"
|
||
) -> Optional[float]:
|
||
"""Read the read-only "Default U-value [W/m²K]" the tool computes for the
|
||
active fabric element (wall/roof/floor), after the type/insulation/thickness
|
||
postbacks have settled.
|
||
|
||
The override checkbox ("U-value Known") is documented as unreliable to toggle,
|
||
but the *default* field is a plain display (a SPAN whose text IS the value,
|
||
e.g. `…WebUserControlWallMain_LabelUValueDefault`) that RdSAP recomputes on
|
||
every postback — that recomputed number is Elmhurst's
|
||
age-band×insulation×thickness answer, which is exactly what the probe wants.
|
||
|
||
Preferred: pass the exact `suffix` (after FP) of that label span. Without it,
|
||
falls back to LABEL PROXIMITY (match `label_regex`, read the nearest numeric),
|
||
which is brittle near the thickness box. Returns None if not found (caller
|
||
should fall back to the screenshot)."""
|
||
if suffix is not None:
|
||
raw_text = page.evaluate(
|
||
"(id)=>{const e=document.getElementById(id); return e?(e.textContent||'').trim():null;}",
|
||
f"{FP}{suffix}",
|
||
)
|
||
if raw_text is not None:
|
||
m = re.search(r"-?\d+(?:\.\d+)?", str(raw_text))
|
||
if m:
|
||
return float(m.group(0))
|
||
return None
|
||
raw = page.evaluate(
|
||
"""(rx)=>{
|
||
const re=new RegExp(rx,'i');
|
||
// Candidate label nodes: any visible element whose own text matches.
|
||
const labels=[...document.querySelectorAll('label,span,td,div,th')]
|
||
.filter(e=>e.offsetParent && re.test((e.textContent||'').trim()));
|
||
const numFrom=(s)=>{const m=(s||'').match(/-?\\d+(?:\\.\\d+)?/); return m?m[0]:null;};
|
||
for(const lab of labels){
|
||
// 1) a sibling/descendant input carrying the value
|
||
let scope=lab.closest('tr,div,td')||lab.parentElement||lab;
|
||
const inp=scope.querySelector("input[type='text'],input:not([type])");
|
||
if(inp && numFrom(inp.value)!==null) return inp.value;
|
||
// 2) the next cell's text (label | value table layout)
|
||
let sib=lab.nextElementSibling;
|
||
for(let i=0;i<3 && sib;i++,sib=sib.nextElementSibling){
|
||
const n=numFrom(sib.textContent);
|
||
if(n!==null && !re.test(sib.textContent)) return n;
|
||
}
|
||
// 3) trailing number in the label's own container, after the label text
|
||
const tail=numFrom(scope.textContent.replace(lab.textContent,''));
|
||
if(tail!==null) return tail;
|
||
}
|
||
return null;}""",
|
||
label_regex,
|
||
)
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return float(str(raw).strip())
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def clear_hot_water_cylinder(page: Page, panel: str = "TabContainer_TabPanelWaterHeating_") -> None:
|
||
"""Uncheck the HW cylinder (combi). The checkbox won't uncheck via
|
||
.uncheck()/commit (a regular-boiler default re-asserts it); JS-set
|
||
checked=false + fire click + change drives the AutoPostBack."""
|
||
cid = f"{FP}{panel}CheckBoxHotWaterCylinder"
|
||
if not page.locator(f"#{cid}").is_checked():
|
||
return
|
||
try:
|
||
with page.expect_navigation(wait_until="load", timeout=8_000):
|
||
page.evaluate(
|
||
"""(id)=>{const c=document.getElementById(id);if(c){c.checked=false;
|
||
c.dispatchEvent(new Event('click',{bubbles:true}));
|
||
c.dispatchEvent(new Event('change',{bubbles:true}));}}""",
|
||
cid,
|
||
)
|
||
except PlaywrightTimeoutError:
|
||
page.wait_for_timeout(2_000)
|