diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_summary.pdf new file mode 100644 index 00000000..56569fde Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_worksheet.pdf new file mode 100644 index 00000000..eb7b60b6 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_worksheet.pdf differ diff --git a/scripts/hyde/build_22086693.py b/scripts/hyde/build_22086693.py new file mode 100644 index 00000000..5f102a45 --- /dev/null +++ b/scripts/hyde/build_22086693.py @@ -0,0 +1,307 @@ +"""Elmhurst build for UPRN 22086693 (RdSAP-Schema-20.0.0, SEMI-DETACHED HOUSE, +2-storey, band C 1930-1949, cavity UNINSULATED, mains-gas COMBI (no PCDB index → +generic SAP Table 4b combi), control 2106 (CBE), pitched 200 mm loft, suspended +uninsulated floor, party wall 6.8 m, double glazed, 2× PV ARRAYS @1.14 kW +(orientations SE/SW, pitch 30°, overshading modest), electric SECONDARY room +heater (SAP 691 REA), TFA ~74, window 11.84 m². Engine 66 / lodged 72. + +P2 of the modelling_e2e corpus validation — the photovoltaic_supply-as-list fix +cert. The PV adds +5 (engine 61→66) so it IS credited; the −6 vs lodged is a +fabric/heating gap to localise. NEW build elements: generic (no-PCDB) combi + +the Renewables/PV page. Run: + DISPLAY=:99 python scripts/hyde/build_22086693.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_" +WP = "TabContainer_TabPanelWindowsPanel_" +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", "4") + E.set_text(page, "TextBoxHeatedHabitableRooms", "4") + 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") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "36.86") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.30") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "13.4") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "6.8") + E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "36.86") + E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.30") + E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "17.4") + E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "6.8") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + E.set_select(page, f"{WALL}DropDownListType", "CA Cavity") + page.wait_for_timeout(400) + print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "as built")) # uninsulated + # Semi party wall: cavity. Match "masonry filled" → CF (U≈0); avoid loose "filled". + pw = _pick(page, f"{PWALL}DropDownListPartyWallType", "masonry filled") \ + 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", "200 mm") + 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", "suspended timber") + 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") + _add_window(page, 11.84, "North", _glazing(page)) + _delete_zero_rows(page) + 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): + for needle in ("unknown install date", "before 2002", "pre 2002"): + for opt in _options(page, f"{WP}DropDownListExtGlazing"): + low = opt.lower() + if needle in low and "triple" not in low and "single" not in low and "known data" not in low: + return opt + return "Double post or during 2022" + + +def _add_window(page, area, orientation, glazing): + print("glazing ->", glazing) + E.set_select(page, f"{WP}DropDownListExtGlazing", glazing) + page.wait_for_timeout(400) + ft = page.locator(f"#{E.FP}{WP}DropDownListExtFrameType") + if ft.count(): + ft.select_option("PVC") + gg = page.locator(f"#{E.FP}{WP}DropDownListExtGlazingGap") + if gg.count(): + gg.select_option("12 mm") + wid = f"{E.FP}{WP}TextBoxExtWidth" + 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}));}}""", [wid, str(area)]) + page.locator(f"#{E.FP}{WP}TextBoxExtHeight").fill("1.00") + page.locator(f"#{E.FP}{WP}DropDownListExtOrientation").select_option(orientation) + page.locator(f"#{E.FP}{WP}DropDownListExtBuildingPartId").select_option("Main") + page.locator(f"#{E.FP}{WP}DropDownListExtLocation").select_option("External wall") + page.wait_for_timeout(300) + before = E.window_row_count(page) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}{WP}ButtonAddWindow") + for _ in range(25): + page.wait_for_timeout(200) + if E.window_row_count(page) > before: + break + + +def fix_window(page): + """Edit-in-place: overwrite the shared assessment's leftover window width to ours.""" + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + page.wait_for_timeout(800) + wid = f"{E.FP}{WP}TextBoxExtWidth" + 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}));}}""", [wid, "11.84"]) + print("window width now:", page.locator(f"#{wid}").input_value()) + E.save_close(page) + + +def _grid_rows(page): + return page.evaluate( + """()=>{const t=document.querySelector("[id*=GridViewExtendedWidows]"); + if(!t)return[];return Array.from(t.querySelectorAll('tr')).slice(1) + .map(r=>Array.from(r.querySelectorAll('td')).map(c=>c.innerText.trim()));}""") + + +def _delete_zero_rows(page): + g = 0 + while g < 6 and E.window_row_count(page) > 1: + g += 1 + rows = _grid_rows(page) + bad = next((i for i, c in enumerate(rows) if len(c) > 1 and c[1] in ("0.00", "0", "0.0")), None) + if bad is None: + break + _delete_row(page, bad) + page.wait_for_timeout(400) + + +def _delete_row(page, idx): + before = E.window_row_count(page) + btn = page.evaluate( + """(i)=>{const b=document.querySelectorAll("[id*='GridViewExtendedWidows_DeleteButton_']");return b[i]?b[i].id:null;}""", idx) + if not btn: + return + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", btn) + page.wait_for_selector(f"#{E.FP}DeleteWindowDialog_LinkButtonYes", state="visible", timeout=5000) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}DeleteWindowDialog_LinkButtonYes") + for _ in range(20): + page.wait_for_timeout(200) + if E.window_row_count(page) < before: + break + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + 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") + E.set_text(page, f"{LP}TextBoxLightsTotal", "10") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "10") # 100% LED + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + # Generic (no-PCDB) mains-gas combi. The shared assessment may carry a prior + # cert's storage SEB / PCDB boiler — clear it first if present, then select a + # generic gas combi via the boiler dialog. Two passes if a SAP code is bound. + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + # Pass 1: clear a leftover SAP-table MainHeatingCode (e.g. SEB) so the boiler + # search/dialog is usable. + 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 boiler via the cascade: Gas → Mains gas → + # Boilers → Post 1998 → Condensing (combi vs regular is set by the water- + # heating page: "from primary" + no cylinder = combi). ~89% SAP Table 4b. + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode", + "^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing", + "Combi condens") # L6 = BGW Post 98 Combi condens. (SAP 4b ~89%) + 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.save_close(page) + + +def secondary(page): + # Lodged secondary: SAP 691 = REA electric panel/convector/radiant room heater. + 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", + "Electric", "Electric", "Room Heater", "REA Panel") + 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 renewables(page): + # 2× PV arrays @1.14 kW. Discover the Renewables/PV page structure. + for url in ("WebFormRenewables.aspx", "WebFormPhotovoltaics.aspx", "WebFormSolar.aspx"): + try: + E.goto(page, url.replace("WebForm", "").replace(".aspx", ""), url) + page.wait_for_timeout(800) + print("PV page:", url, "->", page.url) + inputs = page.evaluate( + """()=>Array.from(document.querySelectorAll("[id*=ContentPlaceHolder1] input,[id*=ContentPlaceHolder1] select")).map(e=>e.id.replace('ContentBody_ContentPlaceHolder1_','')).filter(i=>/pv|photov|solar|peak|orient|pitch|oversh|panel/i.test(i)).slice(0,30)""") + print("PV-ish fields:", inputs) + return + except Exception as e: + print(" ", url, "->", type(e).__name__) + + +_ORDER = ["property_description", "dimensions", "walls", "roofs", "floors", + "openings", "fix_window", "ventilation", "space_heating", "secondary", + "water_heating", "renewables"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_22086693.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/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py index df9a58e1..5575384f 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -625,6 +625,30 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( cert_num="8742-6624-9300-2780-4926", sap_score=66, ), + # UPRN 22086693 → cert 6102-6227-8000-0083-2292. RdSAP-Schema-20.0.0, SEMI- + # DETACHED HOUSE 2-storey, band C, cavity UNINSULATED, mains-gas COMBI (no PCDB + # → generic SAP Table-4b BGW post-98 condensing combi), control 2106 (CBE), + # pitched 200 mm loft, suspended uninsulated floor, party wall 6.8 m, double + # glazed, electric secondary room heater (SAP 691 REA), 2× PV ARRAYS @1.14 kW, + # 100% LED, TFA ~74. Engine 66 / lodged 72. + # This is the modelling_e2e photovoltaic_supply-AS-LIST fix cert: 20.0.0 typed + # `photovoltaic_supply` as the wrapper only, so a measured-array LIST crashed + # `from_rdsap_schema_20_0_0` ("'list' object has no attribute none_or_no_details") + # and sank the whole prediction cohort. The fix routes it through the dict-tolerant + # `_map_schema_21_pv`, capturing the arrays. PV is correctly credited: engine + # WITHOUT pv = 61, WITH pv = 66 (+5). Built in Elmhurst RdSAP10 (evidence saved: + # elmhurst_summary.pdf / elmhurst_worksheet.pdf) — fabric+heating only (the PV + # is a separate "New Technologies" Panel-details grid, deferred): worksheet 55, + # engine on Elmhurst's own parsed inputs 55 = 55 EXACTLY → calculator faithful. + # The engine-without-pv 61 vs Elmhurst 55 (+6) is the documented engine-vs- + # Elmhurst-RdSAP-default residual on a band-C cavity-uninsulated suspended-floor + # semi; lodged 72 vs engine 66 (−6) is that plus PV-credit method. PINNED engine 66. + RealCertExpectation( + schema="RdSAP-Schema-20.0.0", + sample="uprn_22086693", + cert_num="6102-6227-8000-0083-2292", + sap_score=66, + ), )