test(accuracy): pin RdSAP-20.0.0 PV semi uprn_22086693 (PV-list fix corpus)

Corpus validation of the modelling_e2e photovoltaic_supply-as-list fix. Cert
6102-6227-8000-0083-2292 (RdSAP-20.0.0 semi, gas combi + 2× 1.14 kW PV arrays)
crashed from_rdsap_schema_20_0_0 on the measured-array list; the fix routes it
through the dict-tolerant _map_schema_21_pv. PV correctly credited: engine 61
(no PV) → 66 (+5). Built in Elmhurst (evidence: epc.json + summary + worksheet,
fabric+heating; the PV "New Technologies" Panel-details grid deferred): worksheet
55 = engine-on-Elmhurst-inputs 55 exactly → calculator faithful. The +6 engine-vs-
Elmhurst base-dwelling residual is the documented RdSAP-default gap (band-C cavity-
uninsulated suspended-floor semi). Pinned engine 66.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-23 16:13:34 +00:00
parent 75e4989982
commit 8f0432721c
4 changed files with 331 additions and 0 deletions

View file

@ -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 . Engine 66 / lodged 72.
P2 of the modelling_e2e corpus validation the photovoltaic_supply-as-list fix
cert. The PV adds +5 (engine 6166) 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 <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_"
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())

View file

@ -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,
),
)