From 9b0c590bf8f7a1ab4e45adb5410746fe62368ec2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 13:16:44 +0000 Subject: [PATCH 1/7] =?UTF-8?q?fix(heat-transmission):=20bill=20a=20ground?= =?UTF-8?q?-floor=20flat's=20ground=20floor=20(RdSAP=2010=20=C2=A73.12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat floor-exposure heuristic keys on dwelling_type: a flat defaults to has_exposed_floor=False (assuming a heated dwelling below). The Elmhurst Summary path lodges a ground-floor flat's vertical position as a "Ground floor" floor_type rather than the API floor_heat_loss=1 exposed code, and the mapper can label such a flat "Top-floor flat" — so the cascade dropped the ground floor entirely (a ground floor is in contact with the ground and carries heat loss). Treat a "ground floor" floor_type as a heat-loss floor, overriding the dwelling-level suppression upward — mirroring the existing "another dwelling below" party override downward. Worksheet-validated to 1e-4 on simulated case 45 (a ground-floor flat the mapper labelled "Top-floor flat"): floor (28a) 0 -> 25.38 W/K, fabric (33) 75.63 -> 101.0104, HTC (39) 112.93 -> 145.3579, all matching the P960 exactly; SAP 67.81 -> 62.52. RdSAP-21.0.1 corpus within-0.5 69.5% -> 69.7% (MAE 0.859 -> 0.854). Floors ratcheted. Pinned in test_heat_transmission (ground-floor billed + party-floor suppressed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../worksheet/heat_transmission.py | 11 ++++ .../worksheet/test_heat_transmission.py | 64 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 10 ++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index d12b7da9..8140cec2 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -1105,6 +1105,16 @@ def heat_transmission_from_cert( # lodgement is authoritative. Mirrors the roof's "another dwelling # above" override above. Cert 2115-4121-4711-9361-3686. part_floor_is_party = "another dwelling below" in (part.floor_type or "").lower() + # A part whose floor_type is a GROUND floor sits in contact with the + # ground (RdSAP 10 §3.12) and is therefore a heat-loss floor, even when + # the dwelling-level flat heuristic (`_dwelling_exposure`) defaults a + # flat to has_exposed_floor=False. The Elmhurst Summary path lodges a + # ground-floor flat's position as a "Ground floor" floor_type (not the + # API floor_heat_loss=1 exposed code), so without this signal the + # cascade dropped its ground floor entirely — simulated case 45 (a + # ground-floor flat the mapper labelled "Top-floor flat"): worksheet + # (28a) = 47.0 × 0.54 = 25.38 W/K billed as 0, over-rating by +7 SAP. + part_floor_is_ground = "ground floor" in (part.floor_type or "").lower() # A floor lodged as a heat-loss floor — *exposed* (API # floor_heat_loss=1 → `is_exposed_floor`, "an exposed floor if there # is an open space below") or *above a partially heated space* (API @@ -1117,6 +1127,7 @@ def heat_transmission_from_cert( # the "another dwelling below" party signal overrides it downward. part_has_exposed_floor = ( exposure.has_exposed_floor or is_exposed_floor or is_above_partial + or part_floor_is_ground ) and not part_floor_is_party floor_area_total = _round_half_up( geom["ground_floor_area_m2"] if part_has_exposed_floor else 0.0, diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index d841746e..0d53867b 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -157,6 +157,70 @@ def test_mixed_flat_pitched_roof_does_not_contaminate_pitched_u_value() -> None: assert abs(result.roof_w_per_k - 44.6) <= 2.0 +def test_ground_floor_flat_bills_floor_despite_flat_dwelling_type() -> None: + # Arrange — a ground-floor flat whose dwelling_type the mapper labelled + # "Top-floor flat" (so the dwelling-level exposure heuristic + # `_dwelling_exposure` suppresses the floor on the assumption a heated + # dwelling sits below), but whose building part lodges a "Ground floor" + # floor_type. A ground floor is in contact with the ground (RdSAP 10 + # §3.12) -> heat-loss floor. The Elmhurst Summary path lodges this as a + # "Ground floor" floor_type (not the API floor_heat_loss=1 exposed code), + # so without the per-part ground signal the cascade dropped the floor. + # Worksheet-validated by simulated case 45: (28a) = 47.0 × U=0.54 = 25.38 + # W/K, billed as 0 before this fix (+7 SAP). + ground = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + floor_type="Ground floor", + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=47.0, room_height_m=2.4, + heat_loss_perimeter_m=15.8, party_wall_length_m=0.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=47.0, country_code="ENG", + dwelling_type="Top-floor flat", property_type="Flat", + sap_building_parts=[ground], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — the ground floor carries heat loss (≈ 47 × 0.54), not 0. + assert result.floor_w_per_k > 20.0 + + +def test_top_floor_flat_with_party_floor_stays_suppressed() -> None: + # Arrange — the contrast: a flat lodging "(another dwelling below)" sits + # over a heated dwelling, so its floor is a party floor with no heat loss + # (RdSAP 10 §3). The ground-floor override must NOT fire — proving the + # discriminator is the floor_type, not the flat label. + party = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + floor_type="To another dwelling below", + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=47.0, room_height_m=2.4, + heat_loss_perimeter_m=15.8, party_wall_length_m=0.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=47.0, country_code="ENG", + dwelling_type="Top-floor flat", property_type="Flat", + sap_building_parts=[party], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — party floor, no heat loss. + assert result.floor_w_per_k == 0.0 + + def test_part_geometry_floorless_part_honours_full_key_contract() -> None: # Arrange — a building part lodged with NO sap_floor_dimensions (e.g. # a party-wall-only or RR-only extension; observed on 5 certs in a diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 9aed0f34..7b510ab3 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -119,7 +119,15 @@ _CORPUS = Path( # 100010129331 (roof 110.5 -> 31.3 W/K, +13.1 -> -0.05 SAP). within-0.5 # 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 -> 13.6); 3-part cohort 56% -> # 61%. Pinned in test_heat_transmission (by_kind split + no-contamination). -_MIN_WITHIN_HALF_SAP = 0.69 +# GROUND-FLOOR FLAT FLOOR EXPOSURE (RdSAP 10 §3.12): a ground-floor flat whose +# dwelling_type the mapper labelled "Top-floor flat" had its ground floor (in +# contact with the ground -> heat loss) dropped, because the flat exposure +# heuristic keys on dwelling_type and the Summary path lodges the position as a +# "Ground floor" floor_type (not the API floor_heat_loss=1 code). Treating a +# "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated +# case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% -> +# 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission. +_MIN_WITHIN_HALF_SAP = 0.695 _MAX_SAP_MAE = 0.86 _MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current From 72ef0f0e7b60a9b60edbe5a8a199df359abcd4f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 13:44:51 +0000 Subject: [PATCH 2/7] fix(water): don't apply heat-pump water SCOP to a separate immersion (SAP N3.7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a heat-pump cert lodges a PCDB Table 362 record, the APM override set BOTH the space efficiency (N3.6) and the water efficiency (N3.7a) from the heat pump unconditionally. But the PCDB η_water applies only when the DHW is heated BY the heat pump (water-heating code "from main": 901/902/914). A separate electric immersion (WHC 903) heats the water at 100% regardless of the space system, so applying the HP's water SCOP (187.5% × 0.6 in-use = 112.5%) under-counted the immersion's hot-water fuel. Gate the η_water override on the DHW-from-main codes; a separate immersion keeps its own 100% efficiency. Space η_space still always uses the APM value (the heat pump is the space main). Worksheet-validated to 1e-4 on simulated case 45 (HP space + WHC-903 immersion): water fuel (62) 1893.57 -> 2130.2639, total cost (255) 619.7433, CO2 692.13 — all matching the P960 exactly; SAP 60.53 -> rounds to the worksheet's 61. RdSAP-21.0.1 corpus unchanged (no HP+WHC903 certs in it). Pinned in test_cert_to_inputs (immersion fuel is main-independent). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 16 +++++- .../rdsap/test_cert_to_inputs.py | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index c8a59fcf..a271f8f5 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7216,7 +7216,21 @@ def cert_to_inputs( epc=epc, ) if apm_efficiencies is not None: - eff, water_eff = apm_efficiencies + # η_space (N3.6) always replaces the Table 4a default — the heat + # pump is the space main. η_water (N3.7a) applies ONLY when the DHW + # is actually heated by that main (WHC "from main": 901/902/914). A + # separate electric immersion (WHC 903) or other independent DHW + # source keeps its own water efficiency (immersion = 100%), not the + # HP's water SCOP — otherwise a HP-space + immersion-DHW dwelling + # under-counts its hot-water fuel (case 45: water 2130 -> 1894 kWh, + # +1.5 SAP, because 187.5% × 0.6 in-use = 112.5% was applied where + # the worksheet (216) uses 100%). + eff, apm_water_eff = apm_efficiencies + if ( + epc.sap_heating.water_heating_code + in _WATER_INHERIT_FROM_MAIN_CODES + ): + water_eff = apm_water_eff if ( _is_heat_network_main(main) and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 4531c08d..e3e99e9e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -38,6 +38,7 @@ from datatypes.epc.domain.epc_property_data import ( from domain.sap10_ml.tests._fixtures import ( make_building_part, make_floor_dimension, + make_main_heating_detail, make_minimal_sap10_epc, make_sap_heating, make_window, @@ -7581,3 +7582,55 @@ def test_index_less_mev_applies_table_4g_note_3_default_data_iuf() -> None: # 2.5x the raw-0.8 value, not the raw default. assert fan_kwh > 0.0 assert abs(fan_kwh - expected) <= 1e-9 + + +def test_heat_pump_water_scop_not_applied_to_separate_immersion_dhw() -> None: + # Arrange — SAP 10.2 Appendix N3.7(a): a heat pump's PCDB water + # efficiency (η_water) applies to the DHW ONLY when the cylinder is + # heated BY the heat pump. A separate electric immersion (WHC 903) heats + # the water at 100% regardless of the space-heating system, so the HP's + # water SCOP must NOT leak onto it. Invariant: a WHC-903 immersion's + # hot-water fuel is INDEPENDENT of the main — a heat-pump main and a gas- + # boiler main yield the SAME immersion fuel (both 100%, no primary loss). + # Before the fix the APM override set η_water = 187.5% × 0.6 in-use = + # 112.5% on the HP cert, under-counting its immersion fuel. Worksheet- + # validated on simulated case 45: water (62) = 2130.26 kWh at η_water=100%, + # not 2130.26 / 1.125 = 1893.57. + hp_main = make_main_heating_detail( + main_fuel_type=29, # electricity + heat_emitter_type=1, + main_heating_category=4, # heat pump + main_heating_index_number=100053, # PCDB Table 362 ASHP (ECODAN 5 kW) + main_heating_data_source=1, + ) + boiler_main = make_main_heating_detail( + main_fuel_type=26, # mains gas + heat_emitter_type=1, + main_heating_category=2, # gas boiler + sap_main_heating_code=102, + ) + + def _immersion_epc(main: MainHeatingDetail) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_code=903, # separate electric immersion + water_heating_fuel=30, # standard electricity + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=25, + ), + ) + + # Act + hp_fuel = cert_to_inputs(_immersion_epc(hp_main)).hot_water_kwh_per_yr + boiler_fuel = cert_to_inputs(_immersion_epc(boiler_main)).hot_water_kwh_per_yr + + # Assert — the immersion DHW fuel is identical whether the space main is a + # heat pump or a gas boiler (the HP water SCOP does not apply to it). + assert hp_fuel > 0.0 + assert abs(hp_fuel - boiler_fuel) <= 1e-6 From 26106505bee7fa4c3ee22176657f1b1167b26f31 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 13:48:12 +0000 Subject: [PATCH 3/7] chore(scripts): add corpus SAP-accuracy profiler + per-cert dive tools profile_corpus_error.py buckets signed SAP error by raw-API feature and lists worst over/under-raters with the PE/CO2-vs-cost split (COST-side vs DEMAND-side triage). dive_cert.py dumps one cert's lodged-vs-ours SAP/CO2/PE + full intermediate line refs + mapped inputs. Both run on the committed RdSAP-21.0.1 corpus (no /tmp sample needed). Used to find the stone-wall, per-part-roof, ground-floor-flat and HP-water fixes this session. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dive_cert.py | 103 +++++++++++++++ scripts/profile_corpus_error.py | 217 ++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 scripts/dive_cert.py create mode 100644 scripts/profile_corpus_error.py diff --git a/scripts/dive_cert.py b/scripts/dive_cert.py new file mode 100644 index 00000000..6fa83efb --- /dev/null +++ b/scripts/dive_cert.py @@ -0,0 +1,103 @@ +"""Deep-dive a single corpus cert: lodged vs computed SAP/CO2/PE + the full +intermediate line-ref dump + the mapped fabric/heat-loss inputs, so the +diverging line is visible WITHOUT an Elmhurst worksheet. + +USAGE + PYTHONPATH=/workspaces/model python scripts/dive_cert.py + PYTHONPATH=/workspaces/model python scripts/dive_cert.py --filter wall_insulation_type=3 [--n 8] +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) +from scripts.profile_api_error import features + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +def _cert_id(doc: dict[str, Any]) -> str: + return str( + doc.get("certificate_number") + or doc.get("lmk_key") + or doc.get("uprn") + or "?" + ) + + +def _dump(doc: dict[str, Any]) -> None: + cert = _cert_id(doc) + lodged_sap = doc.get("energy_rating_current") + lodged_co2 = doc.get("co2_emissions_current") + lodged_pe = doc.get("energy_consumption_current") + epc = EpcPropertyDataMapper.from_api_response(doc) + r = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + print("=" * 90) + print(f"CERT {cert}") + print( + f" SAP lodged={lodged_sap} ours={r.sap_score_continuous:.2f} " + f"d={r.sap_score_continuous - (lodged_sap or 0):+.2f}" + ) + if lodged_co2 is not None: + print( + f" CO2 lodged={lodged_co2:.3f} ours={r.co2_kg_per_yr / 1000:.3f} t " + f"d={r.co2_kg_per_yr / 1000 - lodged_co2:+.3f}" + ) + if lodged_pe is not None: + print( + f" PE lodged={lodged_pe:.1f} ours={r.primary_energy_kwh_per_m2:.1f} " + f"d={r.primary_energy_kwh_per_m2 - lodged_pe:+.1f} kWh/m2" + ) + print( + f" energy kWh/yr: spaceheat={r.space_heating_kwh_per_yr:.0f} " + f"main={r.main_heating_fuel_kwh_per_yr:.0f} " + f"sec={r.secondary_heating_fuel_kwh_per_yr:.0f} " + f"hw={r.hot_water_kwh_per_yr:.0f} light={r.lighting_kwh_per_yr:.0f} " + f"pumpfan={r.pumps_fans_kwh_per_yr:.0f}" + ) + d = epc.__dict__ + print(" --- key mapped inputs ---") + f = features(doc) + for k in ( + "property_type", "built_form", "age_band", "main_sap_code", + "main_heat_cat", "main_fuel", "has_pcdb_main", "main_data_source", + "wall_construction", "wall_insulation_type", "roof_codes", + "roof_insulation_thickness", "whc", "water_fuel", "immersion_type", + "has_cylinder", "has_secondary", "has_pv", "mains_gas", "n_building_parts", + ): + print(f" {k:26s}= {f.get(k)}") + print(" --- intermediate line refs ---") + inter = r.intermediate or {} + for k in sorted(inter): + print(f" {k:34s}= {inter[k]:.4f}") + + +def main() -> None: + docs = [json.loads(l) for l in _CORPUS.read_text().splitlines() if l.strip()] + if "--filter" in sys.argv: + spec = sys.argv[sys.argv.index("--filter") + 1] + key, _, val = spec.partition("=") + n = int(sys.argv[sys.argv.index("--n") + 1]) if "--n" in sys.argv else 6 + hits = [d for d in docs if str(features(d).get(key)) == val] + print(f"{len(hits)} certs match {spec}; dumping first {n}") + for d in hits[:n]: + _dump(d) + return + target = sys.argv[1] + for d in docs: + if target in _cert_id(d): + _dump(d) + return + print(f"no cert matching {target}") + + +if __name__ == "__main__": + main() diff --git a/scripts/profile_corpus_error.py b/scripts/profile_corpus_error.py new file mode 100644 index 00000000..851086a9 --- /dev/null +++ b/scripts/profile_corpus_error.py @@ -0,0 +1,217 @@ +"""Profile API-path SAP/CO2/PE error over the COMMITTED corpus (no /tmp cache). + +WHAT THIS IS FOR +---------------- +The accuracy thesis: the gov-API response carries the full SAP input set and our +calculator is deterministic, so EVERY cert should reproduce the lodged +SAP/CO2/PE. Any divergence is an input-handling bug, not irreducible noise. + +This is the per-cert microscope for that loop. It runs the in-repo corpus +(``backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl``) through the +real ``from_api_response`` -> ``cert_to_inputs`` -> ``calculate_sap_from_inputs`` +path, then: + 1. buckets the signed SAP error by raw-API feature (reusing + ``profile_api_error.features``) ranked by wasted accuracy, so a + dropped/mis-mapped field surfaces as a biased bucket; + 2. for the worst over- and under-raters, prints the PE/CO2-vs-cost split so + each can be triaged WITHOUT a worksheet: + - PE & CO2 both ~match lodged but SAP off -> COST-side bug + (tariff / PV export / standing charge / secondary fuel); + - PE/CO2 also off -> DEMAND-side bug + (fabric / ventilation / gains / heating demand). + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/profile_corpus_error.py + PYTHONPATH=/workspaces/model python scripts/profile_corpus_error.py --min-n 15 --worst 40 +""" +from __future__ import annotations + +import json +import statistics as stats +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Optional + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) +from scripts.profile_api_error import features + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +class Row: + __slots__ = ( + "cert", "sap_err", "co2_err_t", "pe_err", "lodged_sap", + "our_sap", "lodged_pe", "our_pe", "feats", + ) + + def __init__( + self, + cert: str, + sap_err: float, + co2_err_t: Optional[float], + pe_err: Optional[float], + lodged_sap: float, + our_sap: float, + lodged_pe: Optional[float], + our_pe: float, + feats: dict[str, Any], + ) -> None: + self.cert = cert + self.sap_err = sap_err + self.co2_err_t = co2_err_t + self.pe_err = pe_err + self.lodged_sap = lodged_sap + self.our_sap = our_sap + self.lodged_pe = lodged_pe + self.our_pe = our_pe + self.feats = feats + + +def _load() -> list[dict[str, Any]]: + return [ + json.loads(line) + for line in _CORPUS.read_text().splitlines() + if line.strip() + ] + + +def _compute(corpus: list[dict[str, Any]]) -> tuple[list[Row], int, int]: + rows: list[Row] = [] + skipped = 0 + raised = 0 + for doc in corpus: + lodged_sap = doc.get("energy_rating_current") + if lodged_sap is None: + skipped += 1 + continue + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + except Exception: + raised += 1 + continue + cert = str( + doc.get("certificate_number") + or doc.get("lmk_key") + or doc.get("uprn") + or len(rows) + ) + lodged_co2_t = doc.get("co2_emissions_current") + lodged_pe = doc.get("energy_consumption_current") + rows.append(Row( + cert=cert, + sap_err=result.sap_score_continuous - lodged_sap, + co2_err_t=(result.co2_kg_per_yr / 1000.0 - lodged_co2_t) + if lodged_co2_t is not None else None, + pe_err=(result.primary_energy_kwh_per_m2 - lodged_pe) + if lodged_pe is not None else None, + lodged_sap=lodged_sap, + our_sap=result.sap_score_continuous, + lodged_pe=lodged_pe, + our_pe=result.primary_energy_kwh_per_m2, + feats=features(doc), + )) + return rows, skipped, raised + + +def _triage(r: Row) -> str: + """Cost vs demand label from the PE/CO2 split (~tolerant).""" + if r.pe_err is None or r.co2_err_t is None: + return "?" + pe_ok = abs(r.pe_err) < 5.0 # kWh/m2/yr + co2_ok = abs(r.co2_err_t) < 0.10 # t/yr + if pe_ok and co2_ok: + return "COST" # demand reproduces, cost-side off + return "DEMAND" + + +def main() -> None: + min_n = 12 + n_worst = 30 + if "--min-n" in sys.argv: + min_n = int(sys.argv[sys.argv.index("--min-n") + 1]) + if "--worst" in sys.argv: + n_worst = int(sys.argv[sys.argv.index("--worst") + 1]) + + rows, skipped, raised = _compute(_load()) + n = len(rows) + within = sum(1 for r in rows if abs(r.sap_err) < 0.5) / n * 100 + print( + f"profiled {n} certs ({skipped} no-lodged-SAP, {raised} raised) | " + f"within-0.5 = {within:.1f}% | " + f"signed {stats.mean(r.sap_err for r in rows):+.3f} | " + f"MAE {stats.mean(abs(r.sap_err) for r in rows):.3f}" + ) + out = [r for r in rows if abs(r.sap_err) >= 0.5] + cost_n = sum(1 for r in out if _triage(r) == "COST") + dem_n = sum(1 for r in out if _triage(r) == "DEMAND") + print( + f"of {len(out)} outside-0.5: {dem_n} DEMAND-side (PE/CO2 also off), " + f"{cost_n} COST-side (PE/CO2 match), {len(out) - cost_n - dem_n} unknown" + ) + print("=" * 104) + + feat_names = list(rows[0].feats.keys()) + bucket_lines: list[tuple[float, str]] = [] + for fn in feat_names: + groups: dict[str, list[float]] = defaultdict(list) + for r in rows: + groups[str(r.feats.get(fn))].append(r.sap_err) + for val, es in groups.items(): + cnt = len(es) + if cnt < min_n: + continue + w05 = sum(1 for e in es if abs(e) < 0.5) + mabs = stats.mean(abs(e) for e in es) + waste = (cnt - w05) * mabs + bucket_lines.append((waste, ( + f" {fn:22s}={val:<20.20s} n={cnt:4d} " + f"within0.5={w05 / cnt * 100:4.0f}% " + f"signed={stats.mean(es):+6.2f} mean|err|={mabs:5.2f} " + f"[waste={waste:6.0f}]" + ))) + print(f"TOP ERROR-CARRYING BUCKETS (n_out x mean|err|; min-n={min_n}):") + for _, line in sorted(bucket_lines, key=lambda x: -x[0])[:40]: + print(line) + + print("=" * 104) + print(f"WORST {n_worst} OVER-RATERS (our SAP too high -> we under-count loss/cost):") + _dump_worst(sorted(rows, key=lambda r: -r.sap_err)[:n_worst]) + print("-" * 104) + print(f"WORST {n_worst} UNDER-RATERS (our SAP too low -> we over-count loss/cost):") + _dump_worst(sorted(rows, key=lambda r: r.sap_err)[:n_worst]) + + +def _dump_worst(rows: list[Row]) -> None: + print( + f" {'cert':>16s} {'lodgSAP':>7s} {'ourSAP':>7s} {'dSAP':>6s} " + f"{'dPE':>6s} {'dCO2t':>6s} {'split':>6s} " + f"heat/prop/wall/roof/fuel" + ) + for r in rows: + f = r.feats + sig = ( + f"{f.get('main_sap_code')}/{f.get('property_type')}/" + f"{f.get('wall_construction')}/{f.get('roof_codes')}/" + f"{f.get('main_fuel')} pcdb={f.get('has_pcdb_main')} " + f"2nd={f.get('has_secondary')} pv={f.get('has_pv')}" + ) + pe = f"{r.pe_err:+6.1f}" if r.pe_err is not None else " ?" + co2 = f"{r.co2_err_t:+6.2f}" if r.co2_err_t is not None else " ?" + print( + f" {r.cert:>16.16s} {r.lodged_sap:7.1f} {r.our_sap:7.2f} " + f"{r.sap_err:+6.2f} {pe} {co2} {_triage(r):>6s} {sig}" + ) + + +if __name__ == "__main__": + main() From fc7c4d2d3b79e37cdfa459208d68564d812d016f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 14:15:34 +0000 Subject: [PATCH 4/7] fix(climate): compute EPC CO2/PE on the postcode demand cascade (SAP 10.2 Appendix U p.124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SAP/EI rating is computed on UK-average weather (Appendix U Tables U1-U3 region 0) so ratings are nationally comparable, but Appendix U paragraph 1 (PDF p.124) requires that "other calculations (such as for energy use and costs on EPCs) are done using local weather. Weather data for each postcode district are taken from the PCDB". `Sap10Calculator. calculate` ran ONE cascade (UK-average) and fed it to SAP, CO2 AND primary energy, so every cert's EPC-displayed CO2/PE were computed on the wrong climate. Because most of England is warmer than the UK-average, this systematically OVER-counted heating demand on the emissions/PE outputs. The two cascades (`cert_to_inputs` rating, `cert_to_demand_inputs` postcode) already existed; this wires the demand cascade into the production entry point and grafts its CO2/PE onto the rating result (SAP unchanged). The corpus gauge's longstanding +5% CO2/PE over-estimate was mostly this climate bug, NOT (as previously diagnosed) per-cert mapper fidelity: CO2 MAE 0.26 -> 0.12 t/yr (bias +0.18 -> +0.04) PE MAE 13.6 -> 3.8 kWh/m2 (bias +9.0 -> +0.24) SAP within-0.5 = 69.7% (rating cascade, unchanged) Worksheet-validated to 1e-4 on simulated case 45 (heat-pump ground-floor flat, postcode W6): the P960 prints the current dwelling twice — Block 1 on UK-average weather (SAP 60.5318, CO2 692.13) and Block 2 on postcode weather (CO2 626.78, PE 6581.59). Both reproduce exactly. Added a tracked case-45 Summary fixture + two-cascade cascade pin as a permanent guard, and ratcheted the corpus CO2/PE ceilings to 0.13 / 4.2. The e2e Elmhurst suite (Block-1 line refs) now pins the rating cascade directly; the two Vaillant overlay snapshots refreshed to demand-cascade CO2/PE. pyright not installed in this codespace (strict gate not run locally); change is type-trivial (dataclasses.replace over SapResult). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/fixtures/Summary_001431_case45.pdf | Bin 0 -> 80737 bytes domain/sap10_calculator/calculator.py | 25 +++- .../modelling/test_elmhurst_cascade_pins.py | 13 ++- .../_elmhurst_worksheet_001431_case45.py | 107 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 14 ++- .../worksheet/test_section_cascade_pins.py | 62 ++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 33 ++++-- 7 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf new file mode 100644 index 0000000000000000000000000000000000000000..48a5273e27cf1e15948bb78faf95e9efa4463d31 GIT binary patch literal 80737 zcmeF)1ymeez9{+#gy0T=;0YewgS)$Qu*TgTf&~Z=+}(o*X&|@;cMopC-8JZIzB4m- z&YZixcW2(Zv)($VI;*Jau3fu!cmHeGu3h={CRY#CP8?7_IFADNWvg&<7Dkf!p0zNX6T^F{Kp0) z%&d>wkubCW$Bgrj84Kg%O#5%Su|S{xU2Y%b_05e892u0I^c^1yB54IJ0Rza)(9x8H znURq}!pO|T)RBaVg%!F>OB;J-TRj6K1`#7?GXoodOl%37xjP6KT%Zr?NU|Em@ug~$M5eR?aN(qJoj$v z=O)T5HVtGbEd-%~2|}CniuE24wkV0leS9tg>rX~Ma?l$&j&E8%sl zB1wJ~zdQTB@#aHB=e>dog~M|Ptul=2YDUyPa>wwn&74w{K$sH0c4lHex>5%-dKKmIWqsi6VhpIb;t>B8b)h0$Fps;@4-)n{(&Wc5ZFKMrVZ5k zaok6W`}q35@g*AJJqnDJ^68xoV?&nua_o@9gXVqwCQR4ojR@juuRq>!cXui0r3V$> zZ`U0kjkQS`T9{CVoHOa!I-|7QSegeY-E6(`TG;43|FGA38r7n*fMm9eA(x=G=sLzy zcV^Dv7fC?!ZOrV7%UcIRktv-KD*J`~I=^{)26s6(!NLNs3)Ag+H`$NfjNeRO@8+G=qyzCGs@-z&~PpCPWe0>5DsKdB9QxC9% zB@m9WvjK+R)c8^m7##IwY;uq5O3h8wqaBW=Z;ie$Puy~w^BKY@STEsD(Fqq8*|DAY zGr6gcc0k)F!DCCi_|7XnlrF7BV}bjLAIV;)&^DVfRn$A!KC$jz4T)moLhZwwLmi9d zf>5ms*}|@?f>)a=cOQD=#t1E1C%Cz+-sP9Ej@&-37k$l)(X2-obS_PMf8~5`POjz>7G6vQ}$MTo;<6}D-tkq3;ATrRy}Q8!N3l?PA$W)(x5 z;_}ApBM+~+tq%`p@AgEZ>M1F>yD7eL*Wd5!XKm-tiGin`OWCZQ&XtxK+Cm_clj|An z57&)7Cw)5Da85JMgbg1OgN{poM1vgqX)TQ|eBJS-0<^Ff$i=>r-#hLL2o%MD`dbRr zKrF_^s?XvvKOj=TcWGy+xz@yn{n_Oy`t@cVzDTyK2$4U@)ml$$x;hVC$lS*9tsn zyI+6Zo*l~|B-yo=+u`%|3>tBht$N=Y*&wC6>jKg>md(7G7ql(U8a{ngPcVR1#nZ7# z@Q{^b?WSdg@`~5Y;3I@@qM7#N8*vyK$x3#+1`jo{j7oVT_5wAYj$lFlDIHY&!+{s| zd5YJWVfQ)8LrTWRZM46x){CwWja{k&HPWW03C=nz-8xJ07d*xc`XcylE4rLFQMPZY zhaPC|z-|SneIhE-*!>t~-^0z?FfnfO^T=Z&EBkbA@=TO*ZVpZnd~qpk@fq?4X`LB< zU`?VC`p?XytB~5@i3jNop3vwoDw|e;_ho{(YgNt2LxA{VXKmvOG!pdG}ENfyLgm(*GFsxC1#8v%x(}j$=$qDd~ zJRSPomY0ZRerm>;+s9JG>7+KaK7%}UXMKS*3g_+=c`uAN--~rW)RdAUgRQz(so`g? zD*3J6pxa9_D9oV33iCCq%%dz*ujki|wtEqhd zeLQ~s0)f?88+5#>I>v395UnAhGr=ZU2DATl)V~ZI3wF>h@BVqHV?W!tlaX%p`U@#R zr4!e){-VBWVkS@BT19hWJU>P=yjLePIqduHLf*!wH+#eS(Y&hmbZC(1Me{^d(sjp* z0LHe5v24rV#l}w67uDV+ zlQq@8C&9K5n~{n(Mu)F+g(hMns@^UhQCuj4a{l*}cStuwu)}%AUAQ_ZLqj2~JS~ij zEO4*Y`Gskf4h`SKWjTBMUYYEUXCqFPwIf51 zX*lVN@{7;(P9t^oG?c&@?wvjxND&EWye|GaD^48VmH4J1U&{3yENIA*EP3doUDJ>; z%eo9biDuZ}mnLEGx!dk(ln;~ikwF#*WO3XL+i1tll$+Eucg;(R#U~%YSxdT1 zKG?Ckrf<@yi^d$T4?D_Di1w_Om8{s9h3VQh``Tm5;dxu^gBkWK%P{nIW8fi2%Q8hH zhtKw=AYGR>$5du>jikA>O*RRjA6;sDU-W*_a_KD_`>T+)*Ncq(C_+6s(GztJJy>vx zQ{Me`)SeXuJr6=pf`|8=`vSZBdvWpw-FaKrJ8ER>9&S(ch`NN^;-<1 zn0;vW=rsdwh7OsZ9S1LXAt%Xme>bT-;Co$G_^LrHiq0arNgtwh?z@mo2ll@>AUu)= zhlBWjBPMIk3>5RmozV06G^wT1fy?u&4U2*o#Q78zPSi!ux5uPz{baAklTK*jBa6dU zUCA@GF3_n53iI|fd?GwzT4(hsz=FGI`FoZ}cr`}U3{T4~bS_-v9yYsL&ZeA1e;!K( zl*+yFRCfp&9FPi#R_T_?+98-g(MfW}j_9U7)qyNsFpy zhHmGp_%$G+p5tdn5%pqCXq~LKjY>ZbP6Cl!0%b3=g%_(WZ+c#>J>SSXK2lP3&!?vB zFIsmx|5}h9?&W}|BgH@Vj`H`*OB9#pOa734KQD{wiN2kp)i{(35AHJpPfgOO$u_pD z%nW!%*Ut1>={LP%aPIYNVGfCVDg~)$4vk~w2Ap^O>74qg>{t2Ur)+Rpi!|c-Q0Z;+ zx1Z&PkJ{A5(oHqLnitsx%*MkuIlUhW%?9;qKG*y(hftx z8=dO~fefe7xG-5YzQnQoPV8%De8Yrvmi_*hdn!~JIL3D&_sp))whS^i%4*u!)Va%0 ztm;h0rcc0|7CG|FU5`>u|2IY64eG;Q7Ppm_39XZU&0zzvayW4bgpJy{&tJfX@?F?B zN#G_Y%fcaEdnXPBN^?py_JmPjz94JN}IrmvWac+^ZVi$iwkR-oRrH1oK8) z0=PQ^SFhzA)Cg#mf;t?C?PPd=j*yO9&C>zd$`8i|?^9r`F6ZXu-wodRu|VRT247U2 zABUsR3UC>;lw)J!h_@Amu24I&;alOo5<)&aN8Z_ZUg*8{jJ9zB6 z7TV^ul4xW3($OSsY8NBTaLD&sfUPS5d`;y9f7tg>QVMpwT8+Xj-qqlVb>D_wO|6a5 z=8Bvdc5iWh3tYtU^j$<_C>5=_h4B1>6lXiJN?j&h2!fmK4gA-!{bMBT!9n5L@8*vQ z>@uC|Pd|{GzB>+Xj1US_WG3cvfgemb%_v!D@HDt?F5uC|!Mv)h6)Ma>I`?_-()R9l zWPtac$R_u4Z6?XZkEq{1h!xf-`-IKlh-GH&sYL=mc{lXZg|bGg0VFg<8nZ}=bLnAp zNcAg6sVC?b14;74Sip^D?t%a@QO4=oww?jkj+&@l%5?w@J#`(@d$J?rHX)u6?DdKO z!A-C@bLnflqJurRpIzWX?pGW&GWbIlcz&;npI>(b#YT1diK?>sSC2V^eQ%M>S_OJ> zBT*qEVJ9)lFr=Gjlk zaxuNotKdGP$6i}AjOG>@~o0?zHQ!V6|c5S+%gWvBs5#G@oWoMe@g$SKxoj3TbMXKqcr-tbdRe4NII~6Nl@O2gRc_S0QU!$IGe6&#ru#{`*(b=Yx@48c$^<_0h^3$*_HqjfD7A zzt!okbevj5XX~!SZHuuCl2lk!oVj++rsPOpHLM|Y&_=($HYE#Xpzm!DbS0-Cgk2+1 zEKZ3U)3+}}5-5s&Ew_U5T&q{i+JR(nTIfp#u}(cr`X>)k`E@mWq5IXk1KZHIM6YvB zzHBn;5Q)EoY}qs2c=lQ?^a$Ri3MhRQTE#Brug-mYYvh6;=q7r~c2mL>-&-ueAQ-1V zaV*gIwY=+s#*?rP6a<<=Qi-a%Ui0Et*p(sA#$Itkz|M*IXdW*4HCn3JkpxfqMAfc< zwlC+609)byUcgX9UN1b^ne(Oc) zaLMhWX(v{zufysc$MP!oTUz86N4?UGlKOyfuK>uqn!Z(4Psopj(r@g+9cD+ zTvD^!c%5If(jgW|I$RkXrQb{YZ17hFG`CKf@ycgo1DgWh7$=bA!5wsMGJ1Wrk;G8*DTc|oQgaGrLMxR zs4B&9y-sh`FL5Unv6LQW^nqRgf}V7+?x12qqMO&e_uSZT7UB-i_**mik`p52Nb?2w zLfTd2*#~gM0*eWwNV5~$Z9+hXmMDXCR9u=DO~%xIqaCzoNVB<@D0LUtGoUyV#J#~r zoYF?0#xCQSXMWqNlgQBa_vaT#^lESd9DTE~sF042wG}X=le{0mQQ5ydY}5z5aft{; z=|e~_vP2c+zfxea!J7wcXZj+C?+)nGp-~XVMF;ukH+*J5>mg^3BK3I;MCjRMTm!IIr@O3k` z={)a@FVH5eP=VpMh*!`&<$YrtPwpwg52_Jq6t`t9Blp$}kR$^*bR{6%5>tcCNC(=r zpwHh=$=mu;ID3HG{_&E9)laxL@R-ckS~zDIPw0#OuZX||*OEbNl*E+*BPDb-_coTw18+EOaDMsWsRT;H69i}%_g}h!|I*q0*oFNsbWcNjs{gs}X{JBvp5|a> zWBgCu({PTjr{X5kt!83nE8H8of?TGWxaCp}BVwkN_BgGh=gIry>D2w3E)mqqTokby z5DJCLIm&#RFyDUo@d5*eTd?d)&U8ar!YlZvPmx}Z;e@bW-ueln+z&jkI6Syt-o1$W z@(dOMi%KC=z~{d%wD|=k2*Ro9}W@ctyvXwKO`K_yZ ziC?il_>(3q*qcYH#?QZ&1(uhUF>-QpzGvoRW=-#48QL1}->s3}ACp?kSdrh@cigAi zQDAJv*zMhA-7Kbv1Po>M%WhX>n&UlL^;K`_}edt~76n z=H{eFx7<`h+v1Pu>BJ83f`h;RCa298?W{1?s<==Z}YgY1WnanyUx#n4AmVHPPo zL`=MkLL9e^OWfe9JNU(IF;9!4Vw(p1ORFqXjPf7D6E`Zqj^V z0DnAa)8$%?YL9GSaIp7vQG;UEH1_pW^Xk%-H?|P58_0Fa{CrfY{--p|Vv&pUwK3Hm zJ`w_qXH(Pa>gq|OFKr_WX`lIJRq?gk=%??EA6rZ+OmqE+*yAGJo(#HNTT|`f*lKFd zu(93U-DOU0Z51F@<=K>+A-J()KRBda#?>BbPa3YDse@iNt^VGj$mJiWo}FTjuez&w z0%|TRv)vr>)EiHZ7kl7TzzP=v6v7y1<&|Dfmyt!2Ggjl|OU0~~Ir-k*w z{R{X8>*|d>&2+i%Q}L-}opG8BM!K8A=evb7>sD#)NJ{=M0$uCfL&HNC4=ZZ5N~>~J zg|u$(=uII8&c-i7&DU%rcg!s`H8nD;bZf=L#B#LtNcr2!_A*%92Mn(lqi6y>+EY-I z5*~cPcVyGK>a~XmmmSWF&s<@Z)YU(H-MDOc$3u?Hc21i>zjWx^EDntu0U?|3VX*$c zU{dg32f>8f;p2bov`4HC89BA}_iRHstq^b1|624@3a0l)9oBJ`#3*}etdEm3YUA+{^qiwIDP{MQ+PG;HOGOA;L@8Tjloda@60SU z-$zKZ%JS+p+KTe>D2@;ULZ%i`O4ulHPCwHkS#TPwo@e+!m;3w?t|1uK(N!QJIU%!I zxA-M^)B?;*dsFvjx2|qIXul3N7}TfOU&`ygbWpYB(hS=2D_J zbe`OZ``ZWc7k*>#eCUM@MTk;f)+=%?kv6LL1v>2(Mr~D0X?>(EyZbttu@7y9*IZ^w zLE|BnecifMFN`_oY!R^_u?FI<{;$wlGljsU==#lJ{r)UivLW;wUPU_B5ekQF?VIBb z$W}BDQ>8nSL2%Fm20TeUi@R{K?^5_0hLR>SH5Lnx2;H9a>UzS$AJ!iev*I8w6jb)m z!_VU2vcfw5(OGSBJtV=lYC16`4m*xn2?sPZ+#k`5fsKyiwK{vQWlWDBE2DnWxU;j< zr)#Jc7Tk>G#K~pz#QGGEGDzluaV4veRAU#2!+&a@rnk;I|VPMzMuvb{0E5*MoS$?^B@ z!z$;KG%wn2ZmtV66j_*9PmV8MIP6(k4yV4iGLbHV6eSEvcG0?kIAooWw}fxQIP?o_ zG7IwrT^#M5ZGTRTjgC)-g?>rQXyLy2?B3DN?|vQnd~hgEPTZJ~&WfX+cG35YE!VB8 zu@Q-E+tfS@E4d0;?z8%jIPGov+i<5-v+?mU{`ZcY+J&70MZeFoBl`RM$i)Tj<@s(4 z8jCGU=Hqcp;3&hsn-degf499ZVEkH|wu;5Xs-&1#YhlmKSz`yk;D`SYk{)A2i7)b3 zd)e}Y`vfDgN33T~XQ9ZU&3D&1kc8n4a2G{+ef{k0*fW#-R1lg5^7*+HvNn%bk@Dav zkrO1T%ZxBx>5okF_ER!1x08i(cuX0VH)zTv5$|n zUShYN8#qJinH*nuj`&2%-TmV-RxcYoI5#inmyM0QP1U%`alD>+mvxwIsQi&D4_2_b zyPNA*tX_4-lmZsMxOw}J8p+M!*4kESYcB_Idm=7igNjPB%&g4(6Hz7$iyW*jUN!_+ zWflcCyg1hFQ!w%PN>P^DgG5Az`c6~On&@hK?U(EN*m`iq$x7STy@7YtKgS!0Kd6H# zII~khmmc~CChfL{`|$03-W4StT3m&ZsUH~XS?`qsk7e8;y2@3)L5wWyKH58bDvWn| z+1c$~eY34YL|$Q>LS3|I&%2k0MkJOI4NIS+eEORCG_d`t_b(AcW*J$z7qIEwb-HP5 zB(6q!<|rsybsjT4Kgy&_1C*7OafjfRiz>#Ldt2#F`vy9&#OYyRvwV79ej53OX7Icn zpDW-;-Q~rFkdQ>U%J*-29j~^L$aB40e!PCFm$|F5tg8Gbzr`(Ck=V5BI?Cbvv`@0i z7-F7rY38=U5kLH!61=2R@&oRQL`@Z2JzyXdDeU_W7Z+D_gF+AOOQLCHS_i~7s@~_a zuhWNy0s?UJ8K1KR!t>?i=1GNr0-4@0>Q_&)GY`r6@)*-EW;+W>+8YU z__;pS#HRLSrNEfAPu*eZ=(sA_z(zhGXA}ibqugBPBvRd>48+gt zB3jvN$uQL!Aea5PzD9F1%XJQ9m~wt5lRL*Z2Ir|Jy=s^C`j?UpOip(>O2b~c z)wyZlC(o$Qkd-rnqx~crgFXkQa9g5d`AbkkWWqw@&RE(-$=W-6Br1@!=%;>8!vt$U zOXKS&w*8q=?d2C%2j%ay2@r{J%Ons9qe_Oy%Xb)aO4?S0JTMhvq^$>=*e58g!{n27 zuRwPpZ3nPHTUg^&ss&K z?noAGq*=Dl+>a32UO^(>33G?$2NfbgY-Pz+-VO<+7s#FjN2Svb)4uU+PvOw-k&~2@ z85^Hu8W|517Jo}tQkYYIb4Q{C8keA^oCr9V?%v7obzN6`D0XjWA$IpViC;hVJA2c@ z!ez@YA>T>U+1=^G82Q0f?WNutW-%IgrfSO+=4MD^7>_TrV zkX}LtLXF(~U5~n)jDqbf8l_|!l<$+=A9d#GB@K*X2S$zVl;F|#md`tG3862kkV==K>_&S%hL_kJ~A}A~@933BB*!Yb^#TwIEP+0l`@`L`!Ll|uL z+N~sPpN|c$Sp9*#oi?H?U$EpdE&O)yLfThFDzWcN(QtG!~9 zP-E8f=6~{@oSb6A&?H;772S`Wrp%(m@65cU*;1wFV{63abkbH$P8fESm&pn4AesSo z(P#5lJKN)Fsc{n4U~ZdRFlZSca{HXX)Y;Q>m#Wg;)n9k8ZGd2Z{bGOaP+%@;Xu)8MS8E@M^wtn9>yE#c%s(M%TM(@M@6BfYQ4m&M9hmF?7qK#*%^^CBN3KHBD(*y`k# zsiAAfcFfX>*=l)zx1V97FHL|*;M&rn?;*Ok4 zhinv#DcfddzunwK`iz)cOB?6sgHP;7QbM)&!?eS~kPp~eYK-or^dYyd5#H|G`*-$T z2-{9YmwcBzOf{S}cK!!hwFT2-^FnvU$tfWgdxI%lGhBF~NJrgc1GjsH<4>EtOcSFA z3ce+k!&^REOzZG@@d9Q=&)LN(2D??lz{6pWb6Pb1>ANw`Y$7}yQtimuLY4%wI5SZm zCOWpT5k%54xZRx{0d@&-8`R;?wO4C7Yh@#h&$T_Rkm-E6s3oZ>BfOQhJEK#hQxk8` z%U5$d5uCXLOTz2zVZ)xa!S6{)%C&00?V=ROGAk$)PHPL0-qg|77Tx_ZF0kvje{y;L zCeLl2p9I4vS14LRx{cl=J}zEJ!a-9rYErofeuuZ>B8Pf+2@U&wIrzxhEn4ja!4P{d z4yiD5vCQkipazn+k;ksek!4Fo$2QnK-rqOhCb9IRW8@_cl3GE$HZ5cay>LY~DJ3y( z>Ge?)p2PTfyD|w0;W<4Wb?(gA2PKHo3+*&*dPm$wwqA6ih|RsVPz~xr_91%*yBLtN zYe3|6Rk0Ixa_5p z#RCCoERzxN2J3SdR~*ZS2OPYlHpP(_a~`(S>mlW4Gw?RwbnWy6IjQ!x4}8QMN=!wB zpG{xX)p1Qc@7?d->6=x1#hBH^+oZfASa2Agw7uJUyG0e-hBTQS zpWn!dV@I3Q5wUvTtZ@x;lbLHq1|lLOBNwhLzECY7*8{0~DT9rC#pf6tqaTiNLm<5p z{%7BMpQe>rXYo%?hT_mv0J`V*uVC9ZcK@FvcsGIcw1A9%f(?;3u9nl;LOp*<>-CD=e7w|RrkGw zgm{?e&(l1-ymgj*5k_ky=4Za#O03!=KI#3WOQ_f2F~G;hHZi_1J~6|_!{Z@7O4WXH zQV|?=|Lqmcq>0Pd;z}%^tQ{J}!nX;X0|r;sJO+t(D>@}DN>mG%(4dI)aIC=Aow7;i*S0mAuth`vvYEm!Y?W- zhs0Sn3{n&EJsXoRiePW=n7ZjU=EvB|N@q{c%53~XZ0!(CWJ6STDEnGkYVGKVMJX35ucE59y}bo)F3>%KO+ZHC zG|@MWO^f5DdX=h^sB=@!)2gdwSsY*gg2>--dJ<=?(7XeA3p~1ny zK0Xbe8xzImR>&n^j$ZO*pyJ5N$|0km;NW7X;HPwGwUE9-`()Gct5$*P=lEnHWJ!0{ zTjAGlhx?}~o{kM@sR02w9UX&-#ae1|?kY)z+Wjp=i0`lRaxGO}O2gVY}H(7Ue zZ0&AQYjwcj8W`&bPrda#GFM@JLr2KaF92%!Fv`i?W0qM&+48Y7?!dRQF()&mxv(7l zC)o#)C$WA$D=v&RIRL;1h-4vDh7n z&kc+-8W|hY&#{@FJH1qAwJW!qDg}#d(U?5st>L@b%>Mt8-Vixuzb>B@DBf5ET`hq9|a<-i5uIlM*XvE7D`R8LBRUM^C< z-Ia&W$;(Mks6cHOZa41ALte|SCp#V{pVm-!z(3I7dR6eH%+>*u<@fd_^b(e{)I&IB zEyoag83PY_Lda(RS=}j`WAXliY@`rUSjnxFv4hB9f^l&Mk^P<4 z$CC-tYg~DY7JN|Yo>PD3_wShdAKOPSkX}5O0-xBJ+Xwq6)tz{fgdl`GyS*iU*f8Au zQE2sn7<#62O!zUfe`J(-m-=%N5oX1@YlU__L4T^OgzjCgO-W-hK`OzmA^1Ka&Uy&LVY<8FT{uEuU5Ur z8!&du`f=sW@7N>J>kEYtPZ0O4%+jq7tQ~P(a6$g?G@YHjczOwjQb>;OvGZMo%ugUW z2DBo{j+7=^vXX*VeT$Ryw5JC{%b8E*sA=BCwzz)Mc_LGAf*?@dKG{0}hZ#b}kd)f7 zNHONF$E#b*%hWp1>$9w}t3+Y~(gWMsSsU4od+~0pdRM9McD~>8ID@up7Av@UnCt4A zAc{CDOpCvuHS1e=>k$6jG(|_1*fUE#|Hx%*OHf{|a!7HkyZ=JGjJdBh9FzGs?7c02 zTAMHs=Su{q;w7QCJ16UiKEX9)Zo1I3kIJAvImKHl)f0;*bsuM&_s!g8{!!pjIh7;eH zklJs6*l2EU$;!@(Pjbi?9f$#Dv**m~qVSWt+?SD;u3^=j8YX!vge|$mC!$U9TkS0> zO{NF5w+sOhK`ACiSy@T9`2cxqErEqADz*KiTSRl_dAxOEXMD()h>-8qWlDBsi9c{$ z$uVpMXqxCCq-&-X*6YLrn7X|-5sAHG;7fg3|&3T}%GwQ*7 zonN)O4k#fZ1<^V8Eu{(LSU-y2=As6Ib{FAH?m9DreK7FNcMRj%-@U~OXC`~+p)h()f(Gwn;;rMY zWx1Ez6Vhk1ruTSGi&W@Yx2Nsv@2_~R|J}EEhX4KELw-H`SZ?39C&PG39j!y)3M47~ zIxZPHr@{K!mWX*#?V#vIVtG<2>B(~)^k1yDwuMI{Wixm)r9(3_>LWkeeri<|ziRXy z)SIH(z*bOI&n?WeV`8=!krFvSKX1ipWo+BL{(Ti)!W|M;2fqS)bzN1%|MI!-(i=4# z(eYJ8+*uu7+XFbf^`&!{9o!9EH1Z_8lwaC!DJj<1OX=pnG-Irrn%KyHk0C;NF$DMG z1)R2mm!L5UrbrxrIPU)bHtf0|XjsBzZFAj(ky#g9O!rQ)@@)F@_O`JwD(Ebr@?>j! z`vtFMNwM7^v;%yRDE-Jnq=3_TH$H?#n=vyHBrGi4^&;T)VyAFOec>+-q03GzYyE6e za)I{1dH;o%2s{xcDBj(rg;5#RFn)E*wGWxPWm)Nte$T+%3>;+OOsi6}R68^$g z$3-am*4Nu=XYGusiOgduacG}u1m~%)%wsoJH0m1LS1jnNm51* z4t8*e@^<1!QCZ^Ea1ua7rNmQ2MEK4XE{*uu3$3fNa&kjM1JEueME^Euzm{dx>jfhv z^X!PRXeAsAE8^E3i9Yw6Xcv9*0=WW$Z!a8;jEp*LW&|>XrRv7CNjjm;qc!g|=+$Na z*Z_gCxs?sC&XS^N+7T9&gn_ejbtb#rk*HB9=eto(60YV2c1-1lS_L z76G;hutk6^0&EdrivU{$*do9d0k#ORMSv{=Y!P6K09ypuBES{_wg|9AfGq-S5nzh| zTLjpm|M9kn<6paZ`%l{<)<3y<3)mvS76G;h7`F%*x5xn)w+I-w2pG2r7`F%*w+I-w z2pG2r7`Nyje&PS|DKKskFm4erZV@nU5io8MFm4erZV@nU(SPf4i#Y$a?&*Je+#7E8`5nzh|TLjo5z!m|v2(U$fEdp#2V2c1-1lS_L76G;hutk6^0&EdrivU{$*do9d z0k#ORMSv{=Y!P6K09ypuqW|%>i0fawdHYY>BKAMIc?;Mgz!m|v2(U$fEdp%OSHKnl zwg|9AfGq-S5nzh|TLjo5_`|-3l2Wka)oK)O@va6>tot@#ivU{$*do9d0k#ORMSv~( zZ*7a182`2Y>3bP=G7SpLuN z-ZHT z8RYb=j2I-D82{wvEgL%*AD@Gxy^)?3!UuoR=wW$GL9Ffv@@tC*t8-lvCUf4%^z(va zLmr8k4u%bkhVlICSXvUtsj=r=7iZF* zbw$SI2KUn8xW?RqztSPczC3KxN5Ty7WRaqKktMOES?t0?{r4@a@6X-ely0<FW--HTOi%9Z@RSH-gEFQ|V_)<;yxC9*4n|HAQOMC6GzH4G-)@ za%C#?;r@Mli&AA#@v1`#4L%({%6LXgp@CTBm=&*IpPt>GK&_&79OHX}E*Xim;5)cN z7PV*k^~ByDS*yQh81ZPw_-MEFC56}8Rhw{V^BcIgemuZTOcMf-c$Un{(C~32?%WT{!E*;L%d z>B9Zp)jh@8^>OW7nnAvVSQM8;)O*zwK2R#ZQWCF36c==hur~9})5ZIT`}+s#>)WgE zlj*uS5~2|tswwHped~(QuY-xcIDD;m70Dv)=G)|ni(QhlUaB7cX@-6D#`p((VU<(0lgd%%Th_# z8hMX;CC_@bp|!T1(~a;ZFS$5w?<&LX)3uM^_2uHZWn$mEmKg;%I@my@Kh`P+wt?&C zvu|#p_2|vW%AjHbDX&SM-0)hbZnn5gEVp)=?DW>p*`0B(8n8>1Lg85W+4a%=1MbTH zvQLGJLISr=rbOd(xkiSNLIQty-Ix12#GAXDmbr?QZr`JeJ?Q%xT^)8PHiUkF^6}i@ zEV1hGBBLBd=zErl609Asy1c$T`+c%;ynKCoJ+(1mTcoR=4$X>3A(2NfTe@|&-lxJ5 znz?Wohf2I8G(GftgTC#ag$|2+EwxmB#Y7&dC@!^B0plFy>CFjf0__Yb`LIx_ZibRV+~fO}h~$)v=F-U)39R2IU?#QGTjTEk6$Cj)W(!vy0(6<_=j{sXnEcTvTz5o@CCCeCi3d% ziOI(cln$2uzWM!__6@X{b6ay(xn>_yC3LdIbaF(svqkjs#Wm7J^)oaJdkdfmZ=eYu z6XWlk>_P@0o~7;@$*QV}^5!|l$v=`O*C(NwTs{16P8jU}(oywKw}n{#dQ^#7>Ny(y z=OYSe3juAR{~K)~j(<;RvjU+Fw1xi7Z6VJ89NJ)#(=&{&2ZpzMr)%pVW1$9Vex_U|#8|I-oj*CPVDm&Yi7T*tw}LBhiE zmm>$d*T25T#>Py-%El97Xrlf8kF0}sz12hHOQTIW92 zvPukM&}!iEP=FSI;e))sxsib*biE|>L;GX3BooQw=LTK-`1nf&{YSpgE}zGY%q$&^ zptb&gZCNvMvN8VcV)cW6ntH$UTo3xfDecE?E)$q@M2Mw*aYBj67!?L3*s#JhN*06Z zWe@TQzL&h(?|v0b5`|cauQ>)828aimHQ$PMomFqTsa6Ia#&{xS>~Qk+BHx;|QS^^n z4AJ+E@{93{^*W=E+hO%^hC1U08|!`V8x)r+=L}BqMpX@oJd$^2wE*#Y;K5%ouLxpM zCLfddupI@;)Xjf*0@81uYMa@zL=x*NUFB|CkuhaY56i*uiDjzA?e>W#;FII$Esd96 zo!qNWjI%b|DSqdL_h914wP-5x-cXkqKe+OTIvQir=~K7z1E*GJSl@O>c~+)2geW^tQHRiYAbw1J)49Tj!h2X zjb%4+DO!&qTr??(sc|LhwoR^5lM$yjYF{qfGfw24PI9VFMB^aF`$S;DmbzhGgno9x z+juubis-GA$fKG5v-+Fntz6Xa4@P6f;P5t6&9*t4-&@C5VnMap?{&CrD~%&E4wL*e zXf`TKged4gu+}On6|&2~G%{FZ4$0gTIg?*}cptc*ouLTRsAa4Q`%I~vpKl`H!eXAd z1*snG;5$qorGZ+4s2**f-uh??ffl%h7PhAiPnwGt_T_Z`C%HwFCwSKbV=jhIoqwzJ5VcdVi;TL@_|TIY~NsmDA)e3~H0jDLx2JSfm}pM`*2qA&mc z^F!rO2Ah)eQf8;FFR#gbh(x}i3i{#*+4BV>7a=U+PvW-*%(cs6ziJR%W7YBrPx4ZS z_Qw*&{qD;9Qq#tIqJkQt+X^u%Cr%NzHrCe2_KcW@WtnmrrE)YcH}|@*PUDg=IdJ`a zn2T_*q((urhnJ`>tTZrRZI94;6;U3zDk^{wv2(7c)r=+|)>f|~Bk3na z5N1_w-w_jZ)Y61CnHC$XdJXMXXpMnyNtauYiqHim*hy%fK480m5nIM&*>FbIvZDaF8?#|GI-J80BOkye% zOZ`+$;fVgexSANn_fB*-WYxAKn-<335TZ9zkY;f*znE&0*xKmPaHcaLX3|N0St##9 z3?^K}H9KUXElyKo{8mQUC-$upQl-iSo5i?9C;MPaFh}IM^nCPaHOf~x2VEYEBB0tnk^73d1 znHfbA#?)%M$d}Z;I!o<^x2xiIE|i5t1}*Jm1Z^34<1u7fuFL7uw2AQ=Y!!GdZ4Hm# zDcK}=v&pMlrQ7!t_1Q}r?k@YCLQF8f8GP?r(jX?=FF}gFj z87YVSc19YTpFr&;TLD!JGXl(16x_YVww`TCC(~S1kllWe)o36CKg#7E?u>F- z9D3GJH|B#^H_Xh_f^@oa4~6n2P5Hn5f+AR)w6bof z4%!#XMT~lnquZH21wKoQvBuwx^l>%KSR|M>9HuZk6H@KDsKBniSv!jvhv z-bpyIeOP+6HaKVjb15RZoO=}_W%<1;M(){SzmHD<@r^;knSHT+x^+Uo<5^X3v8P&b zvSs2K<}jZ^3n8-4l@+vCSNo>E%bepwV@YSyRiP#xewl-k@+mSR_R(%*NwN}#Z^Fnp z;ItX8gZKjhFmAp-J2dgyeM0EQXHVgS-h0N#W^S; zgxufBkCX&a1>8BOp@{k9(w6n$OvatTV`d(0xHT-q>?j^Yl~eXN@iQiR(= za{8P~N&~Qj*6Q(86P>X`*HF{Z(?l>bG^C^3x27#4&d(!~ld+wd!wZ~82+*#}ly!R) zf%y4FVk7AdxQc9-J&;%sqfs{DQrDmJ;Z=KS!KEz>@RJ|ermv1`()$a6XJMJ{YfQ>Ycv+x7!Rd&hahSYg;T zwIiw;B>5wnZ|$Ic0J*&H1u*81 zT!u7U`mXHZY4a1Qnqo_4E{1(w(ky-)|F%Z)L{)K~y?|I6GS^d3Sgc&_X6B9t8JmROK#q)E5!ac%ek8C&V_UjsnWtL?L8XF2W!6>&!ZFR4{~AIj!PmkaH~%*Awbd(T zxGO9$9UvcOFJNyKW-mVCt8&MLzk(h6MozTTGhvXKXN8`UHO*|10t*%7wHjBsp8b9p zW%i@NVYe_^r=)42Vp%A%8_ff46MS{Exo{OfgU%Dnhjy;+kdI3XrU%81^xux&%Fk%8 z($T?{c^T1s%V`6!+i|?X9~{!Ee;W$dZD`hJ9F&J+F4zN|Sr{rDgHK#YRb>hEaGn5U zrlfr9=;Pj|?@oXO=_Sd2jZm7|s~bYq$KfG%)akh~}QZV?k+XWutm(E7=>eKE>VtF7bx zFh=&Vfw;-~MN%BF`Wg4yvg?4Q1Q&f8Ar0$EmRbBot&zRvR9ejt zqa3@|uvgXBh@Ek*_}6e)+Ei~hWfr_S&~(1&$g)z)uepQibKbFD(NWr#3IU4%W};HWU8c7Nd; zxyoTC#Gu&a`gf?*M%)7JzW5pBq?)VK!DRb+D^{N~UxNk=QPWq;&K=Wtwxg zm<%a#Ys<(Y048-oz{qCXVfxcOxCuvTp=+-S*5tMZ5%JEPQ$cSEc`}+S9>+>`L^XNG zQR9P^%i0!mq#aaO9tC;eGHUJTKcNuM3|x1<4Ckfy-myC5+b@wyLv%;-=9Xy>#(4a4 z&g-Bz$$Ks`p>U@#R7p^|ZJE{$V^qhs?EPahfWgEII(%H^e#4$F8Rf?KSaF{lQ6q&W zEY39kP1p=({6hX3Nh9voQQgY%*_S{t&rxF1GV1xG70$uYhvD!v)3Xfo3hs@aK>3>4 zy!ZtIlp|-Ra_hc2LzUVw5SJ{V%Kdqi+`IH*kQJ9?+!HIXZ&QPk!?cbgJ$ofueJuXU zAPE#jrq-t1a1a)E8(x{KtSyNC5{f84Ge1K;L()<5HP(gqt$2!b0T)wVOjj^O(cVzh z#ltJe^_g%&tHypmTv~I9Em};7VHSH7Ppy0+ zaq!BlA@ycHXF8^PD}0VAPuHAafqK;;ra#n`_2mWTnxA4N46(CF>Sg&x>BLe80X8XR z={cHROcgT*?NbvrG8spY$Yp{C5ZK?+@#Lp@{!Oi`iIM|E9&+YJY0+j-<&9 zPz9+e-*{%Tt%QUdm-|!p*r=#_F!cm=hOZpnder%Z?2Z88gEAq9*hq5A=W`CugdW*^ zM*>L4ohDweWC5nwKfbtPw?g>p50*8Lu6Mk)+Al(yqFNe4{fo|Yi@aOor`{>#f>2EQn!K0XJ23Qrty(|@6K$$FN{o7{79#Q zWmYXFs|&fbyx_vwo zW#Xvk5(=P$SB|J;mF*>|K7mRJA$?-=s+%~K6M|^sJE9aBg+r-ybYyuNH(nA!^*Gw@ z66vL8Mf2E!5xP9t;g~+S z!2McFzl-=#DaDm$dCR*yYSkMC(_$Qr=!i1W*G4eXxRi9<)fs(Cjr!5bgc`~#kCc0+1p z_<7R{gbVdi(1t39piROjHzpL9>{_l~_b-#Z)*$`Lodc0+d^|z7A76|n5i<}e0a2oG z9b_V{R4>P2DQ%GgmN;@>Qas58w1BdTZvyOAAC-y9V;=^9@&>rxgF;oo;lWP^RS*gG z>SJZttu{oG%131JY6FasOTBZBAE+FQ;Ft8TQt&|Bd?u_)$g5}pRd1#qaZ3io|F|?L z#Iy&%1nV1)2?5``lsMCcsMmqg53_2;g`}$+X}ZN2UlDP3Ob=Ol1!XJ0c{oUoNK*B^ z!}3gxx2s;dXLrp>_sK$n-zwHh0RL?;@ZC|{GoA1xp1a=)$_I%B@}_9?u!f>(2x$nH z?Hz@e(E|u%cA5;;kqw)8(5#j!hrCCNHd{zAlaVKhN#1m96Nb)use@mGZ#JW_#OZZiW#S~Q;MUn_2PLwMbZq2 zUqxWF!ERVBiD)&9kx4^Sdu?n=D2Nap>-vT3^z%-Ynz}miK-u_EJfDt|wHL5Xn*r0m zl4~1Xn`y;*53Ta^?)zwc3)S@|m%&g*6pK=5C}!b&nv=%%sc^c|kCZ|I2&MY$t98<9^(fEsmg*VPQD+G%Ql)+8oUPKasnWE4;ulhf=i#3u zsu~;Xh+FM7l5LqP^YOeDtANStd}7|VFgAGaS~^!g%57)%Q|)vefwztTk0Z_qe{`wo z6MQw^BxV{*VxG$Y1jfF>HF}M1t$YSwgZKCtgRI?>Ft8*b?8A2xri~qQP2G^~Pie&Z z$R8ZIiy2!*h}@fXPtFbUMvXcz8mzdmk}^LZe@lsUr4+2_&D#|AYKXP)iSHu)N&-7a z;T>N~3ELx0;aGEkooR*o{4;r=rPBmyNrCm+lqpctkRH`};c5VVEucnAAtXGVZbJR@ zsrQkSRN(-Z^w$BRUS!j)Zvwz0H6z+)k7{W&@k9n!MJCoon58mG!shBp6>&im!t)Zv zs!*v|ql4(~Zv/*P!T$Yhw9I*3Xp^JK@{l5Hq$ucBv8wCJy*NBVwjEn7S)uehLb zdgNlnw-u6A8mRhgd52e7sZ_~n(T}1>f%IT_xuhg5@QmCI`Y{e6ESp@6i z4d2vtC*t@ip@>?8>gGt(hiLZ(*~JG7xo+Y3ScmSZD`6dce0|27iuyWI7#&@xkr;If z5t)IdN{KUP_*5EvDNPsgS81(81~z`=)IF-c_A%zDyP`aBHwTs$S2v;0-OT=joZVx4 z|3=RKXfOYh-ZPZq4`1+f9p1-&zZlP++Yq*hP z%bT?2-JLtdlE9%n-C))G99=RgZJWcQ7hF$Prto}sX|~m?GO65t@h1EW;1yvmj|h-^ z^&L8`J7EZp`s=Tq#pDLOAFsuljzq$9L0s$9K6VeZ40~0FyJSHgv(f{|pyI+S^zog5 z3bKlhq^Z@y1>#7KId5x?QPV17DVS6pe*C4;DGV}bEPKE)Sp(?A7Iv0Sx8-m{AO!g( zC(aW07l@@KBW3*p!~6}LO?Gq8?+Xun_c{MT6n?_pf3fhuKWWB4f*u?cKmYvU1(P@@ z1A5g)I#=C%Il>KqC<#;u_uha+6EgGju#gEPC{g!)-*UM}-+i(4S*Nr`xq z#CWyZS*dD#jK@X5j;-=0L+nQ-CucF4R^`3iaxJo(!wHMUo|E^7o_>weY82pqQtW}P zw<5KM!m5(eD4V9WC-9buly6U@ft;$IgAB1Df}OCjuZJhs98lT*BJUj$V?@N6iwPIs z&J}|3+d_(c4}ERs@Hr070mkB0xGenhadE?=rWc9p1*(dHjU!VN=1xSQY!(()*6?c^XL8Wl2cr)QH z+%q`^fetU}`bvwL85N`G7vE|p@UyeM_FV041bf~*EHjdz@!`st*sM1#T(5BKBRlT{&s29S3VqpX2V z!aFv#YrG|@6;Ym%9QjdGf4qP1`hcr{fmz*7(nv23kI2@vIT$DgTu6s2>EO31*E>r* zv*UJ?t?K(Yu}59ql#YbTOytti&|_MxPpU>`#Sc(6^(fDDKPhpjW=MpoU#9KP6Co3z z+;`88E3h{bU$T_>3Q(wEoj5ru5zDt0wgKWt!lNTkvlJA3mPZ{nkBDhz< zpmCAobk31{UGy#Xki1L8#J)Ao+k@Xxu_Xl+CzYROhqkSpfkj3Y^&G3!EKDFK*&tOX z2)(A;Rzvn-pvF@A=sKJCYV4JNA=qp+0x$RgZ=y_B0Lv2bb29cKvn6x3`wAj4%9^vo z@->sHEkglt=KV6qRI__##}p$FDc+Imrebnqe#jlUV7B!&=J5*(OTTm`bW9tG&j%4T zl+0K1uwiKnQ}N$N-K9BP_E7{+HXdesQ45-=Z@4_TNy(LH8SlT^?1E328)`@7!>otZ zu-@o68aD4QWb=^4r#(;FRut2Ea;!YnMIqKf+D8~>ZIM+j%*9mUn|_Xpiqu9Bo%PH- z*|E4t%S{4=V~$0t7~ZXVgfv2u5VAG@mW5v!N-Q`Z{O$1gQ~9j?)AnWoJr`<4jh0UY zv*_QhXG^3syHY|SdSBvgjgdd%O3nkAz2vVZ0UvJw3Na?xBS<+%PuTLcS&6>84p`Zd zO@)6U4dQELOyDuvGik_PYe}sK}GpWyDr60l$eR zOI5NvOCWnJ*V`MIg*e38-_xe6y^%56EvjWjWB#9m0?Nh)W}`5s_{(;yn0E(-^QuTW_PB80jHvHu}RwyU?~~eeWC0ecl7@kUnmFkE`$GSo7-(Dkl)5*f&MmMc2+R+{kgHTLT>jE+FcW-tpH4UMp@$bSJUT0njP literal 0 HcmV?d00001 diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index f7099f18..11cecf73 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -42,7 +42,7 @@ Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors. from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Final, Optional, TYPE_CHECKING from domain.sap10_calculator.climate.appendix_u import external_temperature_c @@ -863,6 +863,25 @@ class Sap10Calculator(SapCalculator): """ def calculate(self, epc: "EpcPropertyData") -> SapResult: - from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + # SAP 10.2 Appendix U paragraph 1 (p.124): the SAP and EI ratings are + # computed on UK-average climate (so ratings are nationally + # comparable), but "other calculations (such as for energy use and + # costs on EPCs) are done using local weather" — the EPC-displayed + # CO2 emissions and primary energy use postcode-district weather from + # the PCDB. So we run two climate cascades and graft the demand + # cascade's CO2/PE onto the rating cascade's SAP result. (Worked + # example: simulated case 45 — rating SAP 60.53/CO2 692.13 on + # UK-average; demand CO2 626.78/PE 6581.59 on the W6 postcode.) + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + cert_to_demand_inputs, + cert_to_inputs, + ) - return calculate_sap_from_inputs(cert_to_inputs(epc)) + rating = calculate_sap_from_inputs(cert_to_inputs(epc)) + demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) + return replace( + rating, + co2_kg_per_yr=demand.co2_kg_per_yr, + primary_energy_kwh_per_yr=demand.primary_energy_kwh_per_yr, + primary_energy_kwh_per_m2=demand.primary_energy_kwh_per_m2, + ) diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 0e6832b7..19186eae 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -686,12 +686,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None: # dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot # of the Vaillant overlay's own output, validated transitively by the # system-boiler pin below (which reproduces a real Vaillant cert at delta 0). + # CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator. + # calculate` computes EPC emissions/PE on local weather (SAP 10.2 + # Appendix U p.124); SAP is unchanged (UK-average rating cascade). _assert_overlay_scores( before, option.overlay, sap=51.99820176096402, - co2=1268.4645083243888, - pe=13080.20756425629, + co2=1065.7593506066496, + pe=10995.781557709413, ) @@ -715,12 +718,14 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw # boiler-1 pin above); the same merge also resolved this cert's main-fuel # mapper gap (§14.2 mains-gas derivation), so its raw before now baselines — # see `test_gas_boiler_instant_hw_before_baselines`. + # CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above); + # SAP is unchanged (UK-average rating cascade). _assert_overlay_scores( before, option.overlay, sap=39.00740809309464, - co2=2248.6089062232704, - pe=23094.10189037302, + co2=1845.8588018295509, + pe=18944.42568846759, ) diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py new file mode 100644 index 00000000..7d9ab95f --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py @@ -0,0 +1,107 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 45" worksheet — a ~47 m² GROUND-FLOOR FLAT heated by an +air-source HEAT PUMP (PCDB 100053 ECODAN, radiators, MCS=No) with a +WHC-903 electric-immersion DHW and a 110 L cylinder, postcode W6 9BF +(SAP Region "Thames Valley"). + +Case 45 is the 1e-4 oracle for the SAP 10.2 Appendix U (PDF p.124) TWO- +CLIMATE-CASCADE split. The P960 prints the current dwelling TWICE: + + * Block 1 — "11a. SAP rating / 12a. CO2" — computed on UK-AVERAGE + weather (Appendix U Tables U1-U3 region 0). Drives the SAP/EI rating. + Space-heat demand (98c) = 7333.79; SAP value (258) = 60.5318 (-> 61); + total CO2 (272) = 692.13. + * Block 2 — "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY" — + computed on POSTCODE-DISTRICT weather (PCDB Table 172, W6). Drives the + EPC-displayed figures. Space-heat demand (98c) = 5921.05; total CO2 + (272) = 626.78; total primary energy (286) = 6581.59. + +Per Appendix U paragraph 1: "Other calculations (such as for energy use +and costs on EPCs) are done using local weather." `Sap10Calculator. +calculate` therefore runs both cascades and grafts the demand cascade's +CO2/PE onto the rating cascade's SAP — this fixture pins BOTH. + +Like the other `_elmhurst_worksheet_001431_case*` fixtures it does NOT +hand-build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 45/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf` so the +test runs without depending on the unstaged workspace. + +Per [[feedback-zero-error-strict]]: pins are abs <= 1e-4 against the PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case45.pdf" +) + +# Block 1 — UK-average RATING cascade (`cert_to_inputs`). +RATING_SPACE_HEATING_KWH: Final[float] = 7333.7892 # (98c) +RATING_SAP_CONTINUOUS: Final[float] = 60.5318 # (258) un-rounded +RATING_SAP_INTEGER: Final[int] = 61 # (258) +RATING_CO2_KG_PER_YR: Final[float] = 692.1287 # (272) + +# Block 2 — POSTCODE-district DEMAND cascade (`cert_to_demand_inputs`). +DEMAND_SPACE_HEATING_KWH: Final[float] = 5921.0486 # (98c) +DEMAND_CO2_KG_PER_YR: Final[float] = 626.7797 # (272) +DEMAND_PRIMARY_ENERGY_KWH: Final[float] = 6581.5936 # (286) + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-45 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. This module is a pin PROVIDER (build_epc + constants); + the collected assertions live in `test_section_cascade_pins`.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 1637f281..1625bcbc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -24,7 +24,10 @@ from typing import Final import pytest -from domain.sap10_calculator.calculator import Sap10Calculator +from domain.sap10_calculator.calculator import ( + Sap10Calculator, + calculate_sap_from_inputs, +) from domain.sap10_calculator.rdsap.cert_to_inputs import ( cert_to_inputs, water_heating_section_from_cert, @@ -338,8 +341,13 @@ def test_sap_result_pin(fixture_name: str, field_name: str) -> None: epc = _FIXTURE_MODULES[fixture_name].build_epc() expected = getattr(pin, field_name) - # Act - result = Sap10Calculator().calculate(epc) + # Act — these pins are the worksheet's Block-1 (energy-rating) line refs, + # i.e. the UK-average RATING cascade. `Sap10Calculator.calculate` now + # grafts the postcode DEMAND cascade's CO2/PE onto the result (SAP 10.2 + # Appendix U p.124), so the rating-cascade fields are pinned via + # `cert_to_inputs` directly; the demand cascade is pinned separately + # (corpus gauge + simulated case 45 Block-2 pins). + result = calculate_sap_from_inputs(cert_to_inputs(epc)) actual = getattr(result, field_name) # Assert diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 6547a585..ce93e21c 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -46,6 +46,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_case21 as _w001431_case21, _elmhurst_worksheet_001431_case43 as _w001431_case43, _elmhurst_worksheet_001431_case44 as _w001431_case44, + _elmhurst_worksheet_001431_case45 as _w001431_case45, ) @@ -491,6 +492,67 @@ def test_case44_blower_door_pressure_test_matches_pdf() -> None: _pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan case44") +def test_case45_heat_pump_two_climate_cascade_matches_pdf() -> None: + """Simulated case 45 (heat-pump ground-floor flat, postcode W6) is the + 1e-4 oracle for the SAP 10.2 Appendix U (p.124) two-climate-cascade + split. The P960 prints the current dwelling twice: + + * Block 1 ("11a SAP rating / 12a CO2") on UK-AVERAGE weather (region + 0): space heat (98c) 7333.79, SAP (258) 60.5318, CO2 (272) 692.13. + * Block 2 ("EPC COSTS, EMISSIONS AND PRIMARY ENERGY") on POSTCODE + weather (PCDB Table 172, W6): space heat (98c) 5921.05, CO2 (272) + 626.78, primary energy (286) 6581.59. + + The SAP/EI rating reads the rating cascade; the EPC-displayed CO2/PE + read the demand cascade. Pins both ends at abs=1e-4.""" + # Arrange + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_demand_inputs + + epc = _w001431_case45.build_epc() + # The split only exists because the postcode resolves to local weather. + assert local_climate_for_cert(epc) is not None + + # Act — both climate cascades from the one cert. + rating = calculate_sap_from_inputs(cert_to_inputs(epc)) + demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) + + # Assert — Block 1 (UK-average rating cascade). + _pin( + rating.space_heating_kwh_per_yr, + _w001431_case45.RATING_SPACE_HEATING_KWH, + "(98c) rating case45", + ) + _pin( + rating.sap_score_continuous, + _w001431_case45.RATING_SAP_CONTINUOUS, + "(258) rating case45", + ) + assert rating.sap_score == _w001431_case45.RATING_SAP_INTEGER + _pin( + rating.co2_kg_per_yr, + _w001431_case45.RATING_CO2_KG_PER_YR, + "(272) rating case45", + ) + + # Assert — Block 2 (postcode demand cascade). + _pin( + demand.space_heating_kwh_per_yr, + _w001431_case45.DEMAND_SPACE_HEATING_KWH, + "(98c) demand case45", + ) + _pin( + demand.co2_kg_per_yr, + _w001431_case45.DEMAND_CO2_KG_PER_YR, + "(272) demand case45", + ) + _pin( + demand.primary_energy_kwh_per_yr, + _w001431_case45.DEMAND_PRIMARY_ENERGY_KWH, + "(286) demand case45", + ) + + def test_case6_main_2_emitter_and_control_extracted() -> None: """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter ("Underfloor Heating") and control ("SAP code 2110, ...") — the two diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 7b510ab3..ca4681e4 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -30,11 +30,7 @@ from typing import Any import pytest from datatypes.epc.domain.mapper import EpcPropertyDataMapper -from domain.sap10_calculator.calculator import calculate_sap_from_inputs -from domain.sap10_calculator.rdsap.cert_to_inputs import ( - SAP_10_2_SPEC_PRICES, - cert_to_inputs, -) +from domain.sap10_calculator.calculator import Sap10Calculator _CORPUS = Path( "backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" @@ -127,10 +123,25 @@ _CORPUS = Path( # "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated # case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% -> # 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission. +# POSTCODE DEMAND CASCADE (SAP 10.2 Appendix U paragraph 1, p.124): the +# CO2/PE over-estimate diagnosed above as "per-cert mapper/demand fidelity" +# was largely a CLIMATE-cascade bug. The SAP/EI rating is computed on +# UK-average weather (Tables U1-U3 region 0), but EPC-displayed energy use, +# CO2 emissions and primary energy use POSTCODE-DISTRICT weather from PCDB +# Table 172 — "other calculations (such as for energy use and costs on EPCs) +# are done using local weather". We were feeding the UK-average demand to all +# three outputs, so warm-region certs (most of England, warmer than the +# UK-average) over-counted heating demand → CO2/PE high. `Sap10Calculator. +# calculate` now grafts the demand cascade's CO2/PE onto the rating cascade's +# SAP. Across the corpus this moved CO2 MAE 0.26 -> 0.12 t/yr (bias +0.18 -> +# +0.04) and PE MAE 13.6 -> 3.8 kWh/m2/yr (bias +9.0 -> +0.24); SAP unchanged +# (rating cascade). Worksheet-validated to 1e-4 on simulated case 45 (rating +# CO2 692.13; demand CO2 626.78, PE 6581.59). The residual PE/CO2 spread is +# now the genuine per-cert mapper-fidelity tail. _MIN_WITHIN_HALF_SAP = 0.695 _MAX_SAP_MAE = 0.86 -_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current +_MAX_CO2_MAE_TONNES = 0.13 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 4.2 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: @@ -155,8 +166,12 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( co2_signed_errs_t: list[float] = [] # our − lodged, tonnes/yr pe_signed_errs: list[float] = [] # our − lodged, kWh/m²/yr skipped = 0 + _calculator = Sap10Calculator() # Act — run the API → EpcPropertyData → calculator pipeline per cert. + # `Sap10Calculator.calculate` runs both climate cascades (SAP 10.2 + # Appendix U p.124): the SAP rating on UK-average weather, CO2/PE on + # postcode-district weather — exactly the two figures the EPC lodges. for doc in corpus: lodged_sap = doc.get("energy_rating_current") if lodged_sap is None: @@ -164,9 +179,7 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( continue try: epc = EpcPropertyDataMapper.from_api_response(doc) - result = calculate_sap_from_inputs( - cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) - ) + result = _calculator.calculate(epc) except Exception: # A mapper / calculator raise is a coverage gap tracked elsewhere # (eval_api_sap_accuracy.py); here we gauge the certs that compute. From 6950deae06c80c738c38a5502943ef7ab393c853 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 14:21:00 +0000 Subject: [PATCH 5/7] chore(scripts): triage PE/CO2 on the demand cascade in the corpus profilers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `profile_corpus_error.py` and `dive_cert.py` compared our PE/CO2 against the lodged EPC figures using the UK-average RATING cascade, but the EPC lodges CO2/PE on the postcode DEMAND cascade (SAP 10.2 Appendix U p.124, now wired into Sap10Calculator.calculate in fc7c4d2d). That confounded the DEMAND-vs-COST triage: a cert whose demand actually reproduced on local weather looked "PE off" purely from the climate difference and was mislabelled DEMAND-side. Switching the PE/CO2 lens to `cert_to_demand_ inputs` (SAP still from the rating cascade) re-classifies the corpus outside-0.5 set 261/42 -> 211/92 DEMAND/COST — ~50 certs are genuinely cost-side (e.g. 10091578598: SAP +7.81 but PE +1.6 / CO2 -0.04). Sharpens the hunt for the subtle widespread SAP term. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dive_cert.py | 13 +++++++++---- scripts/profile_corpus_error.py | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/dive_cert.py b/scripts/dive_cert.py index 6fa83efb..8d6da1b4 100644 --- a/scripts/dive_cert.py +++ b/scripts/dive_cert.py @@ -17,6 +17,7 @@ from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, + cert_to_demand_inputs, cert_to_inputs, ) from scripts.profile_api_error import features @@ -40,6 +41,10 @@ def _dump(doc: dict[str, Any]) -> None: lodged_pe = doc.get("energy_consumption_current") epc = EpcPropertyDataMapper.from_api_response(doc) r = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + # SAP/EI rating is the UK-average rating cascade (`r`); EPC CO2/PE use the + # postcode demand cascade (SAP 10.2 Appendix U p.124). Display CO2/PE from + # the demand cascade so they compare like-for-like with the lodged EPC. + d = calculate_sap_from_inputs(cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) print("=" * 90) print(f"CERT {cert}") print( @@ -48,13 +53,13 @@ def _dump(doc: dict[str, Any]) -> None: ) if lodged_co2 is not None: print( - f" CO2 lodged={lodged_co2:.3f} ours={r.co2_kg_per_yr / 1000:.3f} t " - f"d={r.co2_kg_per_yr / 1000 - lodged_co2:+.3f}" + f" CO2 lodged={lodged_co2:.3f} ours={d.co2_kg_per_yr / 1000:.3f} t " + f"d={d.co2_kg_per_yr / 1000 - lodged_co2:+.3f} (demand cascade)" ) if lodged_pe is not None: print( - f" PE lodged={lodged_pe:.1f} ours={r.primary_energy_kwh_per_m2:.1f} " - f"d={r.primary_energy_kwh_per_m2 - lodged_pe:+.1f} kWh/m2" + f" PE lodged={lodged_pe:.1f} ours={d.primary_energy_kwh_per_m2:.1f} " + f"d={d.primary_energy_kwh_per_m2 - lodged_pe:+.1f} kWh/m2 (demand cascade)" ) print( f" energy kWh/yr: spaceheat={r.space_heating_kwh_per_yr:.0f} " diff --git a/scripts/profile_corpus_error.py b/scripts/profile_corpus_error.py index 851086a9..6ef2c4e6 100644 --- a/scripts/profile_corpus_error.py +++ b/scripts/profile_corpus_error.py @@ -37,6 +37,7 @@ from typing import Any, Optional from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import ( + cert_to_demand_inputs, SAP_10_2_SPEC_PRICES, cert_to_inputs, ) @@ -96,6 +97,14 @@ def _compute(corpus: list[dict[str, Any]]) -> tuple[list[Row], int, int]: result = calculate_sap_from_inputs( cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) ) + # SAP/EI rating is the UK-average rating cascade (`result`); + # the EPC-displayed CO2/PE use the postcode demand cascade + # (SAP 10.2 Appendix U p.124). Use the demand cascade for the + # PE/CO2-vs-cost triage so it is not confounded by the climate + # difference (UK-average vs local weather). + demand = calculate_sap_from_inputs( + cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) except Exception: raised += 1 continue @@ -110,14 +119,14 @@ def _compute(corpus: list[dict[str, Any]]) -> tuple[list[Row], int, int]: rows.append(Row( cert=cert, sap_err=result.sap_score_continuous - lodged_sap, - co2_err_t=(result.co2_kg_per_yr / 1000.0 - lodged_co2_t) + co2_err_t=(demand.co2_kg_per_yr / 1000.0 - lodged_co2_t) if lodged_co2_t is not None else None, - pe_err=(result.primary_energy_kwh_per_m2 - lodged_pe) + pe_err=(demand.primary_energy_kwh_per_m2 - lodged_pe) if lodged_pe is not None else None, lodged_sap=lodged_sap, our_sap=result.sap_score_continuous, lodged_pe=lodged_pe, - our_pe=result.primary_energy_kwh_per_m2, + our_pe=demand.primary_energy_kwh_per_m2, feats=features(doc), )) return rows, skipped, raised From 8942d457723a29ebf0ca62d170182b95c03ebf33 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 14:52:58 +0000 Subject: [PATCH 6/7] fix(fuel): price secondary dual-fuel/anthracite at their own rate, not the colliding LPG code (RdSAP 10 Table 32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov-API lodges secondary fuel as an enum whose value can COLLIDE with a different same-valued RdSAP 10 Table 32 / SAP 10.2 Table 12 fuel code: - enum 9 = "dual fuel (mineral and wood)" vs Table code 9 = LPG SC11F - enum 5 = "anthracite" vs Table code 5 = LPG (bulk) The main-fuel boundary already canonicalises these (`_GOV_API_COLLISION_ FUELS`), but the SECONDARY-heating cost + CO2/PE paths never did — they took the bare same-value lookup, so a dual-fuel room heater was priced as LPG (3.48 vs dual-fuel 3.99 p/kWh) and emitted as LPG (CO2 0.241 vs 0.087), and an anthracite secondary as bulk LPG (12.19 vs 3.64 p/kWh). The price under-count over-rates SAP; the CO2 over-count inflates emissions. Fix: add enum 9 to `_GOV_API_COLLISION_FUELS` (5 and 33 were already there) and canonicalise the secondary fuel code on both the cost (`_secondary_fuel_cost_gbp_per_kwh`) and factor (`_secondary_fuel_code`) paths, mirroring the main-fuel boundary. canonical_fuel_code only touches {5,9,33}, so genuinely Table-coded secondaries (House coal 11, wood logs 20, community fuels 30-32) are left unchanged — confirmed by a full-map audit. Corpus: within-0.5 69.7% -> 70.2% (MAE 0.854 -> 0.845; dual-fuel-secondary cohort 42.9% -> 49.0%, signed +0.55 -> +0.41) and CO2 MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). Ratcheted the corpus floors (within 0.70, MAE 0.85, CO2 0.09, PE 4.0). A prior session deferred enum 9 ("direction not understood") while the EPC PE/CO2 lens was confounded by the climate-cascade bug (fc7c4d2d); on the corrected lens the over-rate direction is clear. pyright not installed in this codespace (strict gate not run locally). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 10 ++++- domain/sap10_calculator/tables/table_32.py | 18 +++++--- .../rdsap/test_cert_to_inputs.py | 42 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 20 +++++++-- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a271f8f5..49576723 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2772,7 +2772,11 @@ def _secondary_fuel_cost_gbp_per_kwh( meter_type, fuel_is_electric=True ): return _secondary_off_peak_rate_gbp_per_kwh(meter_type) - return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP + # Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose + # value collides with Table-32 9 = LPG SC11F) before the price lookup, + # exactly as the main-fuel boundary does — otherwise the same-value + # Table lookup mis-prices the secondary at the colliding fuel's rate. + return prices.unit_price_p_per_kwh(canonical_fuel_code(sec_fuel)) * _PENCE_TO_GBP def _pv_array_generation_kwh_per_yr( @@ -3927,6 +3931,10 @@ def _secondary_fuel_code(epc: EpcPropertyData) -> int: code = _int_or_none(epc.sap_heating.secondary_fuel_type) if code is None: return _STANDARD_ELECTRICITY_FUEL_CODE + # Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose value + # collides with the LPG Table code) so the CO2/PE factor lookups resolve + # to the lodged fuel — mirrors the main-fuel boundary + the cost side. + code = canonical_fuel_code(code) or code if code in CO2_KG_PER_KWH: return code return _table_12_factor_fuel_code(code) diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 8377fe86..14544aea 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -121,11 +121,17 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = { # 33 = coal — Table-32 code 33 is the electricity 10-hour low rate # 7.5 p vs house coal 3.67 p (and `is_electric_fuel_code(33)` # wrongly classified the coal main as electric). -# DEFERRED (not included): API 9 = dual fuel (mineral + wood) is also a -# collision (Table-32 9 = LPG SC11F 3.48 p vs dual fuel 3.99 p) but the -# 0.45 p delta nets neutral-to-negative on the (outlier-dominated) -# dual-fuel certs and shifts them in a direction not yet understood — -# investigate separately. +# 9 = dual fuel (mineral + wood) — Table-32 code 9 is LPG SC11F +# 3.48 p vs dual fuel 3.99 p. The gov-API lodges API enum 9 for a +# dual-fuel appliance (description "Room heaters, dual fuel +# (mineral and wood)"), but the same-value Table-32 lookup returns +# LPG 3.48 p, under-pricing the (mostly secondary) dual-fuel heat. +# A prior session deferred this as "direction not understood" +# while the EPC PE/CO2 lens was confounded by the climate-cascade +# bug (fixed in fc7c4d2d); on the corrected lens the dual-fuel +# secondary cohort over-rates (SAP too high = cost too low) by +# +0.55 signed, and pricing UP to the dual-fuel 3.99 p row reduces +# that over-rate — the correct direction. # # COMMUNITY FUELS (handled elsewhere, NOT here): API 30 (waste # combustion), 31 (biomass) and 32 (biogas) — all "(community)" in the @@ -140,7 +146,7 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = { # cert_to_inputs), where the community meaning is unambiguous. Community # fuels 20/25 do not collide with an electricity code, so they resolve # correctly through the heat-network path without any special handling. -_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 33}) +_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 9, 33}) def canonical_fuel_code(fuel_code: Optional[int]) -> Optional[int]: diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index e3e99e9e..2ead1bef 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -80,6 +80,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] + _secondary_fuel_code, # pyright: ignore[reportPrivateUsage] _secondary_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] _section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage] @@ -2162,6 +2163,47 @@ def test_is_electric_main_dual_fuel_table_32_code_10_is_not_electric() -> None: assert _is_electric_main(community_electric_main) is False +def test_dual_fuel_secondary_api_enum_9_prices_as_dual_fuel_not_lpg() -> None: + # Arrange — the gov-API lodges secondary fuel enum 9 = "dual fuel (mineral + # and wood)", but enum value 9 COLLIDES with the same-valued RdSAP 10 + # Table 32 / SAP 10.2 Table 12 code 9 = "LPG (bulk, SC11F)". The secondary + # cost + CO2/PE paths previously took the same-value lookup (LPG 3.48 + # p/kWh, CO2 0.241 kg/kWh) instead of translating the enum to the dual- + # fuel row (3.99 p/kWh, CO2 0.087) — under-costing the secondary (SAP + # over-rate) AND over-counting its CO2 (LPG is fossil; dual fuel is part + # wood). Enum 9 is now in `_GOV_API_COLLISION_FUELS`, and both secondary + # paths canonicalise (mirroring the main-fuel boundary). SAP 10.2 Table + # 12 (p.189) / RdSAP 10 Table 32 (p.95). + gas_boiler_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + dual_fuel_secondary_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_heating=make_sap_heating( + main_heating_details=[gas_boiler_main], + secondary_fuel_type=9, # gov-API enum: dual fuel (mineral + wood) + secondary_heating_type=631, + ), + ) + + # Act — the rating-cascade secondary price + the CO2/PE fuel code. + secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh( + dual_fuel_secondary_epc.sap_heating, + gas_boiler_main, + 2, # standard (single-rate) meter + SAP_10_2_SPEC_PRICES, + ) + secondary_factor_code = _secondary_fuel_code(dual_fuel_secondary_epc) + + # Assert — dual fuel 3.99 p/kWh (NOT LPG 3.48) + Table code 10 (NOT 9). + assert abs(secondary_rate_gbp_per_kwh - 0.0399) <= 1e-6 + assert secondary_factor_code == 10 + + def test_is_electric_water_dual_fuel_table_32_code_10_is_not_electric() -> None: # Arrange — same API/Table 32 collision as `_is_electric_main` per # S0380.136 docstring. diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index ca4681e4..ef933adb 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -138,10 +138,22 @@ _CORPUS = Path( # (rating cascade). Worksheet-validated to 1e-4 on simulated case 45 (rating # CO2 692.13; demand CO2 626.78, PE 6581.59). The residual PE/CO2 spread is # now the genuine per-cert mapper-fidelity tail. -_MIN_WITHIN_HALF_SAP = 0.695 -_MAX_SAP_MAE = 0.86 -_MAX_CO2_MAE_TONNES = 0.13 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 4.2 # kWh / m2 / yr vs energy_consumption_current +# DUAL-FUEL SECONDARY COLLISION (RdSAP 10 Table 32 / SAP 10.2 Table 12): the +# gov-API lodges fuel enum 9 ("dual fuel, mineral and wood") for a dual-fuel +# room heater, but enum 9 collides with the same-valued Table-32/12 code 9 +# (LPG SC11F), so the price (3.48 vs dual-fuel 3.99 p/kWh) AND the CO2/PE +# factors (LPG 0.241 / 1.163 vs dual fuel 0.087 / 1.049) resolved to LPG — +# the secondary was under-costed (→ SAP over-rate) and over-counted on CO2. +# Canonicalising enum 9 (now in `_GOV_API_COLLISION_FUELS`) on the secondary +# cost + factor paths took within-0.5 69.7% -> 70.2% (MAE 0.854 -> 0.845; +# dual-fuel-secondary cohort 42.9% -> 49.0%, signed +0.55 -> +0.41) and CO2 +# MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). A prior session deferred enum 9 +# ("direction not understood") while the PE/CO2 lens was confounded by the +# climate-cascade bug (fc7c4d2d); the corrected lens shows the over-rate. +_MIN_WITHIN_HALF_SAP = 0.70 +_MAX_SAP_MAE = 0.85 +_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: From 2e416a02217f1bab4bc05dec990e142d74fa0017 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 15:01:26 +0000 Subject: [PATCH 7/7] chore: untrack .claude/settings.json (machine-specific personal config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/settings.json` was committed to main by mistake — it holds per-developer permission allow-lists (npx cache paths, /tmp script paths, and even hardcoded credentials), not shared project config. Mirror the existing `.claude/settings.local.json` treatment: remove it from the index and add it to .gitignore so each developer keeps their own local copy. Claude Code merges settings.json + settings.local.json at runtime, so no permissions are lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/settings.json | 168 ------------------------------------------ .gitignore | 1 + 2 files changed, 1 insertion(+), 168 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index f9ab3156..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(python -c ' *)", - "Bash(python -m pytest tests/ -v --no-cov)", - "Bash(git -C /workspaces/model diff --stat)", - "Bash(python -c \"import moto; print\\('moto installed:', moto.__version__\\)\")", - "Bash(grep -E \"\\\\.\\(py|sql\\)$\")", - "Bash(xargs basename -a)", - "Bash(ls -la /workspaces/home 2>/dev/null | head -20)", - "Read(//workspaces/home/**)", - "Bash(command -v uv)", - "Bash(uv --version)", - "Bash(echo \"uv: $\\(uv --version\\)\")", - "Bash(python -m pyright --version)", - "Bash(npx --no-install pyright --version)", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js --outputjson etl/hubspot/hubspotClient.py etl/hubspot/hubspotDataTodB.py etl/hubspot/project_data.py etl/hubspot/scripts/scraper/main.py backend/app/db/models/hubspot_project_data.py backend/app/db/models/hubspot_deal_data.py etl/hubspot/tests/test_scraper_handler.py etl/hubspot/tests/test_hubspot_data_to_db.py etl/hubspot/tests/test_hubspot_client_integration.py)", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); s=d['summary']; print\\('errors',s['errorCount'],'warnings',s['warningCount']\\); [print\\(f\\\\\"{x['severity']}: {x['file'].split\\('/'\\)[-1]}:{x['range']['start']['line']+1} {x['rule'] if 'rule' in x else ''} -- {x['message'].splitlines\\(\\)[0]}\\\\\"\\) for x in d['generalDiagnostics'] if x['severity']=='error']\")", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js etl/hubspot/project_data.py backend/app/db/models/hubspot_project_data.py)", - "Bash(python -c \"import sqlmodel, sqlalchemy, hubspot; print\\('deps importable'\\)\")", - "Bash(python -m pytest --version)", - "Bash(python -m pytest etl/hubspot/tests/ -m \"not integration\" -p no:cacheprovider -o addopts=\"\" -q)", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js --pythonpath /usr/local/bin/python etl/hubspot/project_data.py backend/app/db/models/hubspot_project_data.py)", - "Bash(node /home/vscode/.npm/_npx/110e52990071af13/node_modules/pyright/dist/pyright.js --pythonpath /usr/local/bin/python etl/hubspot/company_data.py)", - "Bash(python /tmp/inspect_project.py)", - "Read(//home/vscode/github/**)", - "Bash(find / -maxdepth 6 -type d -name assessment-model)", - "Bash(terraform fmt *)", - "Bash(terraform init *)", - "Bash(terraform validate *)", - "Bash(python -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py -q)", - "Bash(python -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py tests/repositories/landlord_overrides -q --no-cov)", - "Bash(python -m py_compile tests/infrastructure/chatgpt/test_chatgpt_column_classifier.py)", - "Bash(git add *)", - "Bash(git status *)", - "Bash(cp pyrightconfig.json /tmp/pyrightconfig.bak.json)", - "Bash(python3 -)", - "Bash(npx --yes pyright infrastructure/postgres/landlord_overrides_postgres_repository.py applications/landlord_description_overrides/handler.py tests/repositories/landlord_overrides/postgres/test_landlord_overrides_postgres_repository.py orchestration/classifiable_column.py)", - "Bash(git stash *)", - "Bash(npx --yes pyright applications/landlord_description_overrides/handler.py)", - "Bash(python3 -m pytest tests/repositories/landlord_overrides/postgres/test_landlord_overrides_postgres_repository.py -q)", - "Bash(python3 -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py tests/repositories/landlord_overrides/ -q -p no:cov)", - "Bash(python3 -m pytest tests/orchestration/test_landlord_description_overrides_orchestrator.py tests/repositories/landlord_overrides/ -q)", - "Bash(cp pyrightconfig.json /tmp/pyrightconfig.bak2.json)", - "Bash(npx --yes pyright infrastructure/landlord_overrides/landlord_overrides_postgres_repository.py tests/repositories/landlord_overrides/postgres/test_landlord_overrides_postgres_repository.py)", - "Bash(GIT_EDITOR=true git rebase --continue)", - "Bash(git worktree *)", - "Bash(git branch *)", - "Bash(echo \"exit: $?\")", - "Bash(git reset *)", - "Bash(echo \"fetch-exit: $?\")", - "Bash(sed -n *)", - "Bash(set -e)", - "Bash(git rm *)", - "Bash(git ls-tree *)", - "Bash(command -v pyright)", - "Bash(git merge *)", - "Bash(git rev-list *)", - "Bash(git remote *)", - "Bash(git ls-remote *)", - "Bash(grep -v '\\\\.sample$')", - "Bash([ -f \".git/hooks/$h\" ])", - "Bash(python3 -m pytest tests/test_lambda_packaging.py -p no:cacheprovider --no-header -q)", - "Bash(python3 -m pytest tests/test_lambda_packaging.py -p no:cov --no-header -q)", - "Bash(python3 -m pytest tests/test_lambda_packaging.py --no-cov -p no:cacheprovider -q)", - "Bash(python3 -m pytest tests/test_lambda_packaging.py --no-cov -q)", - "Bash(python3 -m pyright tests/test_lambda_packaging.py)", - "Bash(python3 -m pytest tests/ --collect-only --no-cov -q)", - "Bash(python3 -c \"import yaml; [print\\(f, 'OK'\\) for f in ['.github/workflows/ddd_tests.yml','.github/workflows/unit_tests.yml'] if yaml.safe_load\\(open\\(f\\)\\)]\")", - "Bash(python3 -m pytest tests/ -q --no-cov)", - "Bash(python3 -c ' *)", - "Bash(python3 -m pyright --outputjson scripts/hyde/main.py)", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); s=d['summary']; print\\('errors:',s['errorCount'],'warnings:',s['warningCount']\\); [print\\(f\\\\\" L{e['range']['start']['line']+1}: {e['message'].splitlines\\(\\)[0]}\\\\\"\\) for e in d['generalDiagnostics'] if e['severity']=='error']\")", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); s=d['summary']; print\\('pyright errors:',s['errorCount'],'warnings:',s['warningCount']\\); [print\\(f\\\\\" L{e['range']['start']['line']+1}: {e['message'].splitlines\\(\\)[0]}\\\\\"\\) for e in d['generalDiagnostics'] if e['severity']=='error']\")", - "Bash(python3 main.py)", - "Bash(netstat -ltnp)", - "Bash(fuser 8000/tcp)", - "Bash(kill 351610 685390 351625)", - "Read(//proc/531213/net/**)", - "Bash(ps -p 351610 -o pid=)", - "Bash(python -)", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis::test_cert_omitting_sap_windows_maps_without_missing_required_field\")", - "Bash(python -m pytest datatypes/epc/domain/tests/test_from_rdsap_schema.py infrastructure/epc_client/tests/test_mapper_corpus.py)", - "Bash(git -C /workspaces/model stash push -u -- datatypes/epc/schema/rdsap_schema_20_0_0.py datatypes/epc/domain/mapper.py datatypes/epc/domain/tests/test_from_rdsap_schema.py)", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestFromRdSapSchema21_0_1::test_total_floor_area\")", - "Bash(git -C /workspaces/model stash pop)", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis::test_band_more_than_typical_scales_glazing_by_1_25\")", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis\")", - "Bash(python -m pytest \"datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestRdSap20_0_0ReducedFieldSynthesis::test_synthesised_glazing_type_routed_through_cascade\")", - "Bash(python -m pytest infrastructure/epc_client/tests/test_mapper_corpus.py -k \"wip_schema_20\" -q -p no:cov --no-header -rN)", - "Bash(python -m pytest infrastructure/epc_client/tests/test_mapper_corpus.py -k \"wip_schema_20\" -q --no-header)", - "Bash(timeout 150 python scripts/eon/find_epc_data.py)", - "Bash(python -m scripts.run_modelling_e2e --scenario-id 1266 --portfolio-id 785 --measures high_heat_retention_storage_heaters,solar_pv 709634 709635 709636 709638 709639 709640 709641 709642 709644)", - "Read(//home/vscode/.claude/skills/grill-me/**)", - "Bash(awk '{print $9, $5}')", - "Bash(grep -E \"\\\\.py$\")", - "Bash(python3 scripts/eon/harvest_certs.py)", - "Bash(python scripts/eon/harvest_certs.py)", - "Bash(git commit -q -m ' *)", - "Bash(timeout 250 npx -y pyright@1.1.410 datatypes/epc/domain/mapper.py)", - "Bash(git check-ignore *)", - "Bash(timeout 250 npx -y pyright@1.1.410 datatypes/epc/schema/rdsap_schema_18_0.py datatypes/epc/domain/mapper.py)", - "Bash(timeout 250 npx -y pyright@1.1.410 datatypes/epc/schema/rdsap_schema_17_1.py datatypes/epc/domain/mapper.py)", - "Bash(python -m scripts.run_modelling_e2e --scenario-id 1266 --portfolio-id 785 --measures high_heat_retention_storage_heaters,solar_pv 709634 709635 709636 709638 709639 709640 709641 709642 709643 709644 709645 709637)", - "Read(//workspaces/**)", - "Bash(python -m scripts.run_modelling_e2e --scenario-id 1266 --portfolio-id 785 --measures high_heat_retention_storage_heaters,solar_pv 709634)", - "Bash(python 2_export_data.py)", - "Bash(git commit -q -m 'Map RdSAP-Schema-17.0 certs to EpcPropertyData 🟥 *)", - "Bash(python -m py_compile tests/utilities/floats.py backend/app/db/functions/tests/test_portfolio_functions.py backend/documents_parser/tests/test_summary_pdf_mapper_chain.py backend/documents_parser/tests/test_heating_systems_corpus.py)", - "Bash(PYTHONPATH=/workspaces/model python -)", - "Bash(npx --no-install pyright datatypes/epc/schema/sap_schema_17_1.py datatypes/epc/domain/mapper.py datatypes/epc/domain/tests/test_from_sap_schema.py)", - "Bash(git commit -q -m 'Map full-SAP cert identity and scalar fields to EpcPropertyData 🟩 *)", - "Bash(npx --no-install pyright --outputjson datatypes/epc/domain/mapper.py)", - "Bash(git commit -q -m 'Carry full-SAP measured fabric U-value descriptions into the domain model 🟩 *)", - "Bash(python scripts/hyde/elmhurst_session.py --help)", - "Bash(python scripts/hyde/elmhurst_session.py status)", - "Bash(DISPLAY=:99 import -window root /tmp/hyde-viewer/elm_now2.png)", - "Bash(DISPLAY=:99 timeout 90 python scripts/hyde/elmhurst_explore.py \"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105&Referrer=https%3a%2f%2fmembers.elmhurstenergy.co.uk%2fRdsapZone%2fHome.aspx\")", - "Bash(DISPLAY=:99 timeout 90 python scripts/hyde/elmhurst_explore.py \"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormPropertyDescription.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105\")", - "Bash(DISPLAY=:99 timeout 90 python scripts/hyde/elmhurst_explore.py \"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105\")", - "Bash(DISPLAY=:99 timeout 150 python scripts/hyde/elmhurst_fill.py)", - "Bash(pkill -f \"rdsap10\\\\|for-testing\")", - "Bash(rm -f scripts/hyde/.elmhurst-session/Singleton*)", - "Bash(DISPLAY=:99 timeout 280 python -u scripts/hyde/elmhurst_fill.py --page property_description --commit)", - "Bash(npx --no-install pyright scripts/hyde/elmhurst_download.py)", - "Edit(/.claude/skills/expand-sap-accuracy-corpus/**)", - "Bash(PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py 10093116528)", - "Bash(PYTHONPATH=/workspaces/model python -c ' *)", - "Bash(bash scripts/hyde/start_viewer.sh)", - "Bash(DISPLAY=:99 ELMHURST_URL=\"https://rdsap10online.elmhurstenergy.co.uk/Processing/WebFormAddress.aspx?Guid=B44A0DB4-4C08-4241-B818-86F060172105\" python scripts/hyde/elmhurst_session.py login)", - "Bash(break)", - "Bash(DISPLAY=:99 timeout 90 python /tmp/check_session.py)", - "Bash(pkill -9 -f \"elm_login_hold.py\")", - "Bash(pkill -9 -f \"elmhurst-session\")", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 150 python /tmp/elm_build_dims.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_dump_full.py Walls WebFormWalls.aspx)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_walls_disc.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_roof_disc.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_dump_full.py Openings WebFormOpenings.aspx)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_win_probe.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 220 python /tmp/elm_build_openings.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_tab_probe.py)", - "Bash(ELM_ACCESS=\"P960-0001\" ELM_PWD=\"y22bseGUzr\" DISPLAY=:99 timeout 120 python /tmp/elm_del_probe.py)", - "Bash(npx --no-install pyright datatypes/epc/domain/epc_property_data.py datatypes/epc/domain/mapper.py domain/sap10_calculator/rdsap/cert_to_inputs.py)", - "Bash(PYTHONPATH=/workspaces/model python -m pytest domain/sap10_calculator/tests/test_sap_accuracy_corpus.py -q --no-cov)", - "Bash(PYTHONPATH=/workspaces/model python -m pytest tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py -q --no-cov)", - "Edit(/.claude/skills/epc-to-elmhurst-rdsap-inputs/**)", - "Bash(python3 -c \"import sys; sys.path.insert\\(0,'scripts/hyde'\\); import elmhurst_lib; print\\('elmhurst_lib imports OK:', [f for f in dir\\(elmhurst_lib\\) if not f.startswith\\('_'\\)][:12]\\)\")", - "Bash(PYTHONPATH=/workspaces/model python -m pytest tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py tests/infrastructure/epc_client/test_sap_accuracy_corpus.py -q --no-cov)", - "Bash(PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py 10093116543)", - "Bash(echo \"---EXIT $?---\")", - "Bash(sudo apt-get update -o Dir::Etc::sourceparts=-)", - "Bash(apt-cache policy *)", - "Bash(sudo apt-get update)", - "Bash(bash scripts/hyde/start_viewer.sh restart)", - "Bash(echo \"=== listen sockets \\(port 6080 / 5900\\) ===\"; ss -ltnp 2>/dev/null | grep -E ':6080|:5900' || netstat -ltnp 2>/dev/null | grep -E ':6080|:5900' || echo \"ss/netstat unavailable\" *)", - "Read(//proc/5476/**)", - "Bash(sudo python -m playwright install-deps chromium)", - "Bash(python -m playwright install chromium)", - "Bash(python -m playwright --version)", - "Bash(pip show *)" - ], - "additionalDirectories": [ - "/tmp", - "/workspaces/model/scripts/hyde/.elmhurst-session" - ] - } -} diff --git a/.gitignore b/.gitignore index 9cb34cb3..9c77b311 100644 --- a/.gitignore +++ b/.gitignore @@ -304,6 +304,7 @@ backlog/* # Local Claude config files .claude/*modelling_cohort.csv .claude/settings.local.json +.claude/settings.json # Local EPC debug cache (scripts/eon) scripts/eon/epc_cache.pkl