From 17b1d63f0e7cfcf91b51157aaf271fe3b8ec5f88 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:35:06 +0000 Subject: [PATCH 01/18] test(accuracy): pin SAP-16.0 storage-flat uprn_10070004512 (built_form fix corpus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corpus validation of the modelling_e2e built_form fix. Cert 8742-6624-9300-2780-4926 (SAP-Schema-16.0, ground-floor electric-storage-heater flat) omits built_form; the mapper now derives it from dwelling_type. built_form is ML-only so the fix is SAP-neutral: engine 66 = lodged 66 exactly. Built in Elmhurst (evidence: epc.json + summary + worksheet): worksheet 54, engine-on-Elmhurst-inputs 53 ≈ 54 → calculator faithful. The +12 engine-vs-Elmhurst is a build/input gap (cert size-1 small cylinder unrepresentable in Elmhurst's Normal/110L-minimum entry → higher HW + reduced-field 16.0 defaults). Pinned engine 66. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../uprn_10070004512/elmhurst_summary.pdf | Bin 0 -> 65734 bytes .../uprn_10070004512/elmhurst_worksheet.pdf | Bin 0 -> 43759 bytes .../SAP-Schema-16.0/uprn_10070004512/epc.json | 281 +++++++++++++++++ scripts/hyde/build_10070004512.py | 288 ++++++++++++++++++ .../test_real_cert_sap_accuracy.py | 22 ++ 5 files changed, 591 insertions(+) create mode 100644 backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/elmhurst_summary.pdf create mode 100644 backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/elmhurst_worksheet.pdf create mode 100644 backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/epc.json create mode 100644 scripts/hyde/build_10070004512.py diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/elmhurst_summary.pdf new file mode 100644 index 0000000000000000000000000000000000000000..eddc43149bd024642a60e95b9b605b151f2d92d6 GIT binary patch literal 65734 zcmeFa1yo#Jwx}CHf=iG<@C0{<;O;IJtZ;WI+=2&p_uxSaCwTDS7TkinYtUQ#r~kfv zy6@|EyWhBHym3zzW3#r*C429=Y_9rhk}Hae(=#)$Au^LNk=Pno@bWUMy4e{sis*y% zt!zyh74=Px9Z6Uq7nS+>jctq|B8X2%engQ zpyr0M+SH!A*E5z2vA~xClO^R}uhi5{2vhp<{Qu_DxzZ!g`{1^5VXDGv+eC)iMi3sH zB)r9-)aVIjkCALXz~?5g`6P^cRP#zX1Pd6_eDIZtDUi%*P1n8YQ^r?#@rmd+iw=1psmhT@chYj7LDab z+tTi?S6HnR7go{1ej@kT^j`hi$+yfhFOcl8^OtNRFU!55>8X{)-rdd3lYwp)EqBTS zuTu%b`NFna;II$a_Bdj4YDF>o#Jp`VT0s40m-8Fu{9I!_N$(R?De~*Yy}1v~w;!Xr z9~4z79A7$WS76rFF`@O7gCZlg^2$+zVaohFS%~@R%O$nueme0bgxxq!Mp(-;J6?CM zwVz?6AY8O~;o*F1u48x`+smjsB+W;NiW?yBv_IdG5HVC5pCU;ibBWukXrZLs$DlYUa%r}O`Xr)Wg> zDKOJ2XLdJDj942hvBQoITMzJCUc0_*MwC#0>we4A+oMvD8B+YP({OS;-XU#dX-XM( z!K`oRjM{cDOq=Q=viS<8e3JT->o{w}xdo?xGy%!iar0|# zA6+m-woF#I+-HuP!q%Nx+?D(!OG~_-*KRMHKTO*Jjy%&q1*M2yvE7=~g1z@Q9oQQZ zkJ?V1k7XBSWsF~KH8^q#a>Ja+Gcc__q2*=s_X|d$jo{`@Kf(@`fjP&|2N{1;<4c2K za5Pr1$vtmsw6@TWcR828Hv7Fgb<1xpWQ?F-yMjN%AY5GHz;+VI=Ak~`1?-%LjxX=w zJFoguy0n*!2OlK6lf6x&Z8c}EYIL-FX4AVC7Q@bs){i%jHXhFnrrHp;gWXUAuC>(c zJ@zGx6I!-U@^D+z6;`l~-aTCxd(kh|PJ8>4v_aatrE({WTdP?()w`CU$h`QL_Z66C z-g~g(VRVN78xw6=l+?Et$vh`S#5r{45m5ywYaZWQo*64B``;3 z_Qn%b`KobZd{>jLt_3e_g_vpf{E~>KY!=q1pqYGhkl*?e>C={>*w@$sB) zUo@tXl7gp~;ww+%!+}B0PT{;baK^cu-Nxxcd4;hf3_LZpk=6Nl)BNMKUl$wRY1Wyr z>0@%pN%{9!fa3tImGPyY2flQWHufU9_!sgA(1D;}NgQCHtw5Y`W7Ch8VQb;d#`~2wpvrW6@5+<1X@KvX5koge>a{L& zoV{>}VEF{?>V;~_G(c&C&cv9p?p9d_p_%J0;)XBXTW zs^xd+^Z~m#WGD1aDR{>g&cADy?23j`YLx>AX^XZmtN5&eLv{y^H=VihjKWeq>-k;2 zU(O*KH`T8HwV54UzPBMLQ*YJEmwidw_M+*NyGD{Bq$!?>PeA~$p6Il!C{|XzWdR-| zeih4hnAl9f)J#>j-!puyk7rUY5Ool&_W}Wh1g3S-@Q()J8Vi(evLhbyRECvJOgd

)>-&OTEZ=vqo)(t<>+ymW;&iX}FWv~Y@ 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 ViIdB(*#rR#fy9W|*%dVJ{R>~TQceH> literal 0 HcmV?d00001 diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.0/uprn_10070004512/elmhurst_worksheet.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b71391c8b892ca1c4f7486fba5f2fb8f5b39a9df GIT binary patch literal 43759 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 Vg}VK +""" +from __future__ import annotations +import sys +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_" + + +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", "F Flat") + _pick(page, "DropDownListPropertyType2", "mid-terrace") # built_form 4 + E.set_text(page, "TextBoxStoreys", "1") + E.set_text(page, "TextBoxHabitableRooms", "2") + E.set_text(page, "TextBoxHeatedHabitableRooms", "2") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1900-1929")) # band B + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.set_select(page, "DropDownListRoomInRoofMain", "") + E.save_close(page) + + +def flats(page): + E.goto(page, "Flats", "WebFormFlats.aspx") + E.set_select(page, "DropDownListPositionOfFlat", "Ground Floor") + E.set_text(page, "TextBoxFloor", "0") + 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", "33.24") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.42") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "10.39") + # 16.0 lodges no party_wall_length; a flat's side party walls are unmodelled by + # the engine. Try 0 (Elmhurst may require non-zero — adjust if Recommendations + # complains). + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "0") + 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", "filled")) + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + _pick(page, f"{ROOF}DropDownListType", "another dwelling above") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + # Floor is "to external air" — that is the LOCATION/exposure, not a TYPE. + _pick(page, f"{FLOOR}DropDownListLocation", "external air") # E To external air + page.wait_for_timeout(400) + _pick(page, f"{FLOOR}DropDownListType", "solid") # construction; U from exposure + ins = page.locator(f"#{E.FP}{FLOOR}DropDownListInsulation") + if ins.count(): + 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, 4.88, "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 _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", "5") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "5") # 100% low energy + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + # Electric storage heaters (SAP 402 = SEB), manual charge control (SAP 2401 = + # CSA). Two passes: clear bound PCDB boiler first, then set the SAP-table code. + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + rid = f"{E.MH1}TextBoxPCDFBoilerReference" + ref = page.locator(f"#{rid}").input_value() + if ref not in ("0", ""): + print(f"clearing bound PCDB boiler {ref} -> 0 (rerun space_heating)") + page.evaluate("""(rid)=>{const r=document.getElementById(rid);r.value='0'; + r.dispatchEvent(new Event('change',{bubbles:true}));}""", rid) + page.wait_for_timeout(500) + E.save_close(page) + return + E.set_heating_dialog(page, "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_ButtonMainHeatingCode", + "^Electric", "^Electric", "Storage", "SEB Modern slimline") + print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value()) + E.set_heating_dialog(page, "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_ButtonMainHeatingControls", + "Storage Radiator", "CSA Manual charge control") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "No") + # Economy-7 Dual meter (cert meter_type 1) — hidden Meters sub-tab. + E.click_tab(page, "TabContainer_TabPanelMeters") + E.set_select(page, "TabContainer_TabPanelMeters_RadioButtonListElectricityType", "Dual") + print("meter:", page.locator("#ContentBody_ContentPlaceHolder1_TabContainer_TabPanelMeters_RadioButtonListElectricityType").input_value()) + E.save_close(page) + + +def water_heating(page): + # Electric immersion off-peak (Dual) WITH cylinder (size 1 = small). The + # immersion code REQUIRES a cylinder. + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(600) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "Water Heater", "^Electric", "Immersion") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + cid = f"{E.FP}{WH}CheckBoxHotWaterCylinder" + cyl = page.locator(f"#{cid}") + if cyl.count() and not cyl.is_checked(): + try: + with page.expect_navigation(wait_until="load", timeout=8000): + page.evaluate( + """(id)=>{const c=document.getElementById(id);if(c){c.checked=true; + c.dispatchEvent(new Event('click',{bubbles:true})); + c.dispatchEvent(new Event('change',{bubbles:true}));}}""", cid) + except Exception: + page.wait_for_timeout(2000) + print("cylinder present:", cyl.is_checked() if cyl.count() else "n/a") + print("cyl sizes:", _options(page, f"{WH}DropDownListCylinderSize")) + _pick(page, f"{WH}DropDownListCylinderSize", "small") or \ + E.set_select(page, f"{WH}DropDownListCylinderSize", "Normal") + E.set_select(page, f"{WH}DropDownListInsulated", "Foam") + isuf = f"{WH}DropDownListInsulationThickness" + if page.locator(f"#{E.FP}{isuf}").count(): + _pick(page, isuf, "Unknown") or E.set_select(page, isuf, "25 mm") + imm = page.locator(f"#{E.FP}{WH}RadioButtonListImmersionHeater") + if imm.count(): + E.set_select(page, f"{WH}RadioButtonListImmersionHeater", "Dual") + E.save_close(page) + + +_ORDER = ["property_description", "flats", "dimensions", "walls", "roofs", + "floors", "openings", "ventilation", "space_heating", "water_heating"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_10070004512.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 56989c71..df9a58e1 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -603,6 +603,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( "engine prices 100% at the low rate; see elmhurst_worksheet.pdf (243-246)" ), ), + # UPRN 10070004512 → cert 8742-6624-9300-2780-4926. SAP-Schema-16.0, GROUND- + # FLOOR FLAT, band B, cavity FILLED, ELECTRIC STORAGE HEATERS (SAP 402 SEB, + # manual charge CSA/2401) + electric immersion off-peak (Economy-7 Dual meter) + # with a small cylinder (size 1), roof = another dwelling above, floor to + # EXTERNAL AIR, double glazed, TFA 33.24. Engine 66 = lodged 66 EXACTLY. + # This is the modelling_e2e built_form fix cert: 16.0 omits `built_form`, which + # RdSapSchema17_1 requires; the mapper derives it from dwelling_type (flat → + # modal 4). built_form is ML-only (the SAP calculator never reads it) so the fix + # is SAP-NEUTRAL — the engine reproduces the lodged score regardless. Built in + # Elmhurst RdSAP10 (evidence saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf): + # worksheet SAP 54, engine on Elmhurst's own parsed inputs 53 ≈ 54 → calculator + # faithful. The engine 66 vs Elmhurst 54 (+12) is an input/build gap dominated by + # HOT WATER (engine 1272 vs Elmhurst 1948 kWh): the cert lodges a size-1 (small) + # cylinder but Elmhurst's RdSAP entry has no "Small" option (Normal/110 L is the + # smallest), forcing a larger cylinder + more storage loss; plus the reduced-field + # 16.0 floor/party-wall defaults. PINNED to the observed engine 66 (= lodged 66). + RealCertExpectation( + schema="SAP-Schema-16.0", + sample="uprn_10070004512", + cert_num="8742-6624-9300-2780-4926", + sap_score=66, + ), ) From 539f66143018a3fa87b7dffe8979e068328384df Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:35:19 +0000 Subject: [PATCH 02/18] =?UTF-8?q?Skip=20a=20landlord=20override=20that=20t?= =?UTF-8?q?argets=20a=20building=20part=20the=20EPC=20lacks=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modelling/test_overlay_applicator.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 64a237d7..2e7059ec 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -55,6 +55,33 @@ def test_apply_writes_targeted_building_part_and_leaves_others_untouched() -> No ) +def test_override_targeting_a_part_absent_from_the_epc_is_skipped() -> None: + # A Landlord Override can reference a building part the lodged (or predicted) + # EPC never carried — e.g. an `extension_1` override on a property whose EPC + # has only `main`. The orphaned part is skipped, not crashed on, so the rest + # of the overlay still folds and the property still models. + # Arrange — build_epc() has MAIN + EXTENSION_1 but no EXTENSION_2. + baseline: EpcPropertyData = build_epc() + simulation = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1), + BuildingPartIdentifier.EXTENSION_2: BuildingPartOverlay( + wall_insulation_type=1 + ), + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — the present part got its overlay; the absent one was not added. + assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 1 + assert all( + part.identifier is not BuildingPartIdentifier.EXTENSION_2 + for part in result.sap_building_parts + ) + + def test_flat_roof_construction_type_folds_onto_the_part() -> None: # ADR-0033: a flat-roof landlord override sets `roof_construction_type` so the # calculator's flat path (`"flat" in roof_construction_type`) fires the From d1a2ee11e84ace721c2b56e975606b6778a610b4 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:35:47 +0000 Subject: [PATCH 03/18] fix(task_handler): persist cloud_logs_url for modelling_e2e @task_handler never built or passed cloud_logs_url, so every app using it (incl. modelling_e2e) ran run_subtask with the None default and the CloudWatch deep-link was never saved onto the SubTask. @subtask_handler did this correctly. Extract the URL builder into a shared utilities/aws_lambda/cloud_logs.py (public cloudwatch_url()), use it from both handlers, and pass the URL into run_subtask from @task_handler. Add regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../utilities/aws_lambda/test_task_handler.py | 104 ++++++++++++++++++ utilities/aws_lambda/cloud_logs.py | 27 +++++ utilities/aws_lambda/subtask_handler.py | 22 +--- utilities/aws_lambda/task_handler.py | 3 + 4 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 tests/utilities/aws_lambda/test_task_handler.py create mode 100644 utilities/aws_lambda/cloud_logs.py diff --git a/tests/utilities/aws_lambda/test_task_handler.py b/tests/utilities/aws_lambda/test_task_handler.py new file mode 100644 index 00000000..fae35de2 --- /dev/null +++ b/tests/utilities/aws_lambda/test_task_handler.py @@ -0,0 +1,104 @@ +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +import pytest +from sqlalchemy import Engine +from sqlmodel import Session + +from domain.tasks.tasks import Source +from orchestration.task_orchestrator import TaskOrchestrator +from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository +from repositories.tasks.task_postgres_repository import TaskPostgresRepository +from utilities.aws_lambda.task_handler import task_handler + + +@dataclass +class Harness: + orchestrator: TaskOrchestrator + tasks: TaskPostgresRepository + subtasks: SubTaskPostgresRepository + + @contextmanager + def factory(self) -> Generator[TaskOrchestrator, None, None]: + yield self.orchestrator + + +@pytest.fixture +def harness(db_engine: Engine) -> Iterator[Harness]: + with Session(db_engine) as session: + tasks = TaskPostgresRepository(session=session) + subtasks = SubTaskPostgresRepository(session=session) + yield Harness( + orchestrator=TaskOrchestrator(task_repo=tasks, subtask_repo=subtasks), + tasks=tasks, + subtasks=subtasks, + ) + + +def _direct_event(property_id: str) -> dict[str, Any]: + return {"property_id": property_id} + + +def test_task_handler_records_cloudwatch_url_on_subtask( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + # arrange + monkeypatch.setenv("AWS_REGION", "eu-west-2") + monkeypatch.setenv( + "AWS_LAMBDA_LOG_GROUP_NAME", "/aws/lambda/modelling-e2e" + ) + monkeypatch.setenv( + "AWS_LAMBDA_LOG_STREAM_NAME", "2026/05/20/[$LATEST]abc123" + ) + + @task_handler( + task_source="modelling_e2e", + source=Source.PROPERTY, + orchestrator_cm=harness.factory, + ) + def handler(body: dict[str, Any], context: Any) -> None: + return None + + # act + result = handler(_direct_event("prop-1"), context=None) + + # assert + subtask_id = result[0]["subtask_id"] + saved_url = harness.subtasks.get(UUID(subtask_id)).cloud_logs_url + assert saved_url is not None + assert saved_url.startswith( + "https://eu-west-2.console.aws.amazon.com/cloudwatch/home" + ) + # Log group / stream are console-encoded ("/" -> "$252F"). + assert "$252Faws$252Flambda$252Fmodelling-e2e" in saved_url + assert "$255B$2524LATEST$255D" in saved_url + + +def test_task_handler_leaves_cloudwatch_url_unset_outside_lambda( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + # arrange + for var in ( + "AWS_REGION", + "AWS_LAMBDA_LOG_GROUP_NAME", + "AWS_LAMBDA_LOG_STREAM_NAME", + ): + monkeypatch.delenv(var, raising=False) + + @task_handler( + task_source="modelling_e2e", + source=Source.PROPERTY, + orchestrator_cm=harness.factory, + ) + def handler(body: dict[str, Any], context: Any) -> None: + return None + + # act + result = handler(_direct_event("prop-1"), context=None) + + # assert + subtask_id = result[0]["subtask_id"] + assert harness.subtasks.get(UUID(subtask_id)).cloud_logs_url is None diff --git a/utilities/aws_lambda/cloud_logs.py b/utilities/aws_lambda/cloud_logs.py new file mode 100644 index 00000000..9a8da920 --- /dev/null +++ b/utilities/aws_lambda/cloud_logs.py @@ -0,0 +1,27 @@ +"""Build a CloudWatch console deep-link for the running Lambda invocation. + +Shared by @task_handler and @subtask_handler so both persist the same +`cloud_logs_url` onto the SubTask they run. +""" + +import os +from typing import Optional +from urllib.parse import quote + + +def _console_encode(value: str) -> str: + return quote(value, safe="").replace("%", "$25") + + +def cloudwatch_url() -> Optional[str]: + """Deep-link to this invocation's log stream, or None outside Lambda.""" + region = os.environ.get("AWS_REGION") + log_group = os.environ.get("AWS_LAMBDA_LOG_GROUP_NAME") + log_stream = os.environ.get("AWS_LAMBDA_LOG_STREAM_NAME") + if not (region and log_group and log_stream): + return None + return ( + f"https://{region}.console.aws.amazon.com/cloudwatch/home" + f"?region={region}#logsV2:log-groups/log-group/" + f"{_console_encode(log_group)}/log-events/{_console_encode(log_stream)}" + ) diff --git a/utilities/aws_lambda/subtask_handler.py b/utilities/aws_lambda/subtask_handler.py index e5ac086a..6b513ba4 100644 --- a/utilities/aws_lambda/subtask_handler.py +++ b/utilities/aws_lambda/subtask_handler.py @@ -6,12 +6,11 @@ TaskOrchestrator.run_subtask(...) calls. import json import logging -import os from contextlib import AbstractContextManager from functools import wraps from typing import Any, Callable, Optional, cast -from urllib.parse import quote +from utilities.aws_lambda.cloud_logs import cloudwatch_url from utilities.aws_lambda.default_orchestrator import default_orchestrator from utilities.aws_lambda.subtask_trigger_body import SubtaskTriggerBody from orchestration.task_orchestrator import TaskOrchestrator @@ -42,7 +41,7 @@ def subtask_handler( @wraps(func) def wrapper(event: dict[str, Any], context: Any) -> None: - cloud_logs_url = _cloudwatch_url() + cloud_logs_url = cloudwatch_url() with factory() as orchestrator: for record in _records(event): body = _parse_body(record) @@ -95,20 +94,3 @@ def _records(event: dict[str, Any]) -> list[dict[str, Any]]: if isinstance(raw_records, list): return [r for r in cast(list[Any], raw_records) if isinstance(r, dict)] return [event] - - -def _console_encode(value: str) -> str: - return quote(value, safe="").replace("%", "$25") - - -def _cloudwatch_url() -> Optional[str]: - region = os.environ.get("AWS_REGION") - log_group = os.environ.get("AWS_LAMBDA_LOG_GROUP_NAME") - log_stream = os.environ.get("AWS_LAMBDA_LOG_STREAM_NAME") - if not (region and log_group and log_stream): - return None - return ( - f"https://{region}.console.aws.amazon.com/cloudwatch/home" - f"?region={region}#logsV2:log-groups/log-group/" - f"{_console_encode(log_group)}/log-events/{_console_encode(log_stream)}" - ) diff --git a/utilities/aws_lambda/task_handler.py b/utilities/aws_lambda/task_handler.py index 34811515..43699aee 100644 --- a/utilities/aws_lambda/task_handler.py +++ b/utilities/aws_lambda/task_handler.py @@ -10,6 +10,7 @@ from contextlib import AbstractContextManager from functools import wraps from typing import Any, Callable, Optional, cast +from utilities.aws_lambda.cloud_logs import cloudwatch_url from utilities.aws_lambda.default_orchestrator import default_orchestrator from domain.tasks.tasks import Source from orchestration.task_orchestrator import TaskOrchestrator @@ -41,6 +42,7 @@ def task_handler( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) def wrapper(event: dict[str, Any], context: Any) -> Any: + cloud_logs_url = cloudwatch_url() with factory() as orchestrator: task_ids: list[dict[str, str]] = [] failures: list[dict[str, Any]] = [] @@ -66,6 +68,7 @@ def task_handler( orchestrator.run_subtask( subtask.id, work=lambda body=body: func(body, context), + cloud_logs_url=cloud_logs_url, ) except Exception: logger.exception( From 23ccfb0d0b865899e0df74a5782a41e15b97d895 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:36:37 +0000 Subject: [PATCH 04/18] =?UTF-8?q?Skip=20a=20landlord=20override=20that=20t?= =?UTF-8?q?argets=20a=20building=20part=20the=20EPC=20lacks=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Landlord Override can reference a building part the lodged or predicted EPC never carried (e.g. an extension_1 override on a property whose EPC has only main). apply_simulations indexed parts_by_id[identifier] unguarded, raising KeyError and aborting the whole property's modelling. Now the orphaned part is skipped. Recovers 14 of the 22 modelling_e2e failures in portfolio 796. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/modelling/scoring/overlay_applicator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 961f4e02..6a50fde4 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -41,7 +41,13 @@ def apply_simulations( for simulation in simulations: for identifier, overlay in simulation.building_parts.items(): - part = parts_by_id[identifier] + # A Landlord Override can target a building part the lodged (or + # predicted) EPC never carried — e.g. an `extension_1` override on a + # property whose EPC has only `main`. Skip the orphaned part rather + # than crashing the whole property's modelling on a `KeyError`. + part = parts_by_id.get(identifier) + if part is None: + continue for overlay_field in fields(overlay): value = getattr(overlay, overlay_field.name) if value is not None: From 75341da42d1591bfa4d52ac142513b711487cba5 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:38:38 +0000 Subject: [PATCH 05/18] 32 --- .../uprn_22086693/epc.json | 438 ++++++++++++++++++ .../lambda/modelling_e2e/variables.tf | 2 +- scripts/hyde/elmhurst_download.py | 2 +- 3 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json new file mode 100644 index 00000000..343215d1 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json @@ -0,0 +1,438 @@ +{ + "uprn": 22086693, + "roofs": [ + { + "description": { + "value": "Pitched, 200 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": { + "value": "Flat, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": { + "value": "Cavity wall, as built, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": { + "value": "Cavity wall, as built, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Suspended, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": { + "value": "Solid, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "addendum_numbers": [ + 8 + ], + "cavity_fill_recommended": "true" + }, + "lighting": { + "description": { + "value": "Low energy lighting in all fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "BN2 9ZN", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "BRIGHTON", + "built_form": 2, + "created_at": "2022-03-22 14:09:56", + "door_count": 1, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 14, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 113, + "central_heating_pump_age": 0, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.94, + "schema_type": "RdSAP-Schema-20.0.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": { + "value": "Semi-detached house", + "language": "1" + }, + "language_code": 1, + "property_type": 0, + "address_line_1": "30 Hallett Road", + "assessment_type": "RdSAP", + "completion_date": "2022-03-22", + "inspection_date": "2022-03-17", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 80, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 4, + "pvc_window_frames": "true", + "registration_date": "2022-03-22", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 1, + "photovoltaic_supply": [ + [ + { + "pitch": 2, + "peak_power": { + "value": 1.14, + "quantity": "kW" + }, + "orientation": 5, + "overshading": 1, + "pv_connection": 2 + } + ], + [ + { + "pitch": 2, + "peak_power": { + "value": 1.14, + "quantity": "kW" + }, + "orientation": 7, + "overshading": 1, + "pv_connection": 2 + } + ] + ], + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "Room heaters, electric", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 280, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.3, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 36.86, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.8, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 13.4, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.3, + "quantity": "metres" + }, + "total_floor_area": { + "value": 36.86, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.8, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 17.4, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "roof_construction": 1, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.3, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 6, + "quantity": "square metres" + }, + "party_wall_length": 0, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 7, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 6, + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI", + "flat_roof_insulation_thickness": "AB" + } + ], + "low_energy_lighting": 100, + "solar_water_heating": "N", + "habitable_room_count": 4, + "heating_cost_current": { + "value": 710, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 72, + "lighting_cost_current": { + "value": 74, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 548, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 95, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 112, + "currency": "GBP" + }, + "indicative_cost": "\u00a3500 - \u00a31,500", + "improvement_type": "B", + "improvement_details": { + "improvement_number": 6 + }, + "improvement_category": 5, + "energy_performance_rating": 77, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 49, + "currency": "GBP" + }, + "indicative_cost": "\u00a3800 - \u00a31,200", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 78 + }, + { + "sequence": 3, + "typical_saving": { + "value": 30, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 81, + "environmental_impact_rating": 80 + } + ], + "co2_emissions_potential": 1.5, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 74, + "currency": "GBP" + }, + "schema_version_original": "LIG-19.0", + "alternative_improvements": [ + { + "improvement": { + "sequence": 1, + "typical_saving": { + "value": 59, + "currency": "GBP" + }, + "improvement_type": "Q2", + "improvement_details": { + "improvement_number": 55 + }, + "improvement_category": 6, + "energy_performance_rating": 80, + "environmental_impact_rating": 78 + } + } + ], + "hot_water_cost_potential": { + "value": 66, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2107, + "impact_of_cavity_insulation": -1824, + "space_heating_existing_dwelling": 9574 + }, + "energy_consumption_current": 171, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.1.0.1", + "energy_consumption_potential": 103, + "environmental_impact_current": 69, + "fixed_lighting_outlets_count": 10, + "windows_transmission_details": { + "u_value": 2.6, + "data_source": 2, + "solar_transmittance": 0.76 + }, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 80, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 30, + "low_energy_fixed_lighting_outlets_count": 10 +} \ No newline at end of file diff --git a/deployment/terraform/lambda/modelling_e2e/variables.tf b/deployment/terraform/lambda/modelling_e2e/variables.tf index 02cba590..2595d86c 100644 --- a/deployment/terraform/lambda/modelling_e2e/variables.tf +++ b/deployment/terraform/lambda/modelling_e2e/variables.tf @@ -26,7 +26,7 @@ variable "reserved_concurrent_executions" { variable "maximum_concurrency" { type = number - default = 16 + default = 32 description = "Maximum concurrent Lambda invocations from the SQS trigger." } diff --git a/scripts/hyde/elmhurst_download.py b/scripts/hyde/elmhurst_download.py index f148ba28..0c317033 100644 --- a/scripts/hyde/elmhurst_download.py +++ b/scripts/hyde/elmhurst_download.py @@ -28,7 +28,7 @@ SESSION_DIR = HERE / ".elmhurst-session" SAMPLE_DIR = ( HERE.parent.parent / "backend/epc_api/json_samples/real_life_examples" - / "SAP-Schema-16.3/uprn_100061905751" + / "SAP-Schema-16.0/uprn_10070004512" ) ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105" From 451f0773e066281d10d69e0e2c588a4a9c870093 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:41:57 +0000 Subject: [PATCH 06/18] Log 2026-06-23 14:48 modelling_e2e failure sweep + e2e candidates in corpus worklist Co-Authored-By: Claude Opus 4.8 (1M context) --- .../expand-sap-accuracy-corpus/worklist.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index cdd3abbd..681a2748 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -120,6 +120,46 @@ malformed cert (e.g. PV-as-list AttributeError) is skipped+reported too, not jus missing-field ValueErrors; transient `EpcApiError` (subclasses `Exception`) still propagates. +regression test. +### 🔧🔍 modelling_e2e failure sweep — later run (2026-06-23 14:48 BST, portfolio 796, scenario 1268) +A second `modelling_e2e` run (filter `task_source='modelling_e2e' AND job_started > +'2026-06-23 14:48:01.806 +0100'`): **200 tasks, 30 FAILED (15%) → 22 unique props** +(8 retried once). The DB only stores bare `property_id` in subtask `outputs` +(`cloud_logs_url` empty) — root causes recovered by replaying each through +`scripts.run_modelling_e2e --scenario-id 1268`. Four root-cause clusters: + +**① ✅ FIXED — `KeyError: BuildingPartIdentifier.EXTENSION_1` (14 props, the dominant +failure).** A **Landlord Override** targeted `extension_1` but the lodged/predicted EPC +had no such part; `overlay_applicator.apply_simulations` indexed `parts_by_id[identifier]` +unguarded → crashed the whole property. Fixed: skip the orphaned part (`.get` + `None` +guard). +regression test `test_override_targeting_a_part_absent_from_the_epc_is_skipped`. +Not a mapper gap — a modelling-overlay bug. The 14 (pid / uprn): +710295/100020458237, 710482/100020450179, 713040/100020404702, 715575/100020397529, +715894/100020604961, 717435/22010468, 720142/100020383544, 720560/100021921443, +721241/100020453651, 724945/100021915421, 725415/100020404036, 726517/100020631307, +726592/100021918195, 730800/100021920273. (Observed post-fix eng: 720142 69.2, +710295 63.2→69.8, 720560 61.8→70.4 predicted.) + +**② 🔍 mapper/cascade gap — `UnmappedSapCode: fuel_code 10`** (1 prop, 730259 / uprn +100061905741). EPC-less → prediction synthesises a cohort EPC carrying `fuel_code 10`, +absent from the calculator's cascade dispatch dict → FATAL (it's the target prop's own +fuel, not a skippable cohort cert). FOR KHALIM: add fuel-code-10 to the cascade. + +**③ ⛔ not-predictable — empty/unresolved postcode cohort** (3 props): 714585/100020612517 +(`CR0 0DD`), 718580/10013149015 (`BR6 6BS`), 723881/22005280 (`BN41 2TP`). EPC-less AND +cohort empty after filtering → Prediction Path 3 can't fire. Coverage gap, not a crash bug. + +**④ ⚠ transient — pass clean on replay** (4 props): 712401/100020394694 (eng 75.3), +718138/100020397707 (eng 63.5→69.2), 720844/100020472603 (eng 71.8), 723648/100020480302 +(eng 70.8). Likely a flaky EPC/Solar API call during the batch — no code defect. + +**E2E candidates to pin (best = the lodged-EPC ones, clean to build in Elmhurst):** +`[ ]` 100020383544 (pid 720142, eng 69.2) · `[ ]` 100020458237 (710295) · `[ ]` +100020404702 (713040) · `[ ]` 100020450179 (710482) · `[ ]` 100021915421 (724945) · +`[ ]` 100021920273 (730800) · `[ ]` 100020394694 (712401, eng 75.3) · `[ ]` +100020397707 (718138) · `[ ]` 100020472603 (720844, eng 71.8) · `[ ]` 100020480302 +(723648, eng 70.8). (Schema not populated on the gov-API objects — confirm when keying +each into Elmhurst.) + ### 📋 PLAN — close the 8 modelling_e2e mapping gaps (2026-06-23 run, portfolio 796) The 8 failed prediction targets reduce to **5 distinct mapper-gap classes** (the fix targets). Per class: fix the mapper GENERICALLY, guard with BOTH the RdSAP-21.0.1 From 75e4989982c76acaccb0257a9918300b2c406bab Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 16:00:07 +0000 Subject: [PATCH 07/18] Add moto[s3,sqs] to devcontainer for S3/SQS orchestration tests tests/orchestration/test_postcode_splitter_orchestrator.py imports `from moto import mock_aws` (moto 5.x) but moto was absent, so the file errored at collection. Pin moto[s3,sqs]==5.0.28 (S3+SQS are the only mocked services); resolves cleanly against the boto3 1.35.44 and cryptography 43.0.3 pins. All 4 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/backend/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer/backend/requirements.txt b/.devcontainer/backend/requirements.txt index 2db3710a..78131981 100644 --- a/.devcontainer/backend/requirements.txt +++ b/.devcontainer/backend/requirements.txt @@ -24,6 +24,7 @@ ipykernel>=6.25,<7 dotenv psycopg[binary] pytest-postgresql +moto[s3,sqs]==5.0.28 # mock_aws (moto 5.x) for S3/SQS in orchestration tests # Formatting black==26.1.0 boto3-stubs From 8f0432721c79836a67ad4ce2bf133b84c6f13715 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 16:13:34 +0000 Subject: [PATCH 08/18] test(accuracy): pin RdSAP-20.0.0 PV semi uprn_22086693 (PV-list fix corpus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../uprn_22086693/elmhurst_summary.pdf | Bin 0 -> 66213 bytes .../uprn_22086693/elmhurst_worksheet.pdf | Bin 0 -> 44131 bytes scripts/hyde/build_22086693.py | 307 ++++++++++++++++++ .../test_real_cert_sap_accuracy.py | 24 ++ 4 files changed, 331 insertions(+) create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_summary.pdf create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/elmhurst_worksheet.pdf create mode 100644 scripts/hyde/build_22086693.py 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 0000000000000000000000000000000000000000..56569fde69d5140917f6ba22d1e5edeb642ab688 GIT binary patch literal 66213 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༚%hcPLwyc4{&zPb2BeAr?nP==y{Cf=$Z@6Uv+% z={zTD@&j;nI-(0HrX#{bJeg-mSmR6oF7o)ZW*ZS-E~uoYPZrCUDgKf;|5XXG1Eo|N zN#%tEw+$-6C41EWOi2XyuEFm9_h|pQPt|UyH7H*qV&Sbi>%oCA=erVfI zEDbx}cTBN*RDHZi2ld`g=x$&bF4Y9i40mnK#V2WI>+|DfgUPE|lklxf+O3KsFZ)hs zizSM)b5Cu`cjFIDvs$%tEZcI8 z>Z32UD-H13@mjNGu59W@MLR`YDsBO}`z0R>;O+Rf#&=~fS8%mG3BZ(Qe{A{yXAhX( z7dOMUx+|1E<>xPV^ql*uQ$^m8&_jDdo6t3*uex?W7!!Ref?a>&nbVrFWc?)`8-AP- zfm1&a4^r_uLc7sEOgc}0n#d%;X?P2x;u|%jwt}6ta~kJdW_cB)O=&c2(M+9vIJ_|5 zC+-p+LT^{-^%b!2p5#F^sd4`s0do@08A6%qG}!>2?))}v*REPl1_)zDfEB*r0FnD}q{cd;RPhmo=wY%NW z>jki7lVeh?!9@Zf#-NM>(snjrPcD!TjcK)$~cbYEd z{#9~?Uz?3!mG?+FG8HvH1MU%VSNEbI9Xdj?jBsuthWFYhT|SZ%8t#-?|7hgP+07lU z7iz}VlEgbDE{h}t|?m6&E!lxhbOS z=d9{O(sH8@h^)misxC3~pez2Ys?5&1SDgDR%#_@yEQ*>J6Ik{-OZUG%3Zn1u%GIk! z%8);mc8kM`$0WN`&ewdqb8rHZ{`1z%(=M@py7;H=wswGD*#rRK|6U9bFc<`8uw(cO zyY|4pE(Yhnuo_tg0FMr z|54v{v;P7U`A7eR`5`y$1q*`&Zng^nf&_1l$94C9?SsGB3lX?}`yZH);LUa+g4bz6 zH_HivfPX9J>23vev~>rFiv##{pnkT$<_n*;i_7&-2EVpufP@6V!_&&$^N-mCx&Bx} L09ICc4Tb*#IxY&0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb7b60b6fa0fa35f93c10c6ea30b601f656a8ae4 GIT binary patch literal 44131 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 rW&W@Kg+gS^7T!{5#Xu literal 0 HcmV?d00001 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, + ), ) From 04017975248c300242744caa507d6da8249bcbaf Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 16:48:04 +0000 Subject: [PATCH 09/18] sap fixes --- scripts/hyde/elmhurst_download.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/hyde/elmhurst_download.py b/scripts/hyde/elmhurst_download.py index 0c317033..6e9e4805 100644 --- a/scripts/hyde/elmhurst_download.py +++ b/scripts/hyde/elmhurst_download.py @@ -28,7 +28,7 @@ SESSION_DIR = HERE / ".elmhurst-session" SAMPLE_DIR = ( HERE.parent.parent / "backend/epc_api/json_samples/real_life_examples" - / "SAP-Schema-16.0/uprn_10070004512" + / "RdSAP-Schema-20.0.0/uprn_22086693" ) ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105" From 9956df07ffe856a63c2daf708a1a1ef105f81cfb Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 17:09:11 +0000 Subject: [PATCH 10/18] =?UTF-8?q?Land=20an=20override=20on=20the=20EPC's?= =?UTF-8?q?=20part=20at=20its=20position=20when=20the=20label=20is=20absen?= =?UTF-8?q?t=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Landlord Override's building_part is a positional index (0=main, 1=extension 1…, ADR-0004), but the gov-API EPC can label that slot differently (e.g. lodge the 2nd part as 'other', not 'extension_1'). The previous fix skipped such orphaned overrides, silently discarding the landlord's correction. Now the override falls back onto the EPC's part at that position (via _resolve_part), so the correction lands; only a position the EPC models no part at is skipped (no geometry to model a wholly-absent part). Replaces the skip-only behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modelling/scoring/overlay_applicator.py | 46 ++++++++++++++++--- .../modelling/test_overlay_applicator.py | 44 +++++++++++++----- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 6a50fde4..848f1a8f 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -9,10 +9,12 @@ then discarded). See ADR-0016. import copy from dataclasses import fields -from typing import Optional, Sequence +from typing import Final, Optional, Sequence from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, + SapBuildingPart, SapVentilation, SapWindow, WindowTransmissionDetails, @@ -29,6 +31,39 @@ from domain.modelling.simulation import ( ) +# A Landlord Override's building part is a POSITIONAL index (0=main, 1=extension +# 1…, ADR-0004), translated to a `BuildingPartIdentifier` upstream. This recovers +# that position so an override can fall back onto the part the EPC actually models +# at that slot when the gov-API labelled it differently (e.g. lodged a 2nd part as +# `other` rather than `extension_1`). +_POSITION_BY_IDENTIFIER: Final[dict[BuildingPartIdentifier, int]] = { + BuildingPartIdentifier.MAIN: 0, + BuildingPartIdentifier.EXTENSION_1: 1, + BuildingPartIdentifier.EXTENSION_2: 2, + BuildingPartIdentifier.EXTENSION_3: 3, + BuildingPartIdentifier.EXTENSION_4: 4, +} + + +def _resolve_part( + epc: EpcPropertyData, + parts_by_id: dict[BuildingPartIdentifier, SapBuildingPart], + identifier: BuildingPartIdentifier, +) -> Optional[SapBuildingPart]: + """The building part an overlay targets: the EPC's part with that identifier + when present, else the part at the override's positional index (so a + correction for `extension_1` still lands on the EPC's 2nd part even when the + gov-API lodged it under a different label). ``None`` when the EPC models no + part at that position.""" + part = parts_by_id.get(identifier) + if part is not None: + return part + position = _POSITION_BY_IDENTIFIER.get(identifier) + if position is None or position >= len(epc.sap_building_parts): + return None + return epc.sap_building_parts[position] + + def apply_simulations( baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> EpcPropertyData: @@ -41,11 +76,10 @@ def apply_simulations( for simulation in simulations: for identifier, overlay in simulation.building_parts.items(): - # A Landlord Override can target a building part the lodged (or - # predicted) EPC never carried — e.g. an `extension_1` override on a - # property whose EPC has only `main`. Skip the orphaned part rather - # than crashing the whole property's modelling on a `KeyError`. - part = parts_by_id.get(identifier) + part = _resolve_part(result, parts_by_id, identifier) + # No part at this position — the EPC models fewer parts than the + # override's index. We have no geometry to model the missing part, so + # skip it rather than crash the whole property's modelling. if part is None: continue for overlay_field in fields(overlay): diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 2e7059ec..8f6c960f 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -55,12 +55,37 @@ def test_apply_writes_targeted_building_part_and_leaves_others_untouched() -> No ) -def test_override_targeting_a_part_absent_from_the_epc_is_skipped() -> None: - # A Landlord Override can reference a building part the lodged (or predicted) - # EPC never carried — e.g. an `extension_1` override on a property whose EPC - # has only `main`. The orphaned part is skipped, not crashed on, so the rest - # of the overlay still folds and the property still models. - # Arrange — build_epc() has MAIN + EXTENSION_1 but no EXTENSION_2. +def test_override_for_an_absent_semantic_part_lands_on_the_part_at_that_position() -> ( + None +): + # `building_part` is a POSITIONAL index (0=main, 1=extension 1…, ADR-0004). The + # gov-API EPC can label its parts differently (e.g. a 2nd part lodged as `other` + # rather than `extension_1`). An `extension_1` override must still land on the + # part at position 1 — the landlord's correction is applied, not dropped. + # Arrange — build_epc() is [MAIN, EXTENSION_1]; relabel the 2nd part to OTHER so + # the EXTENSION_1 identifier is absent but position 1 still exists. + baseline: EpcPropertyData = build_epc() + baseline.sap_building_parts[1].identifier = BuildingPartIdentifier.OTHER + simulation = EpcSimulation( + building_parts={ + BuildingPartIdentifier.EXTENSION_1: BuildingPartOverlay( + wall_insulation_type=3 + ) + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — the override folded onto the part at position 1 (the OTHER part). + assert _part(result, BuildingPartIdentifier.OTHER).wall_insulation_type == 3 + + +def test_override_with_no_part_at_that_position_is_skipped() -> None: + # When there is genuinely no part at the override's position (the EPC models + # fewer parts than the index), the override is skipped rather than crashing — + # we cannot model an extension we have no geometry for. + # Arrange — build_epc() has 2 parts (positions 0, 1); position 2 is absent. baseline: EpcPropertyData = build_epc() simulation = EpcSimulation( building_parts={ @@ -74,12 +99,9 @@ def test_override_targeting_a_part_absent_from_the_epc_is_skipped() -> None: # Act result: EpcPropertyData = apply_simulations(baseline, [simulation]) - # Assert — the present part got its overlay; the absent one was not added. + # Assert — the present part got its overlay; nothing was added for position 2. assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 1 - assert all( - part.identifier is not BuildingPartIdentifier.EXTENSION_2 - for part in result.sap_building_parts - ) + assert len(result.sap_building_parts) == len(baseline.sap_building_parts) def test_flat_roof_construction_type_folds_onto_the_part() -> None: From e8c3406901e6bbabaa48b101f4ad3e62f4085b96 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 17:09:47 +0000 Subject: [PATCH 11/18] Update worklist: override now lands positionally, not skipped Co-Authored-By: Claude Opus 4.8 (1M context) --- .../skills/expand-sap-accuracy-corpus/worklist.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index 681a2748..58f3b984 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -130,9 +130,16 @@ A second `modelling_e2e` run (filter `task_source='modelling_e2e' AND job_starte **① ✅ FIXED — `KeyError: BuildingPartIdentifier.EXTENSION_1` (14 props, the dominant failure).** A **Landlord Override** targeted `extension_1` but the lodged/predicted EPC had no such part; `overlay_applicator.apply_simulations` indexed `parts_by_id[identifier]` -unguarded → crashed the whole property. Fixed: skip the orphaned part (`.get` + `None` -guard). +regression test `test_override_targeting_a_part_absent_from_the_epc_is_skipped`. -Not a mapper gap — a modelling-overlay bug. The 14 (pid / uprn): +unguarded → crashed the whole property. Root cause is a **numbering mismatch**: the +override's `building_part` is a positional index (0=main, 1=extension 1…, ADR-0004), but +the gov-API EPC can label that slot differently (720142's 2nd part lodged as `other`, no +`extension_1`). Fix (per product decision — *honour the override, don't drop it*): +`_resolve_part` falls back to the EPC's part **at that position** when the semantic label +is absent, so the landlord's correction lands; only a position the EPC models no part at +is skipped (no geometry to model a wholly-absent part). +regression tests +`test_override_for_an_absent_semantic_part_lands_on_the_part_at_that_position` / +`test_override_with_no_part_at_that_position_is_skipped`. Not a mapper gap — a +modelling-overlay bug. The 14 (pid / uprn): 710295/100020458237, 710482/100020450179, 713040/100020404702, 715575/100020397529, 715894/100020604961, 717435/22010468, 720142/100020383544, 720560/100021921443, 721241/100020453651, 724945/100021915421, 725415/100020404036, 726517/100020631307, From 290097b1c77433133a80702c58be80b27e12c1da Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 17:52:19 +0000 Subject: [PATCH 12/18] Record per-property failure detail in modelling_e2e subtask outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a property failed, the handler recorded only its bare property_id and raised RuntimeError("failed property_ids: [...]"). That string is what SubTask.fail persists into the subtask outputs.error column, so a failed run told you which property failed but never why — forcing a CloudWatch lookup. The per-property catch now captures property_id, uprn, error_type, and the error message, and the raised RuntimeError embeds those as JSON so the subtask outputs column is parseable directly. query_failed_modelling_e2e.py reads that outputs.error into a new Error column in its report. Co-Authored-By: Claude Opus 4.8 (1M context) --- applications/modelling_e2e/handler.py | 31 ++++++++++++++---- scripts/query_failed_modelling_e2e.py | 46 ++++++++++++++++++++------- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 9a8c8e73..0f2c4fe6 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -25,6 +25,7 @@ from __future__ import annotations import dataclasses import io +import json import os from collections.abc import Callable from typing import Any, Optional, cast @@ -289,7 +290,7 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0] products = catalogue_with_off_catalogue_overrides(read_session) - errors: list[int] = [] + failures: list[dict[str, Any]] = [] for property_id in property_ids: try: @@ -426,10 +427,18 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: except Exception as error: # noqa: BLE001 logger.error( - f"property={property_id}: {type(error).__name__}: {error}", + f"property={property_id} uprn={uprns.get(property_id)}: " + f"{type(error).__name__}: {error}", exc_info=True, ) - errors.append(property_id) + failures.append( + { + "property_id": property_id, + "uprn": uprns.get(property_id), + "error_type": type(error).__name__, + "error": str(error), + } + ) # Cohort certs the mapper could not consume were skipped (not aborted on) # so prediction could proceed; surface them — with cert numbers — in the @@ -444,10 +453,20 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: f"{[s['certificate_number'] for s in skipped_certs]}" ) - if errors: - message = f"failed property_ids: {errors}" + if failures: + failed_ids = [f["property_id"] for f in failures] + # Persisted verbatim into the subtask's outputs.error (via + # SubTask.fail), so include each property's error type + message — + # not just the IDs — to make failed runs diagnosable without + # cross-referencing CloudWatch. + message = ( + f"failed property_ids: {failed_ids}; " + f"details: {json.dumps(failures)}" + ) if skipped_certs: - message += f"; skipped_unmappable_cohort_certs: {skipped_certs}" + message += ( + f"; skipped_unmappable_cohort_certs: {json.dumps(skipped_certs)}" + ) raise RuntimeError(message) return {"skipped_unmappable_cohort_certs": skipped_certs} if skipped_certs else None diff --git a/scripts/query_failed_modelling_e2e.py b/scripts/query_failed_modelling_e2e.py index a4cca69f..db684023 100644 --- a/scripts/query_failed_modelling_e2e.py +++ b/scripts/query_failed_modelling_e2e.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import sys from pathlib import Path +from typing import Any, cast _REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_REPO_ROOT)) @@ -30,6 +31,7 @@ with engine.connect() as conn: st.id AS subtask_id, st.task_id, st.inputs, + st.outputs, st.updated_at FROM sub_task st JOIN tasks t ON t.id = st.task_id @@ -45,19 +47,32 @@ with engine.connect() as conn: ) exit(0) + def _error_text(outputs_raw: object) -> str: + """Pull the persisted failure reason out of the subtask outputs JSON.""" + try: + outputs: Any = ( + json.loads(outputs_raw) + if isinstance(outputs_raw, str) + else (outputs_raw or {}) + ) + except Exception: + return str(outputs_raw or "") + if isinstance(outputs, dict): + return str(cast("dict[str, Any]", outputs).get("error", "")) + return "" + # Collect all property_ids across all rows all_property_ids: list[int] = [] - parsed: list[tuple[str, str, list[int], str, str]] = [] - for subtask_id, task_id, inputs_raw, updated_at in subtask_rows: + parsed: list[tuple[str, str, list[int], str, str, str]] = [] + for subtask_id, task_id, inputs_raw, outputs_raw, updated_at in subtask_rows: try: - inputs = ( + inputs: Any = ( json.loads(inputs_raw) if isinstance(inputs_raw, str) else (inputs_raw or {}) ) - property_ids: list[int] = [ - int(p) for p in (inputs.get("property_ids") or []) - ] + raw_ids = cast("list[Any]", cast("dict[str, Any]", inputs).get("property_ids") or []) + property_ids: list[int] = [int(p) for p in raw_ids] except Exception: property_ids = [] parsed.append( @@ -67,6 +82,7 @@ with engine.connect() as conn: property_ids, str(updated_at), inputs_raw or "", + _error_text(outputs_raw), ) ) all_property_ids.extend(property_ids) @@ -80,23 +96,29 @@ with engine.connect() as conn: ).fetchall() uprn_map = {int(r[0]): int(r[1]) for r in uprn_rows} +def _cell(value: str) -> str: + """Escape table-breaking characters so multi-line errors stay on one row.""" + return value.replace("|", "\\|").replace("\n", "
") + + lines: list[str] = [ "# Failed modelling_e2e Subtasks\n", - f"| Subtask ID | Task ID | Updated At | Property ID | UPRN | Inputs |", - f"|-----------|---------|------------|-------------|------|--------|", + f"| Subtask ID | Task ID | Updated At | Property ID | UPRN | Error | Inputs |", + f"|-----------|---------|------------|-------------|------|-------|--------|", ] -for subtask_id, task_id, property_ids, updated_at, inputs_raw in parsed: - inputs_cell = (inputs_raw or "").replace("|", "\\|") +for subtask_id, task_id, property_ids, updated_at, inputs_raw, error in parsed: + inputs_cell = _cell(inputs_raw or "") + error_cell = _cell(error or "") if property_ids: for pid in property_ids: uprn = uprn_map.get(pid, "unknown") lines.append( - f"| {subtask_id} | {task_id} | {updated_at} | {pid} | {uprn} | {inputs_cell} |" + f"| {subtask_id} | {task_id} | {updated_at} | {pid} | {uprn} | {error_cell} | {inputs_cell} |" ) else: lines.append( - f"| {subtask_id} | {task_id} | {updated_at} | — | — | {inputs_cell} |" + f"| {subtask_id} | {task_id} | {updated_at} | — | — | {error_cell} | {inputs_cell} |" ) _OUTPUT.write_text("\n".join(lines) + "\n", encoding="utf-8") From af5b2b5f80ad829beaca85517a1d8d412651e5d0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 18:03:04 +0000 Subject: [PATCH 13/18] Mark unmappable cohort certs as a subtask failure Skipped cohort certs were previously surfaced only as outputs.result on a completed subtask, so they were easy to miss. Treat them as a failure too: once the batch has run to completion (so every modellable property is already written to DB), raise if there were any per-property errors OR any skipped certs. The run gets flagged for debugging without discarding the work done. Co-Authored-By: Claude Opus 4.8 (1M context) --- applications/modelling_e2e/handler.py | 37 +++++++++++-------- .../modelling_e2e/test_handler.py | 36 +++++++++--------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 0f2c4fe6..6e3dece5 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -453,22 +453,29 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: f"{[s['certificate_number'] for s in skipped_certs]}" ) - if failures: - failed_ids = [f["property_id"] for f in failures] - # Persisted verbatim into the subtask's outputs.error (via - # SubTask.fail), so include each property's error type + message — - # not just the IDs — to make failed runs diagnosable without - # cross-referencing CloudWatch. - message = ( - f"failed property_ids: {failed_ids}; " - f"details: {json.dumps(failures)}" - ) - if skipped_certs: - message += ( - f"; skipped_unmappable_cohort_certs: {json.dumps(skipped_certs)}" + # A property that errored AND a cohort cert the mapper could not consume + # are both surfaced as failures, so the subtask is marked failed and + # shows up for debugging. The whole batch has already run by this point — + # every property that could be modelled was written to DB above — so + # failing here flags the run without discarding the work done so far. + if failures or skipped_certs: + parts: list[str] = [] + if failures: + failed_ids = [f["property_id"] for f in failures] + # Persisted verbatim into the subtask's outputs.error (via + # SubTask.fail): include each property's error type + message, + # not just the IDs, so failed runs are diagnosable without + # cross-referencing CloudWatch. + parts.append( + f"failed property_ids: {failed_ids}; " + f"details: {json.dumps(failures)}" ) - raise RuntimeError(message) + if skipped_certs: + parts.append( + f"skipped_unmappable_cohort_certs: {json.dumps(skipped_certs)}" + ) + raise RuntimeError("; ".join(parts)) - return {"skipped_unmappable_cohort_certs": skipped_certs} if skipped_certs else None + return None finally: read_session.close() diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 6929d13f..540d6040 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -214,16 +214,19 @@ def test_lodged_epc_path_saves_epc_plan_and_marks_modelled( _baseline_orchestrator.return_value.run.assert_called_once_with([PROPERTY_ID]) -def test_skipped_cohort_certs_are_surfaced_in_the_outputs() -> None: +def test_skipped_cohort_certs_fail_the_subtask_but_the_plan_is_still_saved() -> None: """Cohort certs the mapper can't consume are skipped (so prediction is not - aborted) and surfaced — with cert numbers — in the subtask outputs, so the - mapper gaps can be reported and closed.""" + aborted), then surfaced as a failure — the subtask is marked failed (the + cert numbers land in outputs.error via the raised RuntimeError) so the + mapper gaps get debugged. The batch still ran to completion first, so the + property's plan was committed before the handler raised.""" from repositories.comparable_properties.epc_comparable_properties_repository import ( SkippedCohortCert, ) mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_plan = _plan_mock() + mock_uow = MagicMock() skipped = [ SkippedCohortCert( certificate_number="8257-7539-1649-0633-4992", @@ -287,24 +290,21 @@ def test_skipped_cohort_certs_are_surfaced_in_the_outputs() -> None: MockUoW = stack.enter_context( patch("applications.modelling_e2e.handler.PostgresUnitOfWork") ) - MockUoW.return_value.__enter__.return_value = MagicMock() + MockUoW.return_value.__enter__.return_value = mock_uow MockUoW.return_value.__exit__.return_value = False - # Act - result = _call_handler(_BODY) + # Act — the skipped cert fails the subtask, but only after the batch ran. + with pytest.raises(RuntimeError) as excinfo: + _call_handler(_BODY) - # Assert — the handler's return (→ subtask outputs.result) carries the cert - # numbers + errors of every skipped cohort cert. - assert result == { - "skipped_unmappable_cohort_certs": [ - { - "certificate_number": "8257-7539-1649-0633-4992", - "error": ( - "ValueError: RdSapSchema17_1: missing required field 'window'" - ), - } - ] - } + # Assert — the cert number + error reach outputs.error (the raised message), + # and the property's plan was still committed before the handler raised. + message = str(excinfo.value) + assert "skipped_unmappable_cohort_certs" in message + assert "8257-7539-1649-0633-4992" in message + assert "RdSapSchema17_1: missing required field 'window'" in message + mock_uow.plan.save.assert_called_once() + mock_uow.commit.assert_called_once() # --------------------------------------------------------------------------- From 51118ce121385c3b1a88ad66b1884d060dfe7a49 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 18:07:10 +0000 Subject: [PATCH 14/18] solar + more logs --- applications/modelling_e2e/handler.py | 62 +++++++++++++++++++++------ scripts/query_failed_modelling_e2e.py | 46 ++++++++++++++------ scripts/trigger_modelling_e2e_sqs.py | 29 +++++++++---- 3 files changed, 104 insertions(+), 33 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 3faf09f7..d1f454f4 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -25,6 +25,7 @@ from __future__ import annotations import dataclasses import io +import json import os from collections.abc import Callable from typing import Any, Optional, cast @@ -82,6 +83,7 @@ from repositories.property.property_overrides_postgres_reader import ( from repositories.scenario.scenario_postgres_repository import ( ScenarioPostgresRepository, ) +from repositories.solar.solar_postgres_repository import SolarPostgresRepository from utilities.aws_lambda.task_handler import task_handler from utilities.logger import setup_logger @@ -235,8 +237,9 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: try: scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0] products = catalogue_with_off_catalogue_overrides(read_session) + solar_reader = SolarPostgresRepository(read_session) - errors: list[int] = [] + failures: list[dict[str, Any]] = [] for property_id in property_ids: try: @@ -300,9 +303,18 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: landlord_overrides=overrides, ).effective_epc - solar_insights: Optional[dict[str, Any]] = ( - None if no_solar else _solar_insights_for(solar_client, spatial) - ) + # Read-before-fetch: the Google Solar call is paid, so skip it + # when this UPRN's insights are already persisted. Only a cache + # miss hits Google — re-runs cost nothing for solar. + solar_insights: Optional[dict[str, Any]] + solar_was_fetched = False + if no_solar: + solar_insights = None + else: + solar_insights = solar_reader.get(uprn) + if solar_insights is None: + solar_insights = _solar_insights_for(solar_client, spatial) + solar_was_fetched = solar_insights is not None # All Measure Types are considered: the off-catalogue overlay # (catalogue_with_off_catalogue_overrides) prices the measures the @@ -339,7 +351,8 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: if spatial is not None: uow.spatial.save(uprn, spatial) if ( - solar_insights is not None + solar_was_fetched + and solar_insights is not None and spatial is not None and spatial.coordinates is not None ): @@ -364,10 +377,18 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: except Exception as error: # noqa: BLE001 logger.error( - f"property={property_id}: {type(error).__name__}: {error}", + f"property={property_id} uprn={uprns.get(property_id)}: " + f"{type(error).__name__}: {error}", exc_info=True, ) - errors.append(property_id) + failures.append( + { + "property_id": property_id, + "uprn": uprns.get(property_id), + "error_type": type(error).__name__, + "error": str(error), + } + ) # Cohort certs the mapper could not consume were skipped (not aborted on) # so prediction could proceed; surface them — with cert numbers — in the @@ -382,12 +403,29 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: f"{[s['certificate_number'] for s in skipped_certs]}" ) - if errors: - message = f"failed property_ids: {errors}" + # A property that errored AND a cohort cert the mapper could not consume + # are both surfaced as failures, so the subtask is marked failed and + # shows up for debugging. The whole batch has already run by this point — + # every property that could be modelled was written to DB above — so + # failing here flags the run without discarding the work done so far. + if failures or skipped_certs: + parts: list[str] = [] + if failures: + failed_ids = [f["property_id"] for f in failures] + # Persisted verbatim into the subtask's outputs.error (via + # SubTask.fail): include each property's error type + message, + # not just the IDs, so failed runs are diagnosable without + # cross-referencing CloudWatch. + parts.append( + f"failed property_ids: {failed_ids}; " + f"details: {json.dumps(failures)}" + ) if skipped_certs: - message += f"; skipped_unmappable_cohort_certs: {skipped_certs}" - raise RuntimeError(message) + parts.append( + f"skipped_unmappable_cohort_certs: {json.dumps(skipped_certs)}" + ) + raise RuntimeError("; ".join(parts)) - return {"skipped_unmappable_cohort_certs": skipped_certs} if skipped_certs else None + return None finally: read_session.close() diff --git a/scripts/query_failed_modelling_e2e.py b/scripts/query_failed_modelling_e2e.py index a4cca69f..db684023 100644 --- a/scripts/query_failed_modelling_e2e.py +++ b/scripts/query_failed_modelling_e2e.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import sys from pathlib import Path +from typing import Any, cast _REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_REPO_ROOT)) @@ -30,6 +31,7 @@ with engine.connect() as conn: st.id AS subtask_id, st.task_id, st.inputs, + st.outputs, st.updated_at FROM sub_task st JOIN tasks t ON t.id = st.task_id @@ -45,19 +47,32 @@ with engine.connect() as conn: ) exit(0) + def _error_text(outputs_raw: object) -> str: + """Pull the persisted failure reason out of the subtask outputs JSON.""" + try: + outputs: Any = ( + json.loads(outputs_raw) + if isinstance(outputs_raw, str) + else (outputs_raw or {}) + ) + except Exception: + return str(outputs_raw or "") + if isinstance(outputs, dict): + return str(cast("dict[str, Any]", outputs).get("error", "")) + return "" + # Collect all property_ids across all rows all_property_ids: list[int] = [] - parsed: list[tuple[str, str, list[int], str, str]] = [] - for subtask_id, task_id, inputs_raw, updated_at in subtask_rows: + parsed: list[tuple[str, str, list[int], str, str, str]] = [] + for subtask_id, task_id, inputs_raw, outputs_raw, updated_at in subtask_rows: try: - inputs = ( + inputs: Any = ( json.loads(inputs_raw) if isinstance(inputs_raw, str) else (inputs_raw or {}) ) - property_ids: list[int] = [ - int(p) for p in (inputs.get("property_ids") or []) - ] + raw_ids = cast("list[Any]", cast("dict[str, Any]", inputs).get("property_ids") or []) + property_ids: list[int] = [int(p) for p in raw_ids] except Exception: property_ids = [] parsed.append( @@ -67,6 +82,7 @@ with engine.connect() as conn: property_ids, str(updated_at), inputs_raw or "", + _error_text(outputs_raw), ) ) all_property_ids.extend(property_ids) @@ -80,23 +96,29 @@ with engine.connect() as conn: ).fetchall() uprn_map = {int(r[0]): int(r[1]) for r in uprn_rows} +def _cell(value: str) -> str: + """Escape table-breaking characters so multi-line errors stay on one row.""" + return value.replace("|", "\\|").replace("\n", "
") + + lines: list[str] = [ "# Failed modelling_e2e Subtasks\n", - f"| Subtask ID | Task ID | Updated At | Property ID | UPRN | Inputs |", - f"|-----------|---------|------------|-------------|------|--------|", + f"| Subtask ID | Task ID | Updated At | Property ID | UPRN | Error | Inputs |", + f"|-----------|---------|------------|-------------|------|-------|--------|", ] -for subtask_id, task_id, property_ids, updated_at, inputs_raw in parsed: - inputs_cell = (inputs_raw or "").replace("|", "\\|") +for subtask_id, task_id, property_ids, updated_at, inputs_raw, error in parsed: + inputs_cell = _cell(inputs_raw or "") + error_cell = _cell(error or "") if property_ids: for pid in property_ids: uprn = uprn_map.get(pid, "unknown") lines.append( - f"| {subtask_id} | {task_id} | {updated_at} | {pid} | {uprn} | {inputs_cell} |" + f"| {subtask_id} | {task_id} | {updated_at} | {pid} | {uprn} | {error_cell} | {inputs_cell} |" ) else: lines.append( - f"| {subtask_id} | {task_id} | {updated_at} | — | — | {inputs_cell} |" + f"| {subtask_id} | {task_id} | {updated_at} | — | — | {error_cell} | {inputs_cell} |" ) _OUTPUT.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index f0eaed38..76105422 100644 --- a/scripts/trigger_modelling_e2e_sqs.py +++ b/scripts/trigger_modelling_e2e_sqs.py @@ -20,9 +20,10 @@ PORTFOLIO_ID: int = 796 SCENARIO_ID: int = 1268 SQS_QUEUE_NAME: str = "modelling_e2e-queue-dev" -# Number of postcodes to process this run (postcodes where all properties are -# already completed are skipped and do not count toward this limit). -POSTCODES_LIMIT: int = 1000 +# Max number of properties to process this run (cost cap). Postcodes are added +# whole, smallest-group-first, until adding the next would exceed this — so the +# actual count lands at or just under the limit. +PROPERTIES_LIMIT: int = 5000 # True → Lambda runs the full pipeline but skips all DB writes (safe for testing). DRY_RUN: bool = False @@ -94,21 +95,31 @@ def _completed_property_ids() -> set[int]: def main() -> None: postcode_map = _load_postcode_map() - completed = _completed_property_ids() - logger.info(f"{len(completed)} property IDs already completed — skipping") + # Pending filter disabled — re-run every property regardless of whether it + # already has a completed modelling_e2e sub_task for this scenario. batches: list[tuple[str, list[int]]] = [] for postcode, ids in postcode_map.items(): - pending = [pid for pid in ids if pid not in completed] - if pending: - batches.append((postcode, pending)) + if ids: + batches.append((postcode, ids)) - to_process = batches[:POSTCODES_LIMIT] + to_process: list[tuple[str, list[int]]] = [] + property_count = 0 + for postcode, ids in batches: + if property_count + len(ids) > PROPERTIES_LIMIT: + continue + to_process.append((postcode, ids)) + property_count += len(ids) if not to_process: logger.info("Nothing left to process.") return + logger.info( + f"selected {len(to_process)} postcodes / {property_count} properties " + f"(limit {PROPERTIES_LIMIT})" + ) + sqs: Any = cast( Any, boto3.client("sqs", region_name="eu-west-2") ) # pyright: ignore[reportUnknownMemberType] From 4fe59b8e78b3bc5407f88187f96b3e471b23534c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 07:42:34 +0000 Subject: [PATCH 15/18] Default RdSAP 21.0.1 wet_rooms_count to 0 (fix NOT-NULL violation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37 modelling_e2e properties failed on the 2026-06-23 run with `NotNullViolation: null value in column "wet_rooms_count" of relation "epc_property"`. Root cause: 21.0.1 lodges `wet_rooms_count` as Optional, and `from_rdsap_schema_21_0_1` passed it straight through (`wet_rooms_count=schema.wet_rooms_count`). A cert omitting it mapped to `EpcPropertyData.wet_rooms_count=None`. When a predicted EPC (which deep-copies a comparable template's EpcPropertyData) inherited that None and was persisted, it violated the `epc_property.wet_rooms_count` NOT-NULL column — and the calc's `wet_rooms_count > 0` check would also raise `TypeError` on None. Fix: coalesce to 0, matching every other mapper (RdSAP "not lodged" → the calc's minimum 1 wet room). Regression test added. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 5 ++++- .../epc/domain/tests/test_from_rdsap_schema.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2f2bd00b..1658383f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2150,7 +2150,10 @@ class EpcPropertyDataMapper: door_count=schema.door_count, habitable_rooms_count=schema.habitable_room_count, heated_rooms_count=schema.heated_room_count, - wet_rooms_count=schema.wet_rooms_count, + # 21.0.1 lodges wet_rooms_count as Optional; None violates the + # epc_property NOT-NULL column (and the calc's `> 0` check). Coalesce + # to 0 like every other mapper (RdSAP "not lodged" → calc minimum 1). + wet_rooms_count=schema.wet_rooms_count or 0, extensions_count=schema.extensions_count, open_chimneys_count=schema.open_chimneys_count, insulated_door_count=schema.insulated_door_count, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index e0c6b1ba..023f7c60 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -379,6 +379,22 @@ class TestFromRdSapSchema21_0_1: # --- general --- + def test_omitted_wet_rooms_count_defaults_to_zero_not_none(self) -> None: + # 21.0.1 lodges wet_rooms_count as Optional, so a cert that omits it + # mapped to EpcPropertyData.wet_rooms_count=None. That None then + # violated the epc_property.wet_rooms_count NOT-NULL constraint when a + # predicted EPC (which deep-copies a comparable template) was persisted, + # and would crash the calc's `wet_rooms_count > 0` check. 37 modelling_e2e + # properties failed on the 2026-06-23 run for this reason. Default to 0, + # matching every other mapper (RdSAP 0 → calc's Table-?? minimum 1 fan). + data = load("21_0_1.json") + data.pop("wet_rooms_count", None) + schema = from_dict(RdSapSchema21_0_1, data) + + result = EpcPropertyDataMapper.from_rdsap_schema_21_0_1(schema) + + assert result.wet_rooms_count == 0 + def test_uprn(self, result: EpcPropertyData) -> None: assert result.uprn == 12457 From f2ace566c3eb20811710843c3c65afbd7b7b6c4c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 07:54:07 +0000 Subject: [PATCH 16/18] ci: disable unit_tests.yml workflow (Actions minutes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment out the Docker-based unit-test workflow — it was consuming too many GitHub Actions minutes. Fully commented (rather than deleted) so it can be restored by uncommenting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/unit_tests.yml | 137 ++++++++++++++++--------------- 1 file changed, 70 insertions(+), 67 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 12d836d0..bd022a2a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,67 +1,70 @@ -name: Run unit tests - -on: - pull_request: - branches: - - "**" - - -jobs: - test-docker: - name: Tests (Docker) - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15 - env: - POSTGRES_USER: test - POSTGRES_PASSWORD: test - POSTGRES_DB: test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Build test image - run: docker build -f Dockerfile.test -t model-test . - - - name: Initialise database schema - run: | - docker run --rm \ - --network host \ - -e DB_HOST=localhost \ - -e DB_NAME=test \ - -e DB_USERNAME=test \ - -e DB_PASSWORD=test \ - -e DB_PORT=5432 \ - model-test python scripts/init_db.py - - - name: Run tests - run: | - docker run --rm \ - --network host \ - -e EPC_AUTH_TOKEN=${{ secrets.DEV_EPC_AUTH_TOKEN }} \ - -e OPEN_EPC_API_TOKEN=${{ secrets.DEV_OPEN_EPC_API_TOKEN }} \ - -e HUBSPOT_API_KEY=${{ secrets.HUBSPOT_API_KEY }} \ - -e AWS_ACCESS_KEY_ID=${{ secrets.DEV_AWS_ACCESS_KEY_ID }} \ - -e AWS_SECRET_ACCESS_KEY=${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} \ - -e AWS_DEFAULT_REGION=${{ secrets.DEV_AWS_REGION }} \ - -e DB_HOST=localhost \ - -e DB_NAME=test \ - -e DB_USERNAME=test \ - -e DB_PASSWORD=test \ - -e DB_PORT=5432 \ - model-test pytest -vv -m 'not integration' - - # The DDD rewrite (tests/) runs in its own workflow (ddd_tests.yml): its - # SQLModel table classes map to the same physical tables as the legacy - # backend models and share the one global SQLModel.metadata, so the two - # suites cannot be imported into the same pytest process. +# Temporarily disabled — this Docker-based suite was consuming too many GitHub +# Actions minutes. Uncomment to re-enable. +# +# name: Run unit tests +# +# on: +# pull_request: +# branches: +# - "**" +# +# +# jobs: +# test-docker: +# name: Tests (Docker) +# runs-on: ubuntu-latest +# +# services: +# postgres: +# image: postgres:15 +# env: +# POSTGRES_USER: test +# POSTGRES_PASSWORD: test +# POSTGRES_DB: test +# ports: +# - 5432:5432 +# options: >- +# --health-cmd pg_isready +# --health-interval 10s +# --health-timeout 5s +# --health-retries 5 +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# +# - name: Build test image +# run: docker build -f Dockerfile.test -t model-test . +# +# - name: Initialise database schema +# run: | +# docker run --rm \ +# --network host \ +# -e DB_HOST=localhost \ +# -e DB_NAME=test \ +# -e DB_USERNAME=test \ +# -e DB_PASSWORD=test \ +# -e DB_PORT=5432 \ +# model-test python scripts/init_db.py +# +# - name: Run tests +# run: | +# docker run --rm \ +# --network host \ +# -e EPC_AUTH_TOKEN=${{ secrets.DEV_EPC_AUTH_TOKEN }} \ +# -e OPEN_EPC_API_TOKEN=${{ secrets.DEV_OPEN_EPC_API_TOKEN }} \ +# -e HUBSPOT_API_KEY=${{ secrets.HUBSPOT_API_KEY }} \ +# -e AWS_ACCESS_KEY_ID=${{ secrets.DEV_AWS_ACCESS_KEY_ID }} \ +# -e AWS_SECRET_ACCESS_KEY=${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} \ +# -e AWS_DEFAULT_REGION=${{ secrets.DEV_AWS_REGION }} \ +# -e DB_HOST=localhost \ +# -e DB_NAME=test \ +# -e DB_USERNAME=test \ +# -e DB_PASSWORD=test \ +# -e DB_PORT=5432 \ +# model-test pytest -vv -m 'not integration' +# +# # The DDD rewrite (tests/) runs in its own workflow (ddd_tests.yml): its +# # SQLModel table classes map to the same physical tables as the legacy +# # backend models and share the one global SQLModel.metadata, so the two +# # suites cannot be imported into the same pytest process. From c49682658a73d018038fb4fe161e63f9b1a0995a Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 07:56:42 +0000 Subject: [PATCH 17/18] ci: disable integration/deploy/protect workflows (Actions minutes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment out the remaining workflows to cut GitHub Actions usage, per request: - integration_tests.yml — rebaselining integration suite (PRs to main) - deploy_fastapi_backend.yml — FastAPI backend deploy (push to dev/prod); deploys must be run manually via `sls deploy` while disabled - protect_releases.yml — main→dev PR-source guardrail Fully commented (not deleted) so each restores by uncommenting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy_fastapi_backend.yml | 343 ++++++++++--------- .github/workflows/integration_tests.yml | 73 ++-- .github/workflows/protect_releases.yml | 37 +- 3 files changed, 231 insertions(+), 222 deletions(-) diff --git a/.github/workflows/deploy_fastapi_backend.yml b/.github/workflows/deploy_fastapi_backend.yml index 6ebb6cef..645d6bc1 100644 --- a/.github/workflows/deploy_fastapi_backend.yml +++ b/.github/workflows/deploy_fastapi_backend.yml @@ -1,170 +1,173 @@ -name: Fast Api Backend Deploy - -on: - push: - branches: [ dev, prod ] - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.10.12 - - - name: Install Serverless and plugins - run: | - npm install -g serverless@^3.38.0 - npm install -g serverless-domain-manager@^7.3.8 - npm install -g serverless-python-requirements - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - - - name: AWS credentials for dev - if: github.ref == 'refs/heads/dev' - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} - aws-region: eu-west-2 - - - name: AWS credentials for prod - if: github.ref == 'refs/heads/prod' - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} - aws-region: eu-west-2 - - - name: Set domain name - id: set_domain - run: echo "::set-output name=domain::${{ secrets[format('{0}_DOMAIN_NAME', github.ref_name)] }}" - - - name: Set EPC auth token - id: set_auth_token - run: echo "::set-output name=auth_token::${{ secrets[format('{0}_EPC_AUTH_TOKEN', github.ref_name)] }}" - - - name: Set Open EPC API token - id: set_open_epc_token - run: echo "::set-output name=open_epc_token::${{ secrets[format('{0}_OPEN_EPC_API_TOKEN', github.ref_name)] }}" - - # Store port, name and host in github secrets - - name: Set DB credentials - id: set_db_credentials - run: | - echo "::set-output name=db_host::${{ secrets[format('{0}_DB_HOST', github.ref_name)] }}" - echo "::set-output name=db_port::${{ secrets[format('{0}_DB_PORT', github.ref_name)] }}" - echo "::set-output name=db_name::${{ secrets[format('{0}_DB_NAME', github.ref_name)] }}" - - - name: Set ECR credentials - id: set_ecr_credentials - run: | - echo "::set-output name=ecr_uri::${{ secrets[format('{0}_ECR_URI', github.ref_name)] }}" - - - name: Set Secrets - id: set_api_secrets - run: | - echo "::set-output name=sap_predictions_bucket::${{ secrets[format('{0}_SAP_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=carbon_predictions_bucket::${{ secrets[format('{0}_CARBON_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=heat_predictions_bucket::${{ secrets[format('{0}_HEAT_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=lighting_cost_predictions_bucket::${{ secrets[format('{0}_LIGHTING_COST_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=heating_cost_predictions_bucket::${{ secrets[format('{0}_HEATING_COST_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=hot_water_cost_predictions_bucket::${{ secrets[format('{0}_HOT_WATER_COST_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=heating_kwh_predictions_bucket::${{ secrets[format('{0}_HEATING_KWH_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=hotwater_kwh_predictions_bucket::${{ secrets[format('{0}_HOTWATER_KWH_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=energy_asessments_bucket::${{ secrets[format('{0}_ENERGY_ASSESSMENTS_BUCKET', github.ref_name)] }}" - echo "::set-output name=google_solar_api_key::${{ secrets[format('{0}_GOOGLE_SOLAR_API_KEY', github.ref_name)] }}" - echo "::set-output name=sap_baseline_predictions_bucket::${{ secrets[format('{0}_SAP_BASELINE_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=carbon_baseline_predictions_bucket::${{ secrets[format('{0}_CARBON_BASELINE_PREDICTIONS_BUCKET', github.ref_name)] }}" - echo "::set-output name=heat_baseline_predictions_bucket::${{ secrets[format('{0}_HEAT_BASELINE_PREDICTIONS_BUCKET', github.ref_name)] }}" - - - name: Setup Docker - uses: docker/setup-buildx-action@v1 - - # - name: Setup Docker Buildx - # run: | - # docker buildx create --use - - - name: Build Docker Image For Engine - run: | - docker buildx build \ - --platform linux/amd64 \ - --provenance=false \ - --output=type=docker \ - -t fastapi-lambda-image:${{ github.sha }} \ - -f backend/docker/engine.Dockerfile \ - . - - - name: Login to ECR - run: | - aws ecr get-login-password --region eu-west-2 | docker login --username AWS --password-stdin ${{ steps.set_ecr_credentials.outputs.ecr_uri }} - - - name: Tag and Push Docker Image to ECR - run: | - docker tag fastapi-lambda-image:${{ github.sha }} ${{ steps.set_ecr_credentials.outputs.ecr_uri }}:${{ github.sha }} - docker push ${{ steps.set_ecr_credentials.outputs.ecr_uri }}:${{ github.sha }} - - - name: Deploy to AWS Lambda via Serverless - env: - API_KEY: ${{ secrets.FASTAPI_API_KEY }} - ENVIRONMENT: ${{ github.ref_name }} - SECRET_KEY: ${{ secrets.NEXTAUTH_SECRET }} - PLAN_TRIGGER_BUCKET: 'retrofit-plan-inputs-${{ github.ref_name }}' - DATA_BUCKET: 'retrofit-data-${{ github.ref_name }}' - PREDICTIONS_BUCKET: 'retrofit-sap-predictions-${{ github.ref_name }}' - SAP_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.sap_predictions_bucket }} - CARBON_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.carbon_predictions_bucket }} - HEAT_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heat_predictions_bucket }} - LIGHTING_COST_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.lighting_cost_predictions_bucket }} - HEATING_COST_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heating_cost_predictions_bucket }} - HOT_WATER_COST_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.hot_water_cost_predictions_bucket }} - HEATING_KWH_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heating_kwh_predictions_bucket }} - HOTWATER_KWH_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.hotwater_kwh_predictions_bucket }} - ENERGY_ASSESSMENTS_BUCKET: ${{ steps.set_api_secrets.outputs.energy_asessments_bucket }} - GOOGLE_SOLAR_API_KEY: ${{ steps.set_api_secrets.outputs.google_solar_api_key }} - DOMAIN_NAME: ${{ steps.set_domain.outputs.domain }} - EPC_AUTH_TOKEN: ${{ steps.set_auth_token.outputs.auth_token }} - OPEN_EPC_API_TOKEN: ${{ steps.set_open_epc_token.outputs.open_epc_token }} - DB_HOST: ${{ steps.set_db_credentials.outputs.db_host }} - DB_PORT: ${{ steps.set_db_credentials.outputs.db_port }} - DB_NAME: ${{ steps.set_db_credentials.outputs.db_name }} - ECR_URI: ${{ steps.set_ecr_credentials.outputs.ecr_uri }} - GITHUB_SHA: ${{ github.sha }} - SAP_BASELINE_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.sap_baseline_predictions_bucket }} - CARBON_BASELINE_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.carbon_baseline_predictions_bucket }} - HEAT_BASELINE_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heat_baseline_predictions_bucket }} - run: | - # Fetch database credentials from AWS Secrets Manager - SECRET_VALUE=$(aws secretsmanager get-secret-value --secret-id ${{ github.ref_name }}/assessment_model/db_credentials --query SecretString) - DB_USERNAME=$(echo "$SECRET_VALUE" | jq -r '. | fromjson | .db_assessment_model_username') - DB_PASSWORD=$(echo "$SECRET_VALUE" | jq -r '. | fromjson | .db_assessment_model_password') - - # Set the database credentials as environment variables - export DB_USERNAME - export DB_PASSWORD - - # Deploy to AWS Lambda via Serverless - sls deploy --stage ${{ github.ref_name }} --verbose - - - name: Smoke test deployed /health - env: - EXPECTED_SHA: ${{ github.sha }} - HEALTH_URL: https://api.${{ steps.set_domain.outputs.domain }}/health - run: | - set -euo pipefail - echo "Probing $HEALTH_URL" - RESPONSE=$(curl -fsSL --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$HEALTH_URL") - echo "Response: $RESPONSE" - ACTUAL_SHA=$(echo "$RESPONSE" | jq -r '.sha') - if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then - echo "::error::SHA mismatch. expected=$EXPECTED_SHA actual=$ACTUAL_SHA" - exit 1 - fi - echo "Health check passed. sha=$ACTUAL_SHA" +# Temporarily disabled — the FastAPI backend deploy pipeline (deploys must be run manually via `sls deploy` while disabled). +# Commented out to cut GitHub Actions minutes; uncomment to re-enable. +# +# name: Fast Api Backend Deploy +# +# on: +# push: +# branches: [ dev, prod ] +# +# jobs: +# deploy: +# runs-on: ubuntu-latest +# +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# +# - name: Set up Python +# uses: actions/setup-python@v2 +# with: +# python-version: 3.10.12 +# +# - name: Install Serverless and plugins +# run: | +# npm install -g serverless@^3.38.0 +# npm install -g serverless-domain-manager@^7.3.8 +# npm install -g serverless-python-requirements +# +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip +# +# - name: AWS credentials for dev +# if: github.ref == 'refs/heads/dev' +# uses: aws-actions/configure-aws-credentials@v1 +# with: +# aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} +# aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} +# aws-region: eu-west-2 +# +# - name: AWS credentials for prod +# if: github.ref == 'refs/heads/prod' +# uses: aws-actions/configure-aws-credentials@v1 +# with: +# aws-access-key-id: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }} +# aws-secret-access-key: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} +# aws-region: eu-west-2 +# +# - name: Set domain name +# id: set_domain +# run: echo "::set-output name=domain::${{ secrets[format('{0}_DOMAIN_NAME', github.ref_name)] }}" +# +# - name: Set EPC auth token +# id: set_auth_token +# run: echo "::set-output name=auth_token::${{ secrets[format('{0}_EPC_AUTH_TOKEN', github.ref_name)] }}" +# +# - name: Set Open EPC API token +# id: set_open_epc_token +# run: echo "::set-output name=open_epc_token::${{ secrets[format('{0}_OPEN_EPC_API_TOKEN', github.ref_name)] }}" +# +# # Store port, name and host in github secrets +# - name: Set DB credentials +# id: set_db_credentials +# run: | +# echo "::set-output name=db_host::${{ secrets[format('{0}_DB_HOST', github.ref_name)] }}" +# echo "::set-output name=db_port::${{ secrets[format('{0}_DB_PORT', github.ref_name)] }}" +# echo "::set-output name=db_name::${{ secrets[format('{0}_DB_NAME', github.ref_name)] }}" +# +# - name: Set ECR credentials +# id: set_ecr_credentials +# run: | +# echo "::set-output name=ecr_uri::${{ secrets[format('{0}_ECR_URI', github.ref_name)] }}" +# +# - name: Set Secrets +# id: set_api_secrets +# run: | +# echo "::set-output name=sap_predictions_bucket::${{ secrets[format('{0}_SAP_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=carbon_predictions_bucket::${{ secrets[format('{0}_CARBON_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=heat_predictions_bucket::${{ secrets[format('{0}_HEAT_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=lighting_cost_predictions_bucket::${{ secrets[format('{0}_LIGHTING_COST_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=heating_cost_predictions_bucket::${{ secrets[format('{0}_HEATING_COST_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=hot_water_cost_predictions_bucket::${{ secrets[format('{0}_HOT_WATER_COST_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=heating_kwh_predictions_bucket::${{ secrets[format('{0}_HEATING_KWH_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=hotwater_kwh_predictions_bucket::${{ secrets[format('{0}_HOTWATER_KWH_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=energy_asessments_bucket::${{ secrets[format('{0}_ENERGY_ASSESSMENTS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=google_solar_api_key::${{ secrets[format('{0}_GOOGLE_SOLAR_API_KEY', github.ref_name)] }}" +# echo "::set-output name=sap_baseline_predictions_bucket::${{ secrets[format('{0}_SAP_BASELINE_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=carbon_baseline_predictions_bucket::${{ secrets[format('{0}_CARBON_BASELINE_PREDICTIONS_BUCKET', github.ref_name)] }}" +# echo "::set-output name=heat_baseline_predictions_bucket::${{ secrets[format('{0}_HEAT_BASELINE_PREDICTIONS_BUCKET', github.ref_name)] }}" +# +# - name: Setup Docker +# uses: docker/setup-buildx-action@v1 +# +# # - name: Setup Docker Buildx +# # run: | +# # docker buildx create --use +# +# - name: Build Docker Image For Engine +# run: | +# docker buildx build \ +# --platform linux/amd64 \ +# --provenance=false \ +# --output=type=docker \ +# -t fastapi-lambda-image:${{ github.sha }} \ +# -f backend/docker/engine.Dockerfile \ +# . +# +# - name: Login to ECR +# run: | +# aws ecr get-login-password --region eu-west-2 | docker login --username AWS --password-stdin ${{ steps.set_ecr_credentials.outputs.ecr_uri }} +# +# - name: Tag and Push Docker Image to ECR +# run: | +# docker tag fastapi-lambda-image:${{ github.sha }} ${{ steps.set_ecr_credentials.outputs.ecr_uri }}:${{ github.sha }} +# docker push ${{ steps.set_ecr_credentials.outputs.ecr_uri }}:${{ github.sha }} +# +# - name: Deploy to AWS Lambda via Serverless +# env: +# API_KEY: ${{ secrets.FASTAPI_API_KEY }} +# ENVIRONMENT: ${{ github.ref_name }} +# SECRET_KEY: ${{ secrets.NEXTAUTH_SECRET }} +# PLAN_TRIGGER_BUCKET: 'retrofit-plan-inputs-${{ github.ref_name }}' +# DATA_BUCKET: 'retrofit-data-${{ github.ref_name }}' +# PREDICTIONS_BUCKET: 'retrofit-sap-predictions-${{ github.ref_name }}' +# SAP_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.sap_predictions_bucket }} +# CARBON_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.carbon_predictions_bucket }} +# HEAT_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heat_predictions_bucket }} +# LIGHTING_COST_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.lighting_cost_predictions_bucket }} +# HEATING_COST_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heating_cost_predictions_bucket }} +# HOT_WATER_COST_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.hot_water_cost_predictions_bucket }} +# HEATING_KWH_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heating_kwh_predictions_bucket }} +# HOTWATER_KWH_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.hotwater_kwh_predictions_bucket }} +# ENERGY_ASSESSMENTS_BUCKET: ${{ steps.set_api_secrets.outputs.energy_asessments_bucket }} +# GOOGLE_SOLAR_API_KEY: ${{ steps.set_api_secrets.outputs.google_solar_api_key }} +# DOMAIN_NAME: ${{ steps.set_domain.outputs.domain }} +# EPC_AUTH_TOKEN: ${{ steps.set_auth_token.outputs.auth_token }} +# OPEN_EPC_API_TOKEN: ${{ steps.set_open_epc_token.outputs.open_epc_token }} +# DB_HOST: ${{ steps.set_db_credentials.outputs.db_host }} +# DB_PORT: ${{ steps.set_db_credentials.outputs.db_port }} +# DB_NAME: ${{ steps.set_db_credentials.outputs.db_name }} +# ECR_URI: ${{ steps.set_ecr_credentials.outputs.ecr_uri }} +# GITHUB_SHA: ${{ github.sha }} +# SAP_BASELINE_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.sap_baseline_predictions_bucket }} +# CARBON_BASELINE_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.carbon_baseline_predictions_bucket }} +# HEAT_BASELINE_PREDICTIONS_BUCKET: ${{ steps.set_api_secrets.outputs.heat_baseline_predictions_bucket }} +# run: | +# # Fetch database credentials from AWS Secrets Manager +# SECRET_VALUE=$(aws secretsmanager get-secret-value --secret-id ${{ github.ref_name }}/assessment_model/db_credentials --query SecretString) +# DB_USERNAME=$(echo "$SECRET_VALUE" | jq -r '. | fromjson | .db_assessment_model_username') +# DB_PASSWORD=$(echo "$SECRET_VALUE" | jq -r '. | fromjson | .db_assessment_model_password') +# +# # Set the database credentials as environment variables +# export DB_USERNAME +# export DB_PASSWORD +# +# # Deploy to AWS Lambda via Serverless +# sls deploy --stage ${{ github.ref_name }} --verbose +# +# - name: Smoke test deployed /health +# env: +# EXPECTED_SHA: ${{ github.sha }} +# HEALTH_URL: https://api.${{ steps.set_domain.outputs.domain }}/health +# run: | +# set -euo pipefail +# echo "Probing $HEALTH_URL" +# RESPONSE=$(curl -fsSL --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$HEALTH_URL") +# echo "Response: $RESPONSE" +# ACTUAL_SHA=$(echo "$RESPONSE" | jq -r '.sha') +# if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then +# echo "::error::SHA mismatch. expected=$EXPECTED_SHA actual=$ACTUAL_SHA" +# exit 1 +# fi +# echo "Health check passed. sha=$ACTUAL_SHA" diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 6093e249..374bf94e 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -1,35 +1,38 @@ -name: Rebaselining Integration Test - -on: - pull_request: - branches: - - main - -jobs: - rebaselining-integration-test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install tox via Makefile - run: | - make setup - - - name: Configure AWS credentials for dev - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} - aws-region: eu-west-2 - - - name: Run only rebaselining integration test - env: - EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }} - run: | - make test ARGS="-m integration" +# Temporarily disabled — the rebaselining integration suite. +# Commented out to cut GitHub Actions minutes; uncomment to re-enable. +# +# name: Rebaselining Integration Test +# +# on: +# pull_request: +# branches: +# - main +# +# jobs: +# rebaselining-integration-test: +# runs-on: ubuntu-latest +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# +# - name: Set up Python 3.11 +# uses: actions/setup-python@v4 +# with: +# python-version: '3.11' +# +# - name: Install tox via Makefile +# run: | +# make setup +# +# - name: Configure AWS credentials for dev +# uses: aws-actions/configure-aws-credentials@v1 +# with: +# aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} +# aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} +# aws-region: eu-west-2 +# +# - name: Run only rebaselining integration test +# env: +# EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }} +# run: | +# make test ARGS="-m integration" diff --git a/.github/workflows/protect_releases.yml b/.github/workflows/protect_releases.yml index cbd08e2f..7c8c7258 100644 --- a/.github/workflows/protect_releases.yml +++ b/.github/workflows/protect_releases.yml @@ -1,17 +1,20 @@ -name: Restrict PR source - -on: - pull_request: - branches: - - dev - -jobs: - check-source-branch: - runs-on: ubuntu-latest - steps: - - name: Fail if PR is not from main - run: | - if [[ "${{ github.head_ref }}" != "main" ]]; then - echo "Only PRs from main are allowed into dev" - exit 1 - fi \ No newline at end of file +# Temporarily disabled — the main→dev PR-source guardrail. +# Commented out to cut GitHub Actions minutes; uncomment to re-enable. +# +# name: Restrict PR source +# +# on: +# pull_request: +# branches: +# - dev +# +# jobs: +# check-source-branch: +# runs-on: ubuntu-latest +# steps: +# - name: Fail if PR is not from main +# run: | +# if [[ "${{ github.head_ref }}" != "main" ]]; then +# echo "Only PRs from main are allowed into dev" +# exit 1 +# fi From 80d617aa5b1b00c4e2454e046dcdb215078de9aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 17:55:53 +0000 Subject: [PATCH 18/18] modelling: glazing overlay models draught-proofing + frame-factor re-lodge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fitting sealed glazing units changes two things beyond the pane's U/g that the cascade reads, which the overlay didn't model — leaving the double/secondary before→after pins ~0.7 SAP short (xfail): 1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes they replace, re-lodging the dwelling-level `percent_draughtproofed` (cert 001431: 84 → 100). The §2 cascade reads that dwelling-level value, so the overlay now carries it. `_recompute_percent_draughtproofed` anchors on the lodged before-% — `after = round((round(before%/100 × N) + flips) / N × 100)`, N = openable windows (vertical + roof) + doors, flips = upgraded panes that were not draught-proofed — so it's robust to incomplete window extraction (unchanged openings are already in the aggregate). ~0.3 SAP. 2. Frame factor (§6 solar gains). A replacement unit re-lodges its own FF=0.70, overriding the pane it replaced — the two "single glazing, known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay` now carries `frame_factor`, written flat onto the window. ~0.4 SAP. Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor` new fields; `apply_simulations` / `_fold_window` write them; the glazing generator computes both from the upgraded set and cert 001431's after. Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after` — both now pin SAP/CO2/PE to the relodged after within tolerance. Updates the two `test_glazing_recommendation` overlay expectations for the new `frame_factor`. 96 modelling tests pass; zero new pyright errors. Co-Authored-By: Claude Opus 4.8 --- .../generators/glazing_recommendation.py | 67 ++++++++++++++++++- .../modelling/scoring/overlay_applicator.py | 10 ++- domain/modelling/simulation.py | 11 ++- .../modelling/test_elmhurst_cascade_pins.py | 29 +++----- .../modelling/test_glazing_recommendation.py | 16 +++-- 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py index 897b011c..53a77bac 100644 --- a/domain/modelling/generators/glazing_recommendation.py +++ b/domain/modelling/generators/glazing_recommendation.py @@ -21,7 +21,7 @@ scoring (ADR-0016). from dataclasses import dataclass from typing import Final, Optional -from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation @@ -48,6 +48,7 @@ class _GlazingTarget: glazing_type: int u_value: float solar_transmittance: float + frame_factor: float # Unrestricted: replace the units with double glazing (gt=5 "Double post 2022"; @@ -58,6 +59,10 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget( glazing_type=5, u_value=1.40, solar_transmittance=0.72, + # Replacement double units re-lodge a standard FF=0.70 (cert 001431's + # after), overriding the panes they replace (e.g. FF 1.00 / 0.50 on the + # "single glazing, known data" windows) — feeds §6 solar gains. + frame_factor=0.70, ) # Protected (conservation/listed/heritage): fit an internal secondary pane # (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges; @@ -70,9 +75,61 @@ _SECONDARY: Final[_GlazingTarget] = _GlazingTarget( glazing_type=11, u_value=2.90, solar_transmittance=0.85, + frame_factor=0.70, # cert 001431's after re-lodges FF=0.70. ) +def _is_draught_proofed(window: SapWindow) -> bool: + """`SapWindow.draught_proofed` is `Union[bool, str]` (bool from the + site-notes mapper, the string "true"/"false" from the API). Normalise + to a bool.""" + flag = window.draught_proofed + if isinstance(flag, bool): + return flag + return flag.strip().lower() in {"true", "yes"} + + +def _recompute_percent_draughtproofed( + epc: EpcPropertyData, upgraded_indices: tuple[int, ...] +) -> Optional[int]: + """RdSAP 10 §8.1 draught-proofing percentage after a glazing upgrade. + + §8.1: "[(number of draughtproofed openable windows & doors) / (total + number of openable windows & doors)] × 100", as an integer. Sealed + double/secondary units are draught-proofed, so every upgraded window + that was NOT draught-proofed flips into the numerator. + + Anchored on the lodged dwelling-level `percent_draughtproofed` (the + value the §2 cascade reads) rather than re-deriving the before-count + from per-window flags: the unchanged openings are already folded into + that aggregate, so this is robust to incomplete window extraction. + + d0 = round(before% / 100 × N) # draught-proofed before + after = round((d0 + flips) / N × 100) # RdSAP 10 §8.1, integer + + N counts every openable window (vertical + roof) plus external doors. + Returns None when no before% is lodged or there are no openings. + """ + before = epc.percent_draughtproofed + if before is None: + return None + n_openings = ( + len(epc.sap_windows) + + len(epc.sap_roof_windows or []) + + (epc.door_count or 0) + ) + if n_openings <= 0: + return None + flips = sum( + 1 + for index in upgraded_indices + if not _is_draught_proofed(epc.sap_windows[index]) + ) + draught_proofed_before = round(before / 100 * n_openings) + after = round((draught_proofed_before + flips) / n_openings * 100) + return max(0, min(100, after)) + + def recommend_glazing( epc: EpcPropertyData, products: ProductRepository, @@ -99,9 +156,15 @@ def recommend_glazing( glazing_type=target.glazing_type, u_value=target.u_value, solar_transmittance=target.solar_transmittance, + frame_factor=target.frame_factor, ) for index in single_indices - } + }, + # Sealed units draught-proof the panes they replace — RdSAP 10 + # §8.1 re-lodges the dwelling's percentage (cert 001431: 84 → 100). + percent_draughtproofed=_recompute_percent_draughtproofed( + epc, single_indices + ), ) cost = Cost( total=len(single_indices) * product.unit_cost_per_m2, diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 848f1a8f..93875bbf 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -106,6 +106,8 @@ def apply_simulations( result.property_type = simulation.property_type if simulation.built_form is not None: result.built_form = simulation.built_form + if simulation.percent_draughtproofed is not None: + result.percent_draughtproofed = simulation.percent_draughtproofed return result @@ -218,11 +220,13 @@ def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None: def _fold_window(window: SapWindow, overlay: WindowOverlay) -> None: """Write a `WindowOverlay`'s non-``None`` fields onto a (copied) window: - ``glazing_type`` flat on the window, ``u_value`` / ``solar_transmittance`` - into its `WindowTransmissionDetails` (where the cascade reads them), starting - a fresh one when the window lodged none.""" + ``glazing_type`` and ``frame_factor`` flat on the window, ``u_value`` / + ``solar_transmittance`` into its `WindowTransmissionDetails` (where the + cascade reads them), starting a fresh one when the window lodged none.""" if overlay.glazing_type is not None: window.glazing_type = overlay.glazing_type + if overlay.frame_factor is not None: + window.frame_factor = overlay.frame_factor if overlay.u_value is None and overlay.solar_transmittance is None: return details: Optional[WindowTransmissionDetails] = window.window_transmission_details diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 77ce2537..2f6fc94d 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -77,12 +77,16 @@ class WindowOverlay: are written into the window's `WindowTransmissionDetails` — where the calculator reads heat loss and solar gain from — because our calculator consumes the lodged values directly rather than deriving them from - `glazing_type`. A `None` field means "leave the baseline value unchanged". + `glazing_type`. `frame_factor` is written flat on the window (the §6 + solar-gain area factor); a replacement unit re-lodges its own FF, which + can differ from the pane it replaced. A `None` field means "leave the + baseline value unchanged". """ glazing_type: Optional[int] = None u_value: Optional[float] = None solar_transmittance: Optional[float] = None + frame_factor: Optional[float] = None @dataclass(frozen=True) @@ -263,3 +267,8 @@ class EpcSimulation: # built form (property_type drives party-wall heat loss + measure eligibility). property_type: Optional[str] = None built_form: Optional[str] = None + # Whole-dwelling RdSAP 10 §8.1 draught-proofing percentage, when a + # Measure changes it (e.g. glazing: sealed units draught-proof the + # panes they replace). The §2 cascade reads this dwelling-level value, + # so the overlay sets it directly. `None` leaves the baseline's value. + percent_draughtproofed: Optional[int] = None diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 19186eae..c99f7b6f 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -492,10 +492,11 @@ def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None: def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None: - # The full-SAP pin below is xfail (draught-proofing coupling), but the - # overlay's actual job — turning every single-glazed window into the - # relodged spec — is deterministic and must hold exactly: it proves the - # generator detects BOTH single-glazing codes (1 and 15) on the real cert. + # The full-SAP pin below now passes (the draught-proofing + frame-factor + # coupling is modelled), but this narrower pin isolates the overlay's actual + # job — turning every single-glazed window into the relodged spec — which is + # deterministic and must hold exactly: it proves the generator detects BOTH + # single-glazing codes (1 and 15) on the real cert. # Arrange before: EpcPropertyData = parse_recommendation_summary( "double_glazing_001431_before.pdf" @@ -528,24 +529,13 @@ def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None: assert _window_spec(applied) == _window_spec(after) -_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = ( - "Blocked on the glazing measure's draught-proofing coupling. The window " - "U/g overlay reproduces the after's 14 windows EXACTLY (all four single-" - "glazed panes — codes 1 and 15 — become the relodged double/secondary " - "spec). The residual ~0.7 SAP is a secondary effect the overlay does not " - "model: replacing the single-glazed (lodged draught_proofed=No) windows " - "with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and " - "lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet " - "isolated. Flips green once the glazing overlay propagates draught-proofing " - "(and the residual fabric coupling is modelled)." -) - - -@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON) def test_double_glazing_overlay_reproduces_the_relodged_after() -> None: # Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15, # "single glazing, known data"); the after re-lodges every one as double - # (gt=5, U=1.40, g=0.72). + # (gt=5, U=1.40, g=0.72). The overlay now also models the two secondary + # effects of fitting sealed units: RdSAP 10 §8.1 draught-proofing + # 84 → 100 (`percent_draughtproofed`) and the re-lodged FF=0.70 on the + # panes that lodged FF 1.00 / 0.50 — so the full-SAP pin closes. before: EpcPropertyData = parse_recommendation_summary( "double_glazing_001431_before.pdf" ) @@ -561,7 +551,6 @@ def test_double_glazing_overlay_reproduces_the_relodged_after() -> None: ) -@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON) def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None: # Arrange — a planning protection forces secondary glazing; the after # re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85). diff --git a/tests/domain/modelling/test_glazing_recommendation.py b/tests/domain/modelling/test_glazing_recommendation.py index 993052f0..52063b5e 100644 --- a/tests/domain/modelling/test_glazing_recommendation.py +++ b/tests/domain/modelling/test_glazing_recommendation.py @@ -67,8 +67,12 @@ def test_single_glazed_dwelling_yields_a_double_glazing_recommendation() -> None option = recommendation.options[0] assert option.measure_type == "double_glazing" assert dict(option.overlay.windows) == { - 0: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), - 2: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), + 0: WindowOverlay( + glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70 + ), + 2: WindowOverlay( + glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70 + ), } @@ -121,6 +125,10 @@ def test_planning_protection_picks_secondary_glazing_over_double() -> None: option = recommendation.options[0] assert option.measure_type == "secondary_glazing" assert dict(option.overlay.windows) == { - 0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), - 2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), + 0: WindowOverlay( + glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70 + ), + 2: WindowOverlay( + glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70 + ), }