mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Real Elmhurst RdSAP10 ground truth for one SAP-Schema-15.0 cert
Built UPRN 100010086084 (the largest gov-API-vs-lodged outlier from the 20-cert batch) in Elmhurst's accredited RdSAP10 tool to get real ground truth, per feedback that lodged rating isn't a valid comparison target. Elmhurst worksheet: SAP 46-48 (orientation-dependent), vs this engine's 53 on gov-API inputs -- a real ~5-7pt residual gap. Ruled out with direct empirical tests: total_floor_area, missing party_wall_length, region/climate (SAP rating always uses UK-average weather per Appendix U -- confirmed inert), boiler/secondary efficiency (exact match, 66%/ 63% both sides). Confirmed real but NOT blind-fixed (need more than one cert's evidence, and a wall-U-value patch tested the wrong direction in isolation): a wall-thickness-unknown U-value fallback mismatch (this engine 1.70, Elmhurst's own "unknown" resolution 1.40) and a window U-value divergence (1.85 vs 2.52 W/m2K for the same nominal glazing pick). Single-window orientation confirmed a ~2pt partial contributor. Also fixes a small, separate, evidenced gap the build surfaced: the Elmhurst summary parser didn't recognise the live tool's "Medium (131-170L)" cylinder-size label (only the bare "Medium"). Full write-up is in the RealCertExpectation comment for this cert in test_real_cert_sap_accuracy.py and the worklist. Residual gap is intentionally left open for follow-up, not force-closed.
This commit is contained in:
parent
e31e01e85f
commit
283b311fcd
7 changed files with 401 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
289
scripts/hyde/build_100010086084.py
Normal file
289
scripts/hyde/build_100010086084.py
Normal file
|
|
@ -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 <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")
|
||||
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
|
||||
# <option VALUE>, not visible text; value "2" renders as "Medium
|
||||
# (131-170L)" on the live tool -- see the mapper fix for that label).
|
||||
# foam insulation (type 1), 25mm (value happens to equal visible text
|
||||
# here: "25 mm").
|
||||
E.set_select(page, f"{WH}DropDownListCylinderSize", "2")
|
||||
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())
|
||||
71
scripts/hyde/download_100010086084.py
Normal file
71
scripts/hyde/download_100010086084.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Download the Input Summary + SAP Worksheets PDFs for UPRN 100010086084,
|
||||
via a fresh-login `E.session()` (elmhurst_download.py's persistent-context
|
||||
login is stale per elmhurst_lib's own documented auth gotcha).
|
||||
|
||||
SAFETY: clicks ONLY the two allow-listed report buttons below. NEVER clicks
|
||||
Submit (`LinkButtonSubmitEPR`).
|
||||
|
||||
DISPLAY=:99 python scripts/hyde/download_100010086084.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import elmhurst_lib as E
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
SAMPLE_DIR = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "backend/epc_api/json_samples/real_life_examples"
|
||||
/ "SAP-Schema-15.0/uprn_100010086084"
|
||||
)
|
||||
|
||||
REPORTS = [
|
||||
("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"),
|
||||
("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf"),
|
||||
]
|
||||
_FORBIDDEN = {"ContentBody_ContentPlaceHolder1_LinkButtonSubmitEPR"}
|
||||
|
||||
|
||||
def _download_one(page, button_id, out):
|
||||
assert button_id not in _FORBIDDEN, f"refusing to click forbidden {button_id}"
|
||||
print(f" clicking {button_id} -> {out.name} ...", flush=True)
|
||||
try:
|
||||
with page.expect_download(timeout=60_000) as dl:
|
||||
page.click(f"#{button_id}", timeout=20_000)
|
||||
dl.value.save_as(str(out))
|
||||
except PlaywrightTimeoutError:
|
||||
try:
|
||||
with page.expect_popup(timeout=10_000) as pop:
|
||||
page.click(f"#{button_id}", timeout=20_000)
|
||||
popup = pop.value
|
||||
with popup.expect_download(timeout=30_000) as dl2:
|
||||
pass
|
||||
dl2.value.save_as(str(out))
|
||||
except PlaywrightTimeoutError:
|
||||
print(f" !! no download/popup for {button_id}", flush=True)
|
||||
return False
|
||||
size = out.stat().st_size if out.exists() else 0
|
||||
print(f" saved {out} ({size:,} bytes)", flush=True)
|
||||
return out.exists() and size > 0
|
||||
|
||||
|
||||
def main():
|
||||
SAMPLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ok = True
|
||||
with E.session() as (ctx, page):
|
||||
print("navigating via Recommendations ...", flush=True)
|
||||
with page.expect_navigation(wait_until="networkidle", timeout=60_000):
|
||||
page.click("#ContentBody_buttonActionRecommendations_Link", timeout=15_000)
|
||||
page.wait_for_timeout(600)
|
||||
print("navigating to Energy Report Summary ...", flush=True)
|
||||
with page.expect_navigation(wait_until="networkidle", timeout=60_000):
|
||||
page.click("#ContentBody_buttonActionSummary_Link", timeout=15_000)
|
||||
page.wait_for_timeout(600)
|
||||
for button_id, fname in REPORTS:
|
||||
ok = _download_one(page, button_id, SAMPLE_DIR / fname) and ok
|
||||
page.wait_for_timeout(600)
|
||||
print("DONE." if ok else "DONE (with errors).", flush=True)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1133,6 +1133,42 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
|
|||
cert_num="0687-2894-6684-9009-7151",
|
||||
sap_score=72,
|
||||
),
|
||||
# Full Elmhurst RdSAP10 rebuild done for this ONE cert (the others in this
|
||||
# batch are unverified against Elmhurst — see the batch-level note above).
|
||||
# Elmhurst ground truth: SAP 48 (best-case single-window orientation
|
||||
# rebuild — see below), vs this engine's 53 on gov-API inputs: a real ~5pt
|
||||
# residual gap, NOT explained by mapper defaults (ruled out below with
|
||||
# direct empirical tests). Two real, confirmed-but-unfixed bugs surfaced;
|
||||
# flagged rather than blind-patched per this file's "fix generically,
|
||||
# never tune to one cert" convention — each needs more than one cert's
|
||||
# evidence before changing shared calculator code:
|
||||
# 1. Wall-thickness-unknown fallback mismatch. This cert's solid-brick
|
||||
# wall has no lodged thickness. `u_wall()` falls back to a flat 1.70
|
||||
# (domain/sap10_ml/rdsap_uvalues.py, the un-thickness-banded Table 6
|
||||
# default). Elmhurst's own accredited tool, given the SAME "unknown"
|
||||
# state (its `CheckBoxWallThicknessUnknown`), resolves to U=1.40 —
|
||||
# not 1.70. Patch-tested in isolation (U 1.70->1.40): engine score
|
||||
# MOVED THE WRONG DIRECTION (53->55, away from Elmhurst's 48), so
|
||||
# this alone isn't the dominant driver and any fix needs to account
|
||||
# for whatever else shifts alongside it — do not fix blind.
|
||||
# 2. Window U-value divergence. Same nominal "Double, unknown install
|
||||
# date" pick: this engine resolves ~1.85 W/m2K, Elmhurst ~2.52
|
||||
# W/m2K, for identical glazed area. Root cause not yet localised.
|
||||
# Ruled out (tested directly, no material effect): total_floor_area vs
|
||||
# summed sap_floor_dimensions (zero effect); missing party_wall_length
|
||||
# (this cert's party wall type is "Solid", U~=0, so near-zero effect
|
||||
# either way); region/climate (SAP rating always uses UK-average weather
|
||||
# per Appendix U, `_region_index` in cert_to_inputs.py -- regional choice
|
||||
# is provably inert for this field); boiler/secondary efficiency (exact
|
||||
# match both sides: 66% / 63%).
|
||||
# Single-window orientation IS a confirmed partial contributor (~2 of the
|
||||
# ~7pt gap): the campaign's `set_single_window` collapses all glazing
|
||||
# into ONE row (documented, accepted approximation -- true multi-
|
||||
# orientation entry does not reliably stick; re-confirmed here, see
|
||||
# elmhurst_lib.py). North-facing (this cert's first build) gave Elmhurst
|
||||
# 46; South-facing (max solar gain) gave 48 -- the mapper's own gov-API
|
||||
# synthesis spreads N/E/S/W, so neither single-orientation pick is
|
||||
# perfectly comparable, but 48 (South) is the fairer bound.
|
||||
RealCertExpectation(
|
||||
schema="SAP-Schema-15.0",
|
||||
sample="uprn_100010086084",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue