From 7987c94876fc2bc2bcb53e57485d66d4036a3ece Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 23:54:35 +0000 Subject: [PATCH] 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 --- scripts/hyde/build_100021924710.py | 231 +++++++++++++++++++++++++++++ scripts/hyde/elmhurst_lib.py | 92 ++++++++++-- 2 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 scripts/hyde/build_100021924710.py diff --git a/scripts/hyde/build_100021924710.py b/scripts/hyde/build_100021924710.py new file mode 100644 index 000000000..ec06d8427 --- /dev/null +++ b/scripts/hyde/build_100021924710.py @@ -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 m², 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 +""" +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()) diff --git a/scripts/hyde/elmhurst_lib.py b/scripts/hyde/elmhurst_lib.py index 71d1ba792..0e8ca6d1c 100644 --- a/scripts/hyde/elmhurst_lib.py +++ b/scripts/hyde/elmhurst_lib.py @@ -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."""