diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index d5c40292c..6bd96ba6a 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -405,7 +405,7 @@ methodology) is still open — see comment in test_real_cert_sap_accuracy.py. All 20 pinned as `RealCertExpectation`s to the engine's OWN observed output. - [x] 🔧 100010359769 — SAP-15.0 · eng 68 (2011 lodged 68, not a target) - [x] 🔧 100010359788 — SAP-15.0 · eng 72 (2011 lodged 73, not a target) -- [x] 🔧 100010086084 — SAP-15.0 · eng 53 (2011 lodged 45, not a target) · solid-brick, largest-gap group; u_wall spot-checked correct +- [x] 🔍 100010086084 — SAP-15.0 · eng 53, Elmhurst worksheet 48 (2011 lodged 45, not a target) · FULL Elmhurst RdSAP10 rebuild done (only cert in this batch with one). Real ~5pt residual gap, NOT the mapper's TFA/party-wall/region/boiler-efficiency defaults (all ruled out empirically). Two real bugs flagged, not fixed (need >1 cert's evidence, see test_real_cert_sap_accuracy.py comment): wall-thickness-unknown fallback (engine 1.70 vs Elmhurst 1.40, but patch-tested WRONG DIRECTION alone) and a window U-value divergence (1.85 vs 2.52 W/m2K, root cause open). Single-window orientation approximation confirmed to account for ~2pt of the gap (North->South rebuild: 46->48). - [x] 🔧 100010090369 — SAP-15.0 · eng 53 (2011 lodged 47, not a target) · ditto - [x] 🔧 100010074470 — SAP-15.0 · eng 46 (2011 lodged 39, not a target) · ditto - [x] 🔧 100010100813 — SAP-15.0 · eng 59 (2011 lodged 47, not a target) · ditto diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-15.0/uprn_100010086084/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-15.0/uprn_100010086084/elmhurst_summary.pdf new file mode 100644 index 000000000..36f53c31e Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-15.0/uprn_100010086084/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-15.0/uprn_100010086084/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-15.0/uprn_100010086084/elmhurst_worksheet.pdf new file mode 100644 index 000000000..f5045d7e7 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-15.0/uprn_100010086084/elmhurst_worksheet.pdf differ diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 33b3cd840..da1753453 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -7552,6 +7552,10 @@ _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10: Dict[str, int] = { "Normal": 2, "Medium": 3, "Large": 4, + # The live RdSAP10-Online tool renders the size band WITH its litre range + # suffix (observed on the SAP-Schema-15.0 UPRN 100010086084 build) rather + # than the bare label the cohort fixtures used above. + "Medium (131-170L)": 3, } diff --git a/scripts/hyde/build_100010086084.py b/scripts/hyde/build_100010086084.py new file mode 100644 index 000000000..9e8ca3895 --- /dev/null +++ b/scripts/hyde/build_100010086084.py @@ -0,0 +1,289 @@ +"""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 = "Medium (131-170L)" 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 +""" +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") + 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(): + 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) + 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()) + 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: size 2 (lodged cylinder_size code -- set_select matches by + #