mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
A FLAT roof lodges its insulation thickness in the DEDICATED gov-EPC API `flat_roof_insulation_thickness` field (e.g. "75mm"), leaving `roof_insulation_thickness` None (that field is for pitched-loft joists). `heat_transmission_from_cert` read only the latter, so a measured flat-roof thickness was ignored and the roof billed at the uninsulated age-band flat default (age E = 1.5) instead of its Table-16 insulated U. Fixed by preferring `flat_roof_insulation_thickness` when the part is a flat roof — the exact mirror of the existing rafter-thickness branch. An "AB"/"NI" (as-built/unknown) value parses to None and keeps the age-band default, unchanged; only measured thicknesses move. PER-CERT ELMHURST VALIDATION (cert 47084930, top-floor flat, flat roof 75 mm): built on the lodged inputs in accredited Elmhurst RdSAP10-Online (evidence saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The worksheet bills "insulated flat roof" at U 0.5 (floor 0.70 + wall 0.25 also matching the engine). The fix takes the engine roof 96.4 -> 32.1 W/K (= 64.26 x 0.5, Elmhurst-exact), PE +47 -> +0.2, SAP 69.51 -> 74.34 = lodged 74. Gauge: within 77.3% -> 77.7%, SAP MAE 0.648 -> 0.641, CO2 0.074 -> 0.072, PE 3.1 -> 2.97. Unit-pinned in test_heat_transmission (flat_roof_insulation_thickness -> U 0.5); RealCertExpectation 47084930 = 74 (Elmhurst-validated). Also adds the build script build_47084930.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
299 lines
13 KiB
Python
299 lines
13 KiB
Python
"""Elmhurst build for UPRN 47084930 (RdSAP-21.0.1, TOP-FLOOR FLAT, band E,
|
||
CAVITY wall with INTERNAL insulation (100 mm), FLAT ROOF insulated 75 mm, floor
|
||
over OTHER PREMISES below, mains-gas COMBI (PCDB 17815), 5 DG windows ~9.9 m²,
|
||
no doors, TFA 64. Lodged SAP 74; engine 69.51 (Δ −4.49, PE +47 over-count).
|
||
|
||
Per-cert worksheet validation. Suspected engine gap: the flat-roof insulation
|
||
thickness is lodged in `flat_roof_insulation_thickness` (75 mm) but the U-value
|
||
calc reads `roof_insulation_thickness` (None) → bills the roof at the age-E flat
|
||
default 1.5 (96 W/K) instead of the 75 mm insulated ~0.5 (~30 W/K). This build
|
||
gets the accredited flat-roof + floor U. Run:
|
||
DISPLAY=:99 python scripts/hyde/build_47084930.py <page>
|
||
"""
|
||
from __future__ import annotations
|
||
import sys
|
||
from pathlib import Path
|
||
import elmhurst_lib as E
|
||
|
||
DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_"
|
||
WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_"
|
||
"TabPanelExternalWallMain_WebUserControlWallMain_")
|
||
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_"
|
||
_SAMPLE_DIR = (Path(__file__).parent.parent.parent
|
||
/ "backend/epc_api/json_samples/real_life_examples"
|
||
/ "RdSAP-Schema-21.0.1/uprn_47084930")
|
||
|
||
|
||
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")
|
||
_pick(page, "DropDownListPropertyType1", "flat") or \
|
||
E.set_select(page, "DropDownListPropertyType1", "F Flat")
|
||
E.set_text(page, "TextBoxStoreys", "1")
|
||
E.set_text(page, "TextBoxHabitableRooms", "3")
|
||
E.set_text(page, "TextBoxHeatedHabitableRooms", "3")
|
||
print("date ->", _pick(page, "DropDownListDateBuiltMain", "1967-1975"))
|
||
E.set_select(page, "DropDownListDateBuiltFirst", "")
|
||
E.save_close(page)
|
||
|
||
|
||
def flats(page):
|
||
E.goto(page, "Flats", "WebFormFlats.aspx")
|
||
print("flat positions:", _options(page, "" if False else "DropDownListPositionOfFlat"))
|
||
_pick(page, "DropDownListPositionOfFlat", "top") or \
|
||
E.set_select(page, "DropDownListPositionOfFlat", "Top Floor")
|
||
E.set_text(page, "TextBoxFloor", "3")
|
||
E.set_select(page, "RadioButtonListFlatCoridor", "None")
|
||
E.save_close(page)
|
||
|
||
|
||
def dimensions(page):
|
||
E.goto(page, "Dimensions", "WebFormDimensions.aspx")
|
||
E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "64.26")
|
||
E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.32")
|
||
E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "18.15")
|
||
E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "18.15")
|
||
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(500)
|
||
print("insulation opts:", _options(page, f"{WALL}DropDownListInsulation"))
|
||
_pick(page, f"{WALL}DropDownListInsulation", "internal")
|
||
page.wait_for_timeout(500)
|
||
for suf in (f"{WALL}DropDownListInsulationThickness",):
|
||
if page.locator(f"#{E.FP}{suf}").count():
|
||
print("wall ins thickness opts:", _options(page, suf))
|
||
_pick(page, suf, "100")
|
||
E.save_close(page)
|
||
|
||
|
||
def roofs(page):
|
||
E.goto(page, "Roofs", "WebFormRoofs.aspx")
|
||
print("ROOF TYPE opts:", _options(page, f"{ROOF}DropDownListType"))
|
||
_pick(page, f"{ROOF}DropDownListType", "flat")
|
||
page.wait_for_timeout(600)
|
||
# discover flat-roof insulation fields
|
||
for suf in (f"{ROOF}DropDownListInsulationAt", f"{ROOF}DropDownListThickness",
|
||
f"{ROOF}DropDownListInsulation"):
|
||
if page.locator(f"#{E.FP}{suf}").count():
|
||
print(f" {suf.split('_')[-1]} opts:", _options(page, suf))
|
||
# flat roof insulated 75 mm
|
||
if page.locator(f"#{E.FP}{ROOF}DropDownListThickness").count():
|
||
_pick(page, f"{ROOF}DropDownListThickness", "75")
|
||
E.save_close(page)
|
||
|
||
|
||
def floors(page):
|
||
E.goto(page, "Floors", "WebFormFloors.aspx")
|
||
print("FLOOR LOCATION opts:", _options(page, f"{FLOOR}DropDownListLocation"))
|
||
# "(other premises below)" = above a partially-heated space
|
||
(_pick(page, f"{FLOOR}DropDownListLocation", "partially heated")
|
||
or _pick(page, f"{FLOOR}DropDownListLocation", "other premises")
|
||
or _pick(page, f"{FLOOR}DropDownListLocation", "unheated"))
|
||
page.wait_for_timeout(400)
|
||
tsel = page.locator(f"#{E.FP}{FLOOR}DropDownListType")
|
||
if tsel.count():
|
||
_pick(page, f"{FLOOR}DropDownListType", "solid")
|
||
E.save_close(page)
|
||
|
||
|
||
def openings(page):
|
||
E.goto(page, "Openings", "WebFormOpenings.aspx")
|
||
E.click_tab(page, "TabContainer_TabPanelWindowsPanel")
|
||
_add_window(page, 9.9, "North", _glazing(page))
|
||
_delete_zero_rows(page)
|
||
E.click_tab(page, "TabContainer_TabPanelDoorsPanel")
|
||
E.set_text(page, f"{DP}TextBoxDoors", "0")
|
||
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 _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", "6")
|
||
E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0")
|
||
E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0")
|
||
E.save_close(page)
|
||
|
||
|
||
def space_heating(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
|
||
E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode",
|
||
"^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing",
|
||
"Combi condens")
|
||
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.set_select(page, "DropDownListSecondaryHeatingPresent", "No")
|
||
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 download(page):
|
||
_SAMPLE_DIR.mkdir(parents=True, exist_ok=True)
|
||
E.goto(page, "Recommendations", "WebFormRecommendations.aspx")
|
||
page.wait_for_timeout(1500)
|
||
with page.expect_navigation(wait_until="networkidle", timeout=40000):
|
||
page.click("#ContentBody_buttonActionSummary_Link", timeout=15000)
|
||
page.wait_for_timeout(2000)
|
||
for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"),
|
||
("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")):
|
||
try:
|
||
with page.expect_download(timeout=60000) as dl:
|
||
page.click(f"#{btn}", timeout=15000)
|
||
dl.value.save_as(str(_SAMPLE_DIR / fname))
|
||
print("saved", fname)
|
||
except Exception as e:
|
||
print("!! download failed", fname, type(e).__name__, str(e)[:120])
|
||
|
||
|
||
_ORDER = ["property_description", "flats", "dimensions", "walls", "roofs",
|
||
"floors", "openings", "ventilation", "space_heating", "water_heating",
|
||
"download"]
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 2 or sys.argv[1] not in _ORDER:
|
||
print("usage: build_47084930.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())
|