Model/scripts/hyde/build_100031768368.py
Jun-te Kim 5cae4a0ce5 fix(rdsap): RdSAP 10 Table 13 200/280mm solid-brick wall boundary
_u_brick_thin_wall_age_a_to_e's `<= 280` check put an uninsulated solid-
brick wall of exactly 280mm in the "200 to 280mm -> 1.7" row; RdSAP 10
§5.7 Table 13 (spec PDF p.41) shares 280 as an unlabelled edge between
that row and "280 to 420mm -> 1.4", so the table text alone doesn't say
which row owns it.

Found by building cert 100031768368 (280mm exactly, band C) in Elmhurst
and comparing every worksheet line to the calculator: volume/ACH/floor/
doors matched exactly, but the wall didn't (1.70 vs Elmhurst's 1.40)
despite an identical input crosswalk. Initially reverted a first attempt
at this fix when it appeared to regress an already-pinned cert
(217091901, band A, also nominally 280mm, previously "confirmed" at
U=1.70) — but that cert's build script never actually set a wall
thickness field, so its shared Elmhurst assessment had silently
inherited a stale 260mm from an earlier build. Fixed that build script
and rebuilt with the correct 280mm entry: Elmhurst's worksheet now also
gives U=1.40, matching 100031768368 and confirming the fix rather than
contradicting it.

Both certs move to (near-)exact lodged matches: 100031768368 59.12 ->
61.21 (lodged 61, within 0.5); 217091901 60.82 -> 61.59 (lodged 62,
exact). Corpus gauge 77.8% -> 78.6% within-0.5, MAE 0.636 -> 0.627 (14
corpus certs lodge exactly 280mm solid brick). TDD'd, 46 pins + full
suite green (2310 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:25:07 +00:00

252 lines
11 KiB
Python

"""Elmhurst build for UPRN 100031768368 (RdSAP-21.0.1, END-TERRACE HOUSE, band C,
SOLID BRICK 280 mm uninsulated, pitched 100 mm loft insulation, SOLID ground
floor uninsulated, mains-gas COMBI (PCDB 18250, Baxi 630 Combi 88.4%) +
gas ROOM-HEATER SECONDARY, single electric shower with WWHRS (no mixer
shower), extract fan lodged (1) + 1 other-heater flue, 10 DG windows across
2 orientations (~13.48 m2 total: East 5.52 m2, West 5.13+2.83 m2 across two
glazing eras), TFA 76. Lodged SAP 61; engine 59.12 (Delta -1.88).
Per-cert Elmhurst validation, continuing the 1-2 SAP-band reconciliation
pass. Uses set_window_groups (3 orientation/glazing groups, not a single
collapsed window — see the 100061275133 finding that a single-orientation
simplification meaningfully understates solar gain) + reset_transient_state
at the end so the shared assessment is left neutral. Run:
DISPLAY=:99 python scripts/hyde/build_100031768368.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_100031768368")
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", "end")
E.set_text(page, "TextBoxStoreys", "2")
E.set_text(page, "TextBoxHabitableRooms", "4")
E.set_text(page, "TextBoxHeatedHabitableRooms", "4")
print("date ->", _pick(page, "DropDownListDateBuiltMain", "1930-1949"))
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", "37.75")
E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.45")
E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "20.1")
E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "5.0")
E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "37.75")
E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.45")
E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "20.1")
E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "5.0")
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"))
for suf in (f"{WALL}TextBoxThickness", f"{WALL}TextBoxWallThickness"):
if page.locator(f"#{E.FP}{suf}").count():
E.set_text(page, suf, "280"); print("wall thickness set 280 via", suf); break
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", "100")
E.save_close(page)
def floors(page):
E.goto(page, "Floors", "WebFormFloors.aspx")
_pick(page, f"{FLOOR}DropDownListLocation", "ground floor")
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)
# 3 orientation/glazing-era groups (not a single collapsed window):
# East 5.52 m2 (double pre-2002), West 5.13 m2 (double pre-2002), West
# 2.83 m2 (double 2002-2022, the one larger newer window index 8).
# The glazing dropdown retains its last-selected value across adds; if
# the next row wants the SAME text, selecting it again fires no 'change'
# event (identical value -> no transition) and the row commits with a
# blank Glazing Type. Force a transition through a different option
# first so every add gets a genuine change event.
for area, orient, glazing in (
(5.5174, "East", "Double pre 2002"),
(5.1346, "West", "Double pre 2002"),
(2.8288, "West", "Double between 2002 and 2021"),
):
E.set_select(page, f"{E._WP}DropDownListExtGlazing", "Secondary glazing") # noqa: SLF001
page.wait_for_timeout(300)
E.add_combined_window(page, area, orient, glazing)
page.wait_for_timeout(500)
guard = 0
while E.window_row_count(page) > 3 and guard < 25:
guard += 1
i = E.window_row_count(page) - 1
if not E._delete_window_at_index(page, i): # noqa: SLF001
break
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", "1") # cert: percent_draughtproofed=100
E.save_close(page)
def ventilation(page):
E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx")
E.click_tab(page, "TabContainer_TabPanelVentilationPanel")
E.set_text(page, f"{VP}TextBoxIntermittentFans", "1") # cert: extract_fans_count=1
E.set_text(page, f"{VP}TextBoxFluesOtherHeater", "1") # cert: other_flues_count=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", "13")
E.set_text(page, f"{LP}TextBoxLedLightsTotal", "10")
E.set_text(page, f"{LP}TextBoxCflLightsTotal", "2")
E.save_close(page)
def space_heating(page):
# PCDB exact-boiler entry: cert's main_heating_index_number 18250
# resolves to Baxi 630 Combi, 88.4% winter efficiency.
E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx")
page.wait_for_timeout(1000)
E.clear_main_heating_code(page)
desc = E.set_pcdb_boiler(page, 18250)
print("PCDB boiler ->", desc)
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())
# cert lodges secondary_heating_type=605 ("Room heaters, mains gas",
# SAP10 Table 4a eff 0.40) — a genuine secondary system, unlike
# 100061275133's "None". The dialog is a 4-level cascade (fuel ->
# sub-fuel -> heater class -> specific product), not 3 like main
# heating controls. Probed all 12 RGx options by resolved SAP code;
# "RGH Flush fitting live effect gas fire, sealed to chimney" is the
# exact match for code 605.
E.set_select(page, "DropDownListSecondaryHeatingPresent", "Yes")
page.wait_for_timeout(900)
E.set_heating_dialog(page, "ButtonSecondaryHeatingCode",
"^Gas", "Mains gas", "Room Heaters", "RGH")
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 baths_showers(page):
"""cert: number_baths=1, shower_outlets=[electric shower, WWHRS=1],
mixer_shower_count=0 — an instantaneous ELECTRIC shower with WWHRS
fitted, not a mixer shower off the main system."""
E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx")
E.click_tab(page, "TabContainer_TabPanelBathsShowers")
page.wait_for_timeout(400)
print("(manual check needed: set 1 bath, 1 electric shower with WWHRS)")
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", "baths_showers",
"water_heating", "download", "cleanup"]
def main():
if len(sys.argv) < 2 or sys.argv[1] not in _ORDER:
print("usage: build_100031768368.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())