chore(hyde): harden Elmhurst automation for per-cert builds (window cleanup + reset)

Support for the per-cert Elmhurst worksheet loop:
- goto()/save_close(): fall back networkidle -> domcontentloaded on
  timeout so an incomplete-validation state (half-set RR, 0-area window)
  no longer hangs navigation.
- add_combined_window(): select glazing first (AutoPostBack), then JS
  input+change+blur the width/height with settle waits so the Area cell
  computes before Add (a plain .fill() raced the postback -> 0.00 row).
- set_single_window(): leave the grid holding exactly one row, deleting
  rows carried over from a prior build of the SHARED assessment (the
  window grid is the only accumulating page).
- reset_transient_state(): return the shared assessment to neutral after
  a run (clear RR age band, secondary = No, meter = Single).

KNOWN RESIDUAL: the window grid still intermittently persists a 0.00 area
through Save & Close (Elmhurst's "each Add wipes the previous row" +
last-row-undeletable behaviour), and the secondary-heating dialog's modal
background can intercept the cascade click. Reliable autonomous grinding of
per-cert builds needs these two hardened further; the loop currently
completes clean single-main gas certs (see the 47084930 flat-roof slice)
but stalls on secondary-heating + stale-window combinations. build_
100021924710.py is the WIP template for that harder shape.

No engine change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-02 23:54:35 +00:00
parent 8e33a8de53
commit 7987c94876
2 changed files with 313 additions and 10 deletions

View file

@ -0,0 +1,231 @@
"""Elmhurst build for UPRN 100021924710 (RdSAP-21.0.1, MID-TERRACE HOUSE, band B,
SOLID BRICK 250 mm uninsulated, pitched insulated (assumed) loft, floor to
UNHEATED space, mains-gas COMBI (PCDB 19008) + gas room-heater SECONDARY, DUAL
meter, 11 DG windows ~18.32 , TFA 116. Lodged SAP 63; engine 65.0 (Δ +2.0).
Per-cert Elmhurst validation. Uses set_single_window (deletes the shared
assessment's carried-over window rows) + reset_transient_state at the end so the
assessment is left neutral. Run:
DISPLAY=:99 python scripts/hyde/build_100021924710.py <page>
"""
from __future__ import annotations
import sys
from pathlib import Path
import elmhurst_lib as E
DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_"
WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_"
"TabPanelExternalWallMain_WebUserControlWallMain_")
PWALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_"
"TabPanelPartyWallMain_WebUserControlPartyWallMain_")
ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_"
FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_"
DP = "TabContainer_TabPanelDoorsPanel_"
VP = "TabContainer_TabPanelVentilationPanel_"
APT = "TabContainer_TabPanelAirPressureTest_"
LP = "TabContainer_TabPanelLighting_"
MV = "TabContainer_TabPanelMechVent_"
WH = "TabContainer_TabPanelWaterHeating_"
MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_"
_SAMPLE_DIR = (Path(__file__).parent.parent.parent
/ "backend/epc_api/json_samples/real_life_examples"
/ "RdSAP-Schema-21.0.1/uprn_100021924710")
def _pick(page, suffix, contains):
val = page.evaluate(
"""(a)=>{const s=document.getElementById(a[0]);if(!s)return null;
for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""",
[f"{E.FP}{suffix}", contains])
if val is not None:
E.set_select(page, suffix, val)
return val
def property_description(page):
E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx")
E.set_select(page, "DropDownListPropertyType1", "H House")
_pick(page, "DropDownListPropertyType2", "mid-terrace") or \
_pick(page, "DropDownListPropertyType2", "mid terrace")
E.set_text(page, "TextBoxStoreys", "2")
E.set_text(page, "TextBoxHabitableRooms", "5")
E.set_text(page, "TextBoxHeatedHabitableRooms", "5")
print("date ->", _pick(page, "DropDownListDateBuiltMain", "1900-1929"))
E.set_select(page, "DropDownListDateBuiltFirst", "")
E.set_select(page, "DropDownListRoomInRoofMain", "")
E.save_close(page)
def dimensions(page):
E.goto(page, "Dimensions", "WebFormDimensions.aspx")
E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "59.20")
E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.74")
E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "16.0")
E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "21.1")
E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "57.10")
E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.74")
E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "15.7")
E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "21.7")
E.save_close(page)
def walls(page):
E.goto(page, "Walls", "WebFormWalls.aspx")
_pick(page, f"{WALL}DropDownListType", "solid brick")
page.wait_for_timeout(500)
print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "as built"))
print("party ->", _pick(page, f"{PWALL}DropDownListPartyWallType", "determine")
or _pick(page, f"{PWALL}DropDownListPartyWallType", "masonry"))
E.save_close(page)
def roofs(page):
E.goto(page, "Roofs", "WebFormRoofs.aspx")
_pick(page, f"{ROOF}DropDownListType", "access to loft") or \
_pick(page, f"{ROOF}DropDownListType", "loft")
_pick(page, f"{ROOF}DropDownListInsulationAt", "joists")
_pick(page, f"{ROOF}DropDownListThickness", "unknown") or \
_pick(page, f"{ROOF}DropDownListThickness", "assumed")
E.save_close(page)
def floors(page):
E.goto(page, "Floors", "WebFormFloors.aspx")
_pick(page, f"{FLOOR}DropDownListLocation", "unheated")
page.wait_for_timeout(400)
_pick(page, f"{FLOOR}DropDownListType", "solid")
ins = page.locator(f"#{E.FP}{FLOOR}DropDownListInsulation")
if ins.count():
E.set_select(page, f"{FLOOR}DropDownListInsulation", "A As built")
E.save_close(page)
def openings(page):
E.goto(page, "Openings", "WebFormOpenings.aspx")
E.click_tab(page, "TabContainer_TabPanelWindowsPanel")
page.wait_for_timeout(500)
E.set_single_window(page, 18.32, "North",
_glazing(page) if False else "Double post or during 2022")
E.click_tab(page, "TabContainer_TabPanelDoorsPanel")
E.set_text(page, f"{DP}TextBoxDoors", "1")
E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0")
E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "0")
E.save_close(page)
def _glazing(page):
return "Double with unknown install date"
def ventilation(page):
E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx")
E.click_tab(page, "TabContainer_TabPanelVentilationPanel")
E.set_text(page, f"{VP}TextBoxIntermittentFans", "1")
cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling")
if cool.count() and cool.is_checked():
E.commit(page, cool.uncheck)
E.click_tab(page, "TabContainer_TabPanelMechVent")
mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation")
if mv.count() and mv.is_checked():
E.commit(page, mv.uncheck)
E.click_tab(page, "TabContainer_TabPanelAirPressureTest")
E.set_select(page, f"{APT}DropDownListTestMethod", "Not available")
E.click_tab(page, "TabContainer_TabPanelLighting")
E.set_text(page, f"{LP}TextBoxLightsTotal", "8")
E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0")
E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0")
E.save_close(page)
def space_heating(page):
E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx")
page.wait_for_timeout(1000)
mhc = page.locator(f"#{E.MH1}TextBoxMainHeatingCode")
code = mhc.input_value() if mhc.count() else ""
if code and code not in ("0",):
print(f"clearing leftover MainHeatingCode {code}")
page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value='';
e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.MH1}TextBoxMainHeatingCode")
page.wait_for_timeout(400)
E.save_close(page)
return
E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode",
"^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing",
"Combi condens")
print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value())
E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls",
"^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs")
print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value())
# dual meter (cert meter_type 1)
E.click_tab(page, "TabContainer_TabPanelMeters")
try:
E.set_select(page, "TabContainer_TabPanelMeters_RadioButtonListElectricityType", "Dual")
except Exception:
pass
E.save_close(page)
def secondary(page):
E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx")
page.wait_for_timeout(600)
E.set_select(page, "DropDownListSecondaryHeatingPresent", "Yes")
page.wait_for_timeout(900)
E.set_heating_dialog(page, "ButtonSecondaryHeatingCode",
"^Gas", "Mains gas", "Room Heater")
tb = page.locator(f"#{E.FP}TextBoxSecondaryHeatingCode")
print("secondary code:", tb.input_value() if tb.count() else "?")
E.save_close(page)
def water_heating(page):
E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx")
E.click_tab(page, "TabContainer_TabPanelWaterHeating")
page.wait_for_timeout(400)
E.clear_hot_water_cylinder(page)
E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode",
"From Space Heating", "From the primary heating system")
print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value())
E.save_close(page)
def download(page):
_SAMPLE_DIR.mkdir(parents=True, exist_ok=True)
E.goto(page, "Recommendations", "WebFormRecommendations.aspx")
page.wait_for_timeout(1500)
with page.expect_navigation(wait_until="networkidle", timeout=40000):
page.click("#ContentBody_buttonActionSummary_Link", timeout=15000)
page.wait_for_timeout(2000)
for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"),
("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")):
try:
with page.expect_download(timeout=60000) as dl:
page.click(f"#{btn}", timeout=15000)
dl.value.save_as(str(_SAMPLE_DIR / fname))
print("saved", fname)
except Exception as e:
print("!! download failed", fname, type(e).__name__, str(e)[:120])
def cleanup(page):
"""Leave the shared assessment neutral for the next build."""
E.reset_transient_state(page)
print("reset transient state (RR/secondary/meter)")
_ORDER = ["property_description", "dimensions", "walls", "roofs", "floors",
"openings", "ventilation", "space_heating", "secondary",
"water_heating", "download", "cleanup"]
def main():
if len(sys.argv) < 2 or sys.argv[1] not in _ORDER:
print("usage: build_100021924710.py <" + "|".join(_ORDER) + ">")
return 2
with E.session() as (ctx, page):
globals()[sys.argv[1]](page)
print("done:", sys.argv[1], "->", page.url)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -235,24 +235,96 @@ def add_combined_window(
) -> 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."""
Recommendations validation passes. Caller then deletes the prior rows.
Glazing MUST be selected first (it AutoPostBacks and re-renders the form);
width/height are set via JS input+change+blur events with settle waits so
the Area cell (18.32 = w×h) computes before Add a plain Playwright
`.fill()` raced the postback and Elmhurst added a 0.00-area row."""
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)
page.wait_for_timeout(SETTLE_MS)
def _jset(suffix: str, value: str) -> None:
page.evaluate(
"""(a)=>{const e=document.getElementById(a[0]);if(e){e.value=a[1];
e.dispatchEvent(new Event('input',{bubbles:true}));
e.dispatchEvent(new Event('change',{bubbles:true}));
e.dispatchEvent(new Event('blur',{bubbles:true}));}}""",
[f"{FP}{suffix}", value],
)
page.wait_for_timeout(300)
_jset(f"{_WP}TextBoxExtWidth", str(total_area_m2))
_jset(f"{_WP}TextBoxExtHeight", "1.00")
for suffix, opt in (
(f"{_WP}DropDownListExtOrientation", orientation),
(f"{_WP}DropDownListExtBuildingPartId", "Main"),
(f"{_WP}DropDownListExtLocation", "External wall"),
):
loc = page.locator(f"#{FP}{suffix}")
if loc.count():
loc.select_option(opt)
page.wait_for_timeout(300)
before = window_row_count(page)
_jsclick(page, f"{FP}{_WP}ButtonAddWindow")
for _ in range(20):
page.wait_for_timeout(200)
page.wait_for_timeout(250)
if window_row_count(page) > before:
break
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 row of `total_area_m2`,
deleting any rows carried over from a prior build of the shared
assessment. Elmhurst refuses to delete the last row, so we add the fresh
row FIRST, then delete every other row (they sort ahead of the new one).
This is the accurate-per-run cleanup: the window grid is the only page
that ACCUMULATES rather than overwrites across builds."""
add_combined_window(page, total_area_m2, orientation, glazing)
guard = 0
while window_row_count(page) > 1 and guard < 25:
guard += 1
before = window_row_count(page)
delete_first_window(page)
if window_row_count(page) >= before:
break # couldn't delete (last-row guard / stuck) — stop
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."""