chore(hyde): window-grid entry findings (fill+Tab persistence, frame/gap required)

Continued hardening for the per-cert Elmhurst loop. Confirmed findings:
- The window Width/Height must be set via Playwright .fill() + Tab (fires
  the ASP.NET AutoPostBack so the server commits the area); a JS-set of
  .value bypasses the postback and Elmhurst persists a 0.00-area row.
- Frame Type (PVC) + Glazing Gap (12 mm) are REQUIRED per row or the
  Recommendations gate blocks the worksheet ("Frame Type/Glazing Gap must
  be entered"). add_combined_window now sets both.

KNOWN BLOCKER (documented for the next attempt): the window grid is not
yet reliably automatable when stale rows exist on the shared assessment.
New rows insert at index 0; the delete flow reliably removes only index 0
(the newest), so accumulated 0.00 junk rows BELOW cannot be targeted, and
grid reads are timing-inconsistent across postbacks. A robust handler
needs to either (a) drive the per-row Delete by matching the row's
server-side id rather than DOM index, or (b) start each build from a
freshly-created assessment (not the shared one) so the grid is empty.
Until then the per-cert loop runs only on the FIRST build after a manual
window reset. build_100050881708.py is the WIP template.

No engine change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-03 03:44:49 +00:00
parent 7987c94876
commit b4dbaef7f2
2 changed files with 243 additions and 15 deletions

View file

@ -0,0 +1,223 @@
"""Elmhurst build for UPRN 100050881708 (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_100050881708.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_100050881708")
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", "semi")
E.set_text(page, "TextBoxStoreys", "2")
E.set_text(page, "TextBoxHabitableRooms", "3")
E.set_text(page, "TextBoxHeatedHabitableRooms", "3")
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", "31.62")
E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.40")
E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "16.4")
E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "6.2")
E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "31.62")
E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.40")
E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "16.4")
E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "6.2")
E.save_close(page)
def walls(page):
E.goto(page, "Walls", "WebFormWalls.aspx")
_pick(page, f"{WALL}DropDownListType", "cavity")
page.wait_for_timeout(500)
print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "filled"))
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", "225")
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, 9.56, "North",
_glazing(page) if False else "Double post or during 2022")
E.click_tab(page, "TabContainer_TabPanelDoorsPanel")
E.set_text(page, f"{DP}TextBoxDoors", "2")
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", "2")
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())
E.set_select(page, "DropDownListSecondaryHeatingPresent", "No")
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", "water_heating", "download", "cleanup"]
def main():
if len(sys.argv) < 2 or sys.argv[1] not in _ORDER:
print("usage: build_100050881708.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

@ -238,32 +238,37 @@ def add_combined_window(
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."""
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 _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)
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
_jset(f"{_WP}TextBoxExtWidth", str(total_area_m2))
_jset(f"{_WP}TextBoxExtHeight", "1.00")
_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():
loc.select_option(opt)
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")