Model/scripts/hyde/build_100010086084.py
Jun-te Kim e91e0fa33e Fix cylinder_size/boiler_flue_type input errors for UPRN 100010086084 (SAP-15.0)
Both flagged mismatches were Elmhurst input errors (same silent-stale-value
contamination class as the earlier chimneys/wall-thickness bugs), not
parser or calculator bugs:

- cylinder_size: build_100010086084.py's water_heating() selected
  DropDownListCylinderSize by raw value "2", but this DOM's option values
  ARE their visible litre-band text (no "2" among them) — Playwright
  silently no-ops on a non-matching value, leaving a prior cert's "Medium
  (131-170L)" selection in place. Fixed to match by text ("Normal"), and
  added the missing mapper dict entries (Normal/Large litre-suffixed
  labels) to _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10 (a real mapper-coverage
  gap — the calculator raises UnmappedElmhurstLabel rather than silently
  mis-mapping).

- boiler_flue_type: the boiler-code search dialog's combined "Balanced/Open
  Flue" Table 4b category doesn't drive the separate, independently
  selectable RadioButtonListFlueType field, which was left at an inherited
  "Balanced" from a prior cert. Fixed space_heating() to explicitly select
  "Open" to match the lodged gov-API code. This field isn't consumed by
  Sap10Calculator (ML/generator-only), so it had zero effect on the SAP
  score.

Re-downloaded elmhurst_summary.pdf/elmhurst_worksheet.pdf after the fixes
(Recommendations page confirmed clean). Elmhurst-PDF-inputs path moved
46->47 SAP from the cylinder-volume correction; gov-API SAP (53) and
Elmhurst's own worksheet (51) are unchanged. Full accuracy suites re-run
clean (67 passed, 67 skipped, 1 xfailed, same as before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:11:41 +00:00

316 lines
16 KiB
Python

"""Elmhurst build for UPRN 100010086084 (SAP-Schema-15.0, cert
8493-5079-7429-8996-5293, SEMI-DETACHED HOUSE, 9 Pear Tree Avenue, Crewe
CW1 3SH, band C 1930-1949, SOLID BRICK uninsulated (thickness not lodged --
UNKNOWN, not a specific mm value), pitched 300mm loft insulation, SOLID
ground floor uninsulated, mains-gas generic (no-PCDB) REGULAR boiler — SAP
Table 4b code 115 (pre-1998, balanced/open flue, "BGL Pre 98 Wall mount
BF/OF") + programmer/room-thermostat control (2104/CBC) + radiators +
cylinder (size 2 = "Normal (Less than 130L)" on the live tool, foam, 25mm), gas
room-heater SECONDARY (603, approximated as RGB "Modern fire with open flue
post 1980" -- the cert gives no finer detail), TFA 68, single meter,
2-storey / 2 floor-dims (no extensions). Partial double glazing
(multiple_glazing_type 1, multiple_glazed_proportion 50 -- NOT modelled as
partial; this mapper's own `_api_cascade_glazing_type(1)` treats it as 100%
double too, so the Elmhurst build matches the mapper's own simplification),
glazed_area band 1 (Normal): 0.148*68=10.06 m^2 combined window.
2011 lodged 45; this RdSAP10 engine on gov-API inputs produces 53. Elmhurst's
OWN worksheet, built here, gives 46 (window South-facing: 48 -- see below).
NEITHER is the "right answer" to chase blindly -- see the investigation
write-up in tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py
next to this cert's RealCertExpectation for the full comparison, what was
ruled out, and the two real-but-unfixed bugs this build surfaced (wall-
thickness-unknown U-value fallback mismatch; a window U-value divergence).
GOTCHAS specific to this build (fold into the next SAP-Schema-15.0 cert):
* The shared assessment WILL carry a stale PCDB boiler ref on Main Heating
1 from a prior build -- clear `TextBoxPCDFBoilerReference` to "0" (JS +
save) BEFORE `ButtonMainHeatingCode` becomes clickable (mirrors the
documented storage-heater "clear the PCDF boiler first" pattern).
* Wall thickness: use `CheckBoxWallThicknessUnknown`, NOT a blank/skipped
field -- an untouched thickness textbox silently keeps whatever a PRIOR
cert set (bit this build once: inherited a stale 280mm, landing on the
exact documented Table 13 200/280mm edge case).
* `set_single_window`'s orientation choice is NOT neutral for a cert this
reduced-field: North gave 46, South gave 48 (max solar gain) on this
cert. South is the fairer bound (this mapper's own N/E/S/W synthesis
spreads solar gain; a single North-facing pick is the worst case).
* The Roof/Cylinder thickness dropdowns need an EXACT option-text match
("300 mm" not "300 mm or more"; "25 mm" not "25") -- `set_select` fails
silently (no exception) on a non-matching value and leaves the field at
its previous/inherited state. ALWAYS verify with `.input_value()` after
setting, don't trust the call succeeded.
* Address/region are carried over from whatever cert built the shared
assessment last -- explicitly set them (see `address()` below) even
though region is provably inert for the SAP rating itself (UK-average
weather always, `_region_index` in cert_to_inputs.py) -- other summary
fields (post town) still read from it.
Run:
DISPLAY=:99 python scripts/hyde/build_100010086084.py <page>
"""
from __future__ import annotations
import sys
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_"
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 address(page):
page.goto(E.ENTRY_URL, wait_until="networkidle", timeout=60_000)
E.set_text(page, "TextBoxHouseNo", "9")
E.set_text(page, "TextBoxStreet", "Pear Tree Avenue")
E.set_text(page, "TextBoxTown", "Crewe")
E.set_text(page, "TextBoxPostCode", "CW1 3SH")
page.wait_for_timeout(300)
E.set_select(page, "DropDownListRegion", "West Pennines")
page.click("#ContentBody_ContentPlaceHolderNavigations_ButtonSaveClose", timeout=15_000)
page.wait_for_timeout(1000)
def property_description(page):
E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx")
E.set_select(page, "DropDownListPropertyType1", "H House")
_pick(page, "DropDownListPropertyType2", "semi") # built_form 2
E.set_text(page, "TextBoxStoreys", "2")
E.set_text(page, "TextBoxHabitableRooms", "3")
E.set_text(page, "TextBoxHeatedHabitableRooms", "3")
print("date ->", _pick(page, "DropDownListDateBuiltMain", "1930-1949")) # band C
E.set_select(page, "DropDownListDateBuiltFirst", "")
E.set_select(page, "DropDownListRoomInRoofMain", "")
E.save_close(page)
def dimensions(page):
E.goto(page, "Dimensions", "WebFormDimensions.aspx")
# Ground floor: area 37.74, heat_loss_perimeter 21.05 -> geometry-derived
# party wall (the shared/depth side) ~4.58m.
E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "37.74")
E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.41")
E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "21.05")
E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "4.58")
# First floor: area 39.48, heat_loss_perimeter 18.45 -> party wall ~6.75m.
E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "39.48")
E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.45")
E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "18.45")
E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "6.75")
E.save_close(page)
def walls(page):
E.goto(page, "Walls", "WebFormWalls.aspx")
E.set_select(page, f"{WALL}DropDownListType", "SO Solid brick")
page.wait_for_timeout(400)
print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "as built"))
# Thickness NOT lodged on this schema -- mark unknown explicitly, don't
# leave the textbox untouched (it silently inherits a stale value).
cb = page.locator(f"#{E.FP}{WALL}CheckBoxWallThicknessUnknown")
if cb.count() and not cb.is_checked():
E.commit(page, cb.check)
pw = _pick(page, f"{PWALL}DropDownListPartyWallType", "solid") \
or _pick(page, f"{PWALL}DropDownListPartyWallType", "determine")
print("party wall ->", pw)
E.save_close(page)
def roofs(page):
E.goto(page, "Roofs", "WebFormRoofs.aspx")
_pick(page, f"{ROOF}DropDownListType", "access to loft")
_pick(page, f"{ROOF}DropDownListInsulationAt", "joists")
E.set_select(page, f"{ROOF}DropDownListThickness", "300 mm") # exact match
print("roof thickness now:", page.locator(f"#{E.FP}{ROOF}DropDownListThickness").input_value())
E.save_close(page)
def floors(page):
E.goto(page, "Floors", "WebFormFloors.aspx")
E.set_select(page, f"{FLOOR}DropDownListLocation", "G Ground floor")
_pick(page, f"{FLOOR}DropDownListType", "solid")
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")
# glazed_area band 1 (Normal): total = 0.148*68*1.00 = 10.06 m^2. multiple_
# glazing_type=1 -> this mapper's own _api_cascade_glazing_type(1) treats
# it as 100% double (ignores multiple_glazed_proportion=50 the same way),
# so "Double, unknown install date" matches the mapper's own assumption.
# South (not North) -- max solar gain, the fairer single-orientation
# bound against this mapper's own N/E/S/W synthesis (see module docstring).
E.set_single_window(page, 10.06, "South", "Double with unknown install date")
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 ventilation(page):
E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx")
E.click_tab(page, "TabContainer_TabPanelVentilationPanel")
# This cert's register lodges open_fireplaces_count=0 (epc.json) -- the
# shared assessment's ventilation tab silently inherits a stale
# TextBoxOpenChimneys="2" from a PRIOR cert build (same untouched-field-
# keeps-prior-value gotcha documented above for wall thickness) unless
# explicitly zeroed here.
E.set_text(page, f"{VP}TextBoxOpenChimneys", "0")
E.set_text(page, f"{VP}TextBoxChimneysFluesClosedFire", "0")
E.set_text(page, f"{VP}TextBoxBlockedChimneys", "0")
E.set_text(page, f"{VP}TextBoxIntermittentFans", "0")
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")
# low_energy_lighting 43% of 7 fixed outlets -> 3 low-energy.
E.set_text(page, f"{LP}TextBoxLightsTotal", "7")
E.set_text(page, f"{LP}TextBoxLedLightsTotal", "3")
E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0")
E.save_close(page)
def space_heating(page):
# SAP Table 4b code 115: pre-1998 gas boiler, balanced/open flue, no PCDB
# index (main_heating_data_source=2). Regular boiler (has_hot_water_
# cylinder=true, water_heating_code 901 "from primary") + radiators
# (heat_emitter_type=1), fan_flue_present=N.
E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx")
page.wait_for_timeout(1000)
# The shared assessment's Main Heating 1 slot carries a stale PCDB boiler
# ref from a prior build -- clear it first (JS + save) so the SAP-code
# button appears; mirrors the documented storage-heater pattern.
ref = page.locator(f"#{E.FP}{MH1B}TextBoxPCDFBoilerReference")
if ref.count() and ref.input_value() not in ("", "0"):
print(f"clearing leftover PCDF boiler ref {ref.input_value()}")
page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value='0';
e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.FP}{MH1B}TextBoxPCDFBoilerReference")
page.wait_for_timeout(500)
E.save_close(page)
page.wait_for_load_state("networkidle", timeout=20_000)
page.wait_for_selector(
f"#{E.NAV}SpaceHeating_Link", state="visible", timeout=30_000
)
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
# Generic (no-PCDB) mains-gas REGULAR boiler, pre-1998 balanced/open flue
# (matches boiler_flue_type=1, fan_flue_present=N) -> Gas -> Mains gas ->
# Boilers -> Pre 1998 - Balanced/Open Flue -> Wall mount (regular, not
# combi/back-boiler -- matches has_hot_water_cylinder=true).
E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode",
"^Gas", "Mains gas", "Boilers", "Balanced.*Open",
"Wall mount")
print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value())
# Control 2104 = "Programmer and room thermostat" (matches the cert's own
# main_heating_controls description) -- no TRVs. NB: CBC is "Programmer
# and room thermostat"; CBB is "Room thermostat and TRVs" -- do not swap.
E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls",
"^Boilers", "^Standard", "CBC Programmer and room thermostat")
print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value())
# Explicit flue type (BUG fixed): the "Balanced/Open Flue" boiler-code
# CATEGORY picked above is a combined Table 4b group name (BF/OF) that
# does NOT set the separate `RadioButtonListFlueType` select on this
# page -- that field kept its inherited/default "Balanced" from a
# prior cert (silent-stale-value class of bug, see docstring), which
# mismatches the lodged gov-API `boiler_flue_type=1` ("Open"). Set it
# explicitly to match.
E.set_select(page, f"{MH1B}RadioButtonListFlueType", "Open")
print("flue type:", page.locator(f"#{E.FP}{MH1B}RadioButtonListFlueType").input_value())
E.save_close(page)
def secondary(page):
# Lodged secondary: SAP 603 = room heaters, mains gas (no finer detail
# lodged -- RGB "Modern fire with open flue post 1980" is the generic
# approximation).
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", "RGB Modern fire with open flue")
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.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())
# cylinder: lodged cylinder_size code 2 = SAP10 "Normal" (110 L; see
# cert_to_inputs.py _CYLINDER_SIZE_CODE_TO_LITRES). BUG (fixed): this
# dropdown's <option VALUE> IS its visible text on this build ("No
# Access" / "Normal (Less than 130L)" / "Medium (131-170L)" / "Large
# (Greater than 171L)" / "Value known") -- passing the bare numeric
# code "2" to `set_select` silently matched nothing (Playwright
# select_option no-ops on a non-matching value) and left the field at
# whatever a PRIOR cert's build had set ("Medium (131-170L)"), the
# same silent-failure/stale-value contamination class as the wall-
# thickness gotcha documented in this file's docstring. Match by the
# real option text instead.
E.select_by_contains(page, f"{WH}DropDownListCylinderSize", "Normal")
E.set_select(page, f"{WH}DropDownListInsulated", "Foam")
E.set_select(page, f"{WH}DropDownListInsulationThickness", "25 mm")
print("cylinder size:", page.locator(f"#{E.FP}{WH}DropDownListCylinderSize").input_value())
print("cylinder thickness:", page.locator(f"#{E.FP}{WH}DropDownListInsulationThickness").input_value())
E.save_close(page)
_ORDER = ["address", "property_description", "dimensions", "walls", "roofs",
"floors", "openings", "ventilation", "space_heating", "secondary",
"water_heating"]
def main():
if len(sys.argv) < 2 or sys.argv[1] not in _ORDER:
print("usage: build_100010086084.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())