Model/scripts/hyde/elmhurst_lib.py
Jun-te Kim 3fd7c5708d feat(hyde): PCDB exact-boiler entry (set_pcdb_boiler + clear_main_heating_code)
Cracks the 'PCDB disabled' blocker. The PCDF boiler reference resolves the exact
lodged boiler (e.g. 18908 -> Worcester Greenstar 4000, 88.70%) when: (1) the main
heating CODE is empty server-side (else the field + magnifying glass are
aspNetDisabled), and (2) the index is entered via REAL keyboard + Tab (a JS-set
.value doesn't survive the AutoPostBack). Now Elmhurst uses the same boiler
efficiency as the SAP engine, so worksheet vs calculator compare like-for-like.

Verified on 100061275133: Elmhurst now shows 'Efficiency of main space heating
system 1 = 88.70%' (was generic 84%). Includes updated worksheet evidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:49:23 +00:00

603 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 reusable campaign assessment ("Khalim-test"), overwritten per UPRN. Can be
# overridden per run via the `ELM_GUID` env var (e.g. a per-UPRN assessment under
# a different account) — see Limitations "parameterize the GUID".
ASSESSMENT_GUID = os.environ.get(
"ELM_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},
# Container /dev/shm is tiny (64M) → chromium renderer "Target
# crashed" mid-build without this. --no-sandbox for rootless Xvfb.
args=["--disable-dev-shm-usage", "--no-sandbox"],
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.on("dialog", lambda d: d.accept())
# Login can be slow/flaky; retry the whole goto+fill a few times.
for attempt in range(4):
try:
page.goto(ENTRY_URL, wait_until="domcontentloaded", timeout=60_000)
if "WebFormLogin" not in page.url:
break # already authed (server session still valid)
access, pwd = _creds()
ac = "#ctl00_ctl00_ContentBody_ContentBody_TextBoxAccessCode"
page.wait_for_selector(ac, state="visible", timeout=30_000)
page.fill(ac, 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)
if "WebFormLogin" not in page.url:
break
except PlaywrightTimeoutError:
print(f" login attempt {attempt+1} timed out, retrying...", flush=True)
page.wait_for_timeout(2000)
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.
Glazing MUST be selected first (it AutoPostBacks and re-renders the form);
width/height are then set via a Playwright `.fill()` + Tab so the ASP.NET
AutoPostBack fires and the server-side Area (w×h) commits — a JS-set of the
value bypasses the postback and Elmhurst persists a 0.00-area row.
Frame Type + Glazing Gap are REQUIRED by the Recommendations validation
gate (else "Frame Type/Glazing Gap must be entered" blocks the worksheet)."""
set_select(page, f"{_WP}DropDownListExtGlazing", glazing)
page.wait_for_timeout(SETTLE_MS)
def _fill_tab(suffix: str, value: str) -> None:
loc = page.locator(f"#{FP}{suffix}")
loc.fill(value)
loc.press("Tab")
page.wait_for_timeout(1200) # AutoPostBack settle
_fill_tab(f"{_WP}TextBoxExtWidth", str(total_area_m2))
_fill_tab(f"{_WP}TextBoxExtHeight", "1.00")
# Frame Type (PVC) + Glazing Gap (12 mm) are validation-required per row.
for suffix, opt in (
(f"{_WP}DropDownListExtFrameType", "PVC"),
(f"{_WP}DropDownListExtGlazingGap", "12 mm"),
(f"{_WP}DropDownListExtOrientation", orientation),
(f"{_WP}DropDownListExtBuildingPartId", "Main"),
(f"{_WP}DropDownListExtLocation", "External wall"),
):
loc = page.locator(f"#{FP}{suffix}")
if loc.count():
try:
loc.select_option(opt)
except Exception:
pass
page.wait_for_timeout(250)
page.wait_for_timeout(300)
before = window_row_count(page)
_jsclick(page, f"{FP}{_WP}ButtonAddWindow")
for _ in range(20):
page.wait_for_timeout(250)
if window_row_count(page) > before:
break
def _delete_window_at_index(page: Page, i: int) -> bool:
"""Delete the window grid row at DOM index `i` via its per-row Delete
image button + the Yes modal (JS-clicked to bypass the modal background
that intercepts real clicks). Returns True if a delete fired."""
bid = (
"ContentBody_ContentPlaceHolder1_TabContainer_TabPanelWindowsPanel_"
f"GridViewExtendedWidows_DeleteButton_{i}"
)
if not page.evaluate("(id)=>!!document.getElementById(id)", bid):
return False
page.evaluate("(id)=>{const e=document.getElementById(id);if(e)e.click();}", bid)
page.wait_for_timeout(2000)
yid = "ContentBody_ContentPlaceHolder1_DeleteWindowDialog_LinkButtonYes"
if not page.evaluate(
"(id)=>{const e=document.getElementById(id);return e?e.offsetParent!==null:false;}",
yid,
):
return False
page.evaluate("(id)=>{const e=document.getElementById(id);if(e)e.click();}", yid)
page.wait_for_timeout(2500)
return True
def set_single_window(
page: Page, total_area_m2: float, orientation: str = "North",
glazing: str = "Double post or during 2022",
) -> None:
"""Leave the window grid holding EXACTLY one correct row of
`total_area_m2`, cleaning any rows carried over from a prior build of the
SHARED assessment.
The grid can't be emptied (Elmhurst refuses to delete the last row) and it
has no inline edit, so per [[window-grid]] the reliable pattern is: ADD the
fresh window (it inserts at DOM index 0), then delete the STALE rows BELOW
it (indices high->low, never index 0) so the freshly-added row survives as
the single remaining window. Deleting index 0 (delete_first_window) would
drop the good row and strand a 0.00-area junk row that then blocks the
Recommendations gate."""
add_combined_window(page, total_area_m2, orientation, glazing)
page.wait_for_timeout(500)
guard = 0
while window_row_count(page) > 1 and guard < 25:
guard += 1
i = window_row_count(page) - 1 # bottom-most (oldest) stale row
if not _delete_window_at_index(page, i):
break
def reset_transient_state(page: Page) -> None:
"""Return the shared assessment to a NEUTRAL state after a run so the next
build (or Khalim's) is not polluted by this cert's carried-over settings.
Clears the room-in-roof age band, forces secondary-heating present = No,
and the electricity meter back to Single. The window grid is left to the
next build's `set_single_window` (Elmhurst can't empty it to zero rows).
Call once at the end of a build session."""
goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx")
rr = page.locator("#ContentBody_ContentPlaceHolder1_DropDownListRoomInRoofMain")
if rr.count() and rr.input_value():
rr.select_option("")
page.wait_for_timeout(SETTLE_MS)
save_close(page)
goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx")
page.wait_for_timeout(SETTLE_MS)
sec = page.locator(f"#{FP}DropDownListSecondaryHeatingPresent")
if sec.count():
try:
set_select(page, "DropDownListSecondaryHeatingPresent", "No")
except Exception:
pass
meters = "TabContainer_TabPanelMeters"
click_tab(page, meters)
et = page.locator(f"#{FP}{meters}_RadioButtonListElectricityType")
if et.count():
try:
set_select(page, f"{meters}_RadioButtonListElectricityType", "Single")
except Exception:
pass
save_close(page)
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 clear_main_heating_code(page: Page) -> None:
"""Empty Main Heating 1's SAP code via a real server postback (set the
cascade's top-level dropdown to '' + OK). REQUIRED before `set_pcdb_boiler`:
while any code is set, the PCDF reference textbox and the boiler-search
magnifying glass both render `aspNetDisabled`. They only enable when the
code is empty server-side (a JS-clear alone flips the class cosmetically but
leaves the postback unwired)."""
page.evaluate(
"(id)=>{const e=document.getElementById(id); if(e)e.click();}",
f"{MH1}ButtonMainHeatingCode",
)
page.wait_for_timeout(SETTLE_MS * 3)
page.evaluate(
"""(id)=>{const s=document.getElementById(id);if(s){s.value='';
s.dispatchEvent(new Event('change',{bubbles:true}));}}""",
f"{HEATING_DIALOG}DropDownList1",
)
page.wait_for_timeout(SETTLE_MS * 3)
dialog_commit(page, "OK")
page.wait_for_timeout(SETTLE_MS * 2)
def set_pcdb_boiler(page: Page, pcdb_index: "int | str") -> str:
"""Set Main Heating 1 to the EXACT PCDB boiler by its EPC
`main_heating_index_number` (e.g. 18908 = Worcester Greenstar 4000,
GR4700iW 25 C NG, 88.70%). Elmhurst resolves it to the product's PCDB
winter efficiency — the same value the SAP engine uses — so the worksheet
and our calculator compare like-for-like (not against the generic BGW
SAP-Table 84%). Returns the resolved boiler description.
Hard-won mechanism (supersedes the old "PCDB disabled" note):
1. The PCDF reference textbox is only writable while the main heating CODE
is empty — call `clear_main_heating_code(page)` first.
2. The index must be entered via REAL keyboard + Tab. A JS-set `.value`
does NOT survive the field's AutoPostBack, so the boiler never
resolves (the original failure mode). Ctrl-A + type + Tab works.
3. The `TextBoxPCDFBoilerReference` direct-entry path is far simpler than
the magnifying-glass grid (whose modal has no layout box, so
mouse/Playwright clicks can't target rows). The grid search DOES work
(JS-focus the FullSearch box + `keyboard.type` + fire
HyperLinkActionBoilerSearch → "Ref No" column == the PCDB index) if a
name lookup is ever needed, but direct entry is preferred."""
fld = f"{MH1}TextBoxPCDFBoilerReference"
page.evaluate(
"(id)=>{const e=document.getElementById(id);if(e){e.focus();e.select();}}", fld
)
page.wait_for_timeout(SETTLE_MS)
page.keyboard.press("Control+a")
page.keyboard.type(str(pcdb_index), delay=40)
page.keyboard.press("Tab")
page.wait_for_timeout(SETTLE_MS * 5) # AutoPostBack resolves the PCDB record
return page.evaluate(
"""()=>{const e=document.querySelector('[id*=LabelMHSSapCodeDescription]');
return e?e.innerText.trim():'';}"""
)
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)