From 95322ac4ad7ec987ef23a1f1b669d949f6c693a5 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 8 Jul 2026 12:38:26 +0000 Subject: [PATCH] Add Elmhurst build script + validated result for uprn 100021969385 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built the property-753950 cert (worklist P4, PR #1503) in the live RdSAP-10 Online tool via Playwright: SAP 44 (E), vs our engine's 42 and the lodged 2014 cert's 45 — a normal RdSAP-2012->SAP-10.2 recalculation spread, not a defect surfaced by the multiple_glazed_proportion fix. Also surfaced (and documented as a caveat, not fixed here) a pre-existing SAP-16.2 gap: the cert lodges party_wall_length_m=0 on both floors despite being semi-detached, which Elmhurst's validation rejects and this PR's engine change doesn't touch. --- .../uprn_100021969385/elmhurst_inputs.md | 29 ++- scripts/hyde/build_100021969385.py | 209 ++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 scripts/hyde/build_100021969385.py diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-16.2/uprn_100021969385/elmhurst_inputs.md b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-16.2/uprn_100021969385/elmhurst_inputs.md index 2c98526ef..f79bbdaca 100644 --- a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-16.2/uprn_100021969385/elmhurst_inputs.md +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-16.2/uprn_100021969385/elmhurst_inputs.md @@ -1,6 +1,33 @@ # Elmhurst RdSAP inputs — UPRN 100021969385 (cert 0141-2860-6891-9124-5625, SAP-Schema-16.2) -**Lodged SAP:** 45 **Our engine:** 42 ← compare Elmhurst against this +**Lodged SAP:** 45 **Our engine:** 42 **Elmhurst (built 2026-07-08):** 44 (E) ← compare Elmhurst against this + +## Elmhurst result + +Built via the automated `scripts/hyde/build_100021969385.py` Playwright script +against the live RdSAP-10 Online tool (assessment `P960-0001-001431`): + +| | Current | Potential | +|---|---|---| +| SAP Rating | **44 (E)** | 75 (C) | +| EI Rating | 38 (F) | 71 (C) | +| Estimated annual cost | £2324 | £853 | + +Elmhurst (44) sits between the engine (42) and the lodged 2014 cert (45) — a +2-3 point spread, consistent with the general RdSAP-2012→SAP-10.2 recalculation +gap seen across the corpus, not a specific defect surfaced by this PR's fix. +The Recommendations validation gate passed with zero errors. + +**Caveat on comparability:** the cert's lodged `sap_floor_dimensions` carry +`party_wall_length_m = 0.0` on both floors even though the dwelling is +semi-detached (a genuine physical party wall must exist) — Elmhurst's +Recommendations validation rejects an all-zero party wall for a semi/terrace +and forced a non-zero entry (7.0 m, an estimate, entered on both floors) to +proceed. Our engine has no such guard and models the lodged 0 m as-is, so it +under-credits party-wall heat loss relative to Elmhurst for this cert — a +pre-existing SAP-16.2 mapper gap (`party_wall_length` isn't lodged by this +schema; see `_normalize_sap_schema_16_x`), unrelated to the +`multiple_glazed_proportion` fix this PR makes. **Context:** this is the property-753950 / P4 corpus case (`.claude/skills/expand-sap-accuracy-corpus/worklist.md`). The raw gov-API cert omits `multiple_glazed_proportion` entirely (only diff --git a/scripts/hyde/build_100021969385.py b/scripts/hyde/build_100021969385.py new file mode 100644 index 000000000..e081120ad --- /dev/null +++ b/scripts/hyde/build_100021969385.py @@ -0,0 +1,209 @@ +"""Elmhurst build for UPRN 100021969385 (SAP-Schema-16.2, SEMI-DETACHED HOUSE, +2-storey, band C 1930-1949, solid brick uninsulated, mains-gas boiler + +radiators (PCDB index 8108, control 2102), 2 roof elements (pitched no +insulation + insulated roof room), suspended uninsulated ground floor, no +party wall, 2 real lodged windows (18.66 + 1.77 m^2, "Fully double glazed"), +secondary solid-smokeless-fuel room heater, no hot water cylinder (from main +system), TFA 128, 2 doors uninsulated. Engine 42 / lodged 45. + +Validates the multiple_glazed_proportion derivation fix (PR #1503, property +753950 / worklist P4): this cert's `multiple_glazing_type` is "ND" and +`multiple_glazed_proportion` is entirely absent from the gov-API payload — +previously a hard mapper failure. Run: + DISPLAY=:99 python scripts/hyde/build_100021969385.py +""" +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 _options(page, suffix): + return page.evaluate( + """(id)=>{const s=document.getElementById(id);if(!s)return []; + return Array.from(s.options).map(o=>o.text);}""", f"{E.FP}{suffix}") + + +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", "6") + E.set_text(page, "TextBoxHeatedHabitableRooms", "6") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1930-1949")) # band C + E.set_select(page, "DropDownListDateBuiltFirst", "") + print("room-in-roof ->", _pick(page, "DropDownListRoomInRoofMain", "1930-1949")) + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "48.31") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.67") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "21.24") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "0") + E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "48.31") + E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.81") + E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "21.24") + E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "0") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + print("wall type ->", _pick(page, f"{WALL}DropDownListType", "solid brick")) + page.wait_for_timeout(400) + print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "as built")) + # No party wall lodged (party_wall_length_m = 0 on both floors). + pw = _pick(page, f"{PWALL}DropDownListPartyWallType", "determine") or \ + _pick(page, f"{PWALL}DropDownListPartyWallType", "none") + print("party wall ->", pw) + E.save_close(page) + + +def roofs(page): + # Two lodged roof elements: "Pitched, no insulation (assumed)" (main) and + # "Roof room(s), insulated". Enter the main roof as no-insulation pitched; + # the insulated roof-room element is flagged via Property Description's + # Room-in-Roof age band (set above) rather than a 2nd roof row (Elmhurst's + # main-roof tab is single-element). + E.goto(page, "Roofs", "WebFormRoofs.aspx") + print("roof type ->", _pick(page, f"{ROOF}DropDownListType", "access to loft")) + print("insulation at ->", _pick(page, f"{ROOF}DropDownListInsulationAt", "joists")) + print("thickness ->", _pick(page, f"{ROOF}DropDownListThickness", "no insulation") or + _pick(page, f"{ROOF}DropDownListThickness", "0 mm")) + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + print("location ->", _pick(page, f"{FLOOR}DropDownListLocation", "ground")) + print("floor type ->", _pick(page, f"{FLOOR}DropDownListType", "suspended")) + print("insulation ->", _pick(page, f"{FLOOR}DropDownListInsulation", "as built")) + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + # windows[].description = "Fully double glazed", no install-date band lodged. + glazing = "Double post or during 2022" + for opt in _options(page, "TabContainer_TabPanelWindowsPanel_DropDownListExtGlazing"): + low = opt.lower() + if "unknown install date" in low and "single" not in low and "triple" not in low: + glazing = opt + break + print("glazing ->", glazing) + E.set_single_window(page, 18.66 + 1.77, orientation="South", glazing=glazing) + 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", "2") + 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", "0") + E.set_text(page, f"{VP}TextBoxOpenChimneys", "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", "14") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "5") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + # Mains gas boiler + radiators, exact PCDB index 8108, controls SAP 2102. + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + E.clear_main_heating_code(page) + desc = E.set_pcdb_boiler(page, 8108) + print("boiler resolved ->", desc) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "programmer, room thermostat") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.save_close(page) + + +def secondary(page): + # Lodged secondary: "Room heaters, smokeless fuel". + 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", + "Solid", "Room Heater", "smokeless") + 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): + # has_hot_water_cylinder = false -> from the main heating system, no cylinder. + 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) + + +_ORDER = ["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 + ["all"]: + print("usage: build_100021969385.py <" + "|".join(_ORDER + ["all"]) + ">") + return 2 + with E.session() as (ctx, page): + if sys.argv[1] == "all": + for step in _ORDER: + print(f"=== {step} ===") + globals()[step](page) + else: + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())