From 6385a0be85fac32c14d6de238c39573200a7a58b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:01:11 +0000 Subject: [PATCH 01/13] =?UTF-8?q?fix(mapper):=20map=20dropped=20=C2=A73.9.?= =?UTF-8?q?2=20Simplified=20Type-2=20room-in-roof=20(API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov API lodges a §3.9.2 Simplified Type-2 RR (a room-in-roof bounded by continuous common walls) under `room_in_roof_type_2` — gable + common-wall lengths AND heights. The block was undeclared → `from_dict` dropped it → neither the Type-1 nor Detailed path fired → the cascade's Simplified branch billed the WHOLE A_RR shell (12.5√(floor/1.5)) at the Table-18-col-4 default with no gable/common-wall deduction (over-count → under-rate; 7 corpus certs at signed −5.02). Fix: declare `RoomInRoofType2` on rdsap_schema_21_0_0/_21_0_1 + SapRoomInRoof, and build `detailed_surfaces` by MIRRORING the worksheet-validated Summary path (`_map_elmhurst_rir_surface`, is_simplified) rather than back-solving: common wall → L × (0.25 + H) (billed at the main-wall U) gable → L × (0.25 + H) − Σ (H − H_cw)²/2 (RdSAP 10 §3.9.2 + Table 4) The gable correction sums all common walls (exposed/party/sheltered, incl. the H=0 absent-gable negative-area case that deducts from the A_RR residual); a Connected gable sums only the common walls it overtops. The `gable_wall_type_*` code routes the kind (0/1/2/3 = Party/Exposed/Sheltered/ Connected). A raw-L×H prototype scattered; the §3.9.2 quadratic is the missing piece. Validation is cross-mapper parity, NOT a corpus back-solve: `_api_type_2_ surfaces` produces surfaces IDENTICAL to the Summary path on cohort cert 000565 (connected_wall 3.68, gable_wall_external 16.08/27.68, common walls, and the −0.17 absent-gable quadratic), and 000565 is pinned to 1e-4 in the harness — so the API RR fabric is now correct by construction. The remaining type-2 cohort SAP scatter is unrelated per-cert causes (stone walls, secondary fuel), not the RR. Gauges: corpus within-0.5 67.6% → 67.9% (MAE 0.979 → 0.959); /tmp 71.7% → 71.8% (MAE 0.838 → 0.822). Harness 47/47 (000565 unchanged); regression = the 3 pre-existing fails; pyright net-zero (65=65). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 76 +++++++++++++++++++ .../domain/tests/test_from_rdsap_schema.py | 44 +++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 17 +++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 30 +++++++- 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b3fc944f..b79b51d1 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3931,12 +3931,88 @@ def _api_build_room_in_roof( # §3.9.1 default RR storey height (2.45 m); the type code routes # the U-value (Exposed → main-wall U, Party → 0.25). rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1) + type_2 = getattr(bp_rir, "room_in_roof_type_2", None) + if type_2 is not None: + rir.detailed_surfaces = _api_type_2_surfaces(type_2) details = getattr(bp_rir, "room_in_roof_details", None) if details is not None: rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat) return rir +def _api_type_2_surfaces( + type_2: Any, +) -> Optional[List[SapRoomInRoofSurface]]: + """Translate the §3.9.2 Simplified Type 2 block into the per-surface + list the cascade's Detailed-RR branch consumes — MIRRORING the + worksheet-validated Summary path (`_map_elmhurst_rir_surface`, + is_simplified, validated to 1e-4 by cohort cert 000565). Unlike the + Type 1 block (gable lengths only, billed raw L × 2.45), Type 2 lodges + gable + common-wall lengths AND heights, so the spec's §3.9.2 areas + apply: + common wall → `L × (0.25 + H)` (billed at uw) + gable → `L × (0.25 + H_gable) + − Σ_each_common (H_gable − H_common,n)² / 2` + The gable correction is taken over ALL common walls for an exposed/ + party/sheltered gable (the worksheet evaluates it literally, incl. the + H_gable=0 absent-gable case → a negative area that deducts from the + A_RR residual without billing a physical wall); a Connected gable + (Table 4 row 4, U=0) sums only the common walls it overtops, matching + the Summary's connected-gable branch. The `gable_wall_type_*` code + routes the kind (0 Party / 1 Exposed / 2 Sheltered / 3 Connected) via + `_api_type_1_gable_kind`; U-values are left to the cascade (no per- + gable U is lodged on the API path).""" + cw_heights = [ + float(h) + for length, h in ( + (type_2.common_wall_length_1, type_2.common_wall_height_1), + (type_2.common_wall_length_2, type_2.common_wall_height_2), + ) + if length is not None and h is not None and length > 0 and h > 0 + ] + surfaces: List[SapRoomInRoofSurface] = [] + gable_specs = ( + (type_2.gable_wall_type_1, type_2.gable_wall_length_1, + type_2.gable_wall_height_1), + (type_2.gable_wall_type_2, type_2.gable_wall_length_2, + type_2.gable_wall_height_2), + ) + for gable_type, length, height in gable_specs: + # Length is mandatory; H may be 0 for the §3.9.2 absent-gable + # quadratic (only when common walls drive the correction). + if length is None or length <= 0 or height is None: + continue + if height <= 0 and not cw_heights: + continue + kind = _api_type_1_gable_kind(gable_type) + length_m, height_m = float(length), float(height) + if cw_heights: + if kind == "connected_wall": + correction = sum( + ((height_m - h) ** 2) / 2.0 for h in cw_heights if height_m > h + ) + else: + correction = sum(((height_m - h) ** 2) / 2.0 for h in cw_heights) + area = _round_half_up_2dp(1.0, length_m * (0.25 + height_m) - correction) + else: + area = _round_half_up_2dp(length_m, height_m) + surfaces.append(SapRoomInRoofSurface(kind=kind, area_m2=area)) + common_specs = ( + (type_2.common_wall_length_1, type_2.common_wall_height_1), + (type_2.common_wall_length_2, type_2.common_wall_height_2), + ) + for length, height in common_specs: + if length is None or height is None or length <= 0 or height <= 0: + continue + surfaces.append( + SapRoomInRoofSurface( + kind="common_wall", + area_m2=_round_half_up_2dp(float(length), 0.25 + float(height)), + ) + ) + return surfaces or None + + def _api_rir_detailed_surfaces( details: Any, *, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 054e9864..84795363 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2228,3 +2228,47 @@ class TestRoomInRoofDetailedSlopeAndStudWall: assert studs[0].insulation_thickness_mm == 75 assert abs(commons[0].area_m2 - 10.32) <= 1e-9 assert commons[0].insulation_thickness_mm is None + + +class TestRoomInRoofType2SimplifiedQuadratic: + """RdSAP 10 §3.9.2 Simplified Type 2 RR — the gov API lodges gable + + common-wall lengths AND heights under `room_in_roof_type_2`. The block + was undeclared → dropped → the cascade billed the whole A_RR shell at + the Table-18-col-4 default (over-count → under-rate, 7 corpus certs at + signed −5.02). The mapper now MIRRORS the worksheet-validated Summary + §3.9.2 areas (cross-mapper parity, proven identical on cohort cert + 000565): common walls L×(0.25+H), gables L×(0.25+H) − Σ(H−H_cw)²/2.""" + + def test_from_api_response_applies_3_9_2_gable_quadratic(self) -> None: + # Arrange — two common walls (L=8, H=1 → cw_heights [1,1]); an + # exposed gable (L=10, H=2) and a party gable (L=6, H=2). + # common wall = round(8 × (0.25+1)) = 10.00 + # exposed gable= round(10 × (0.25+2) − 2×(2−1)²/2) = round(22.5−1) = 21.50 + # party gable = round(6 × (0.25+2) − 1.0) = round(13.5−1) = 12.50 + cert = load("21_0_1.json") + rir = cert["sap_building_parts"][0]["sap_room_in_roof"] + rir.pop("room_in_roof_type_1", None) + rir["room_in_roof_type_2"] = { + "gable_wall_type_1": 1, "gable_wall_length_1": 10.0, + "gable_wall_height_1": 2.0, + "gable_wall_type_2": 0, "gable_wall_length_2": 6.0, + "gable_wall_height_2": 2.0, + "common_wall_length_1": 8.0, "common_wall_height_1": 1.0, + "common_wall_length_2": 8.0, "common_wall_height_2": 1.0, + } + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert + rir_part = result.sap_building_parts[0].sap_room_in_roof + assert rir_part is not None + surfaces = rir_part.detailed_surfaces + assert surfaces is not None + ext = [s for s in surfaces if s.kind == "gable_wall_external"] + party = [s for s in surfaces if s.kind == "gable_wall"] + commons = [s for s in surfaces if s.kind == "common_wall"] + assert len(ext) == 1 and abs(ext[0].area_m2 - 21.50) <= 1e-9 + assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9 + assert len(commons) == 2 + assert abs(commons[0].area_m2 - 10.00) <= 1e-9 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 4f7e7e40..c9565d9b 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -236,12 +236,29 @@ class RoomInRoofDetails: common_wall_height_2: Optional[float] = None +@dataclass +class RoomInRoofType2: + """RdSAP §3.9.2 Simplified Type 2 RR — gable + common-wall geometry. + See `rdsap_schema_21_0_1.RoomInRoofType2`. Previously dropped.""" + gable_wall_type_1: Optional[int] = None + gable_wall_type_2: Optional[int] = None + gable_wall_length_1: Optional[float] = None + gable_wall_length_2: Optional[float] = None + gable_wall_height_1: Optional[float] = None + gable_wall_height_2: Optional[float] = None + common_wall_length_1: Optional[float] = None + common_wall_length_2: Optional[float] = None + common_wall_height_1: Optional[float] = None + common_wall_height_2: Optional[float] = None + + @dataclass class SapRoomInRoof: """Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0.""" floor_area: Union[int, float] construction_age_band: str room_in_roof_type_1: Optional[RoomInRoofType1] = None + room_in_roof_type_2: Optional[RoomInRoofType2] = None room_in_roof_details: Optional[RoomInRoofDetails] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 37714034..0c6379d9 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -278,14 +278,38 @@ class RoomInRoofDetails: common_wall_height_2: Optional[float] = None +@dataclass +class RoomInRoofType2: + """RdSAP §3.9.2 Simplified Type 2 RR — a room-in-roof bounded by + continuous common walls (accessible common-wall height < 1.8 m, so the + space counts as RR not a separate storey). Lodges gable + common-wall + lengths AND heights (unlike Type 1, gable lengths only). `gable_wall_ + type_*` is the Table 4 variant (0 Party / 1 Exposed / 2 Sheltered / + 3 Connected). Previously undeclared → dropped by `from_dict`, so the + cascade billed the whole A_RR shell at the Table-18-col-4 default + (over-count → under-rate).""" + gable_wall_type_1: Optional[int] = None + gable_wall_type_2: Optional[int] = None + gable_wall_length_1: Optional[float] = None + gable_wall_length_2: Optional[float] = None + gable_wall_height_1: Optional[float] = None + gable_wall_height_2: Optional[float] = None + common_wall_length_1: Optional[float] = None + common_wall_length_2: Optional[float] = None + common_wall_height_1: Optional[float] = None + common_wall_height_2: Optional[float] = None + + @dataclass class SapRoomInRoof: floor_area: Union[int, float] construction_age_band: str - # Two real-API shapes coexist: older certs (cohort 6035, 0240, test - # fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer - # certs (9501) lodge the Detailed-RR block. Accept both. + # Three real-API shapes coexist: older certs (cohort 6035, 0240, test + # fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; some lodge + # the §3.9.2 Simplified Type 2 wrapper (gable + common-wall geometry); + # newer certs (9501) lodge the Detailed-RR block. Accept all three. room_in_roof_type_1: Optional[RoomInRoofType1] = None + room_in_roof_type_2: Optional[RoomInRoofType2] = None room_in_roof_details: Optional[RoomInRoofDetails] = None From 688bb4d601db1fe3c31a2432a9c8afcc6a777eeb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:01:34 +0000 Subject: [PATCH 02/13] =?UTF-8?q?test(corpus):=20ratchet=20SAP=20ceiling?= =?UTF-8?q?=200.99->0.97=20(=C2=A73.9.2=20Type-2=20RR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- tests/infrastructure/epc_client/test_sap_accuracy_corpus.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index aa05c9af..5f8d3669 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -95,8 +95,11 @@ _CORPUS = Path( # 67.3% -> 67.5% (MAE 1.020 -> 0.987). The follow-on `common_wall_*` Detailed-RR # surfaces (billed at main-wall U, deducted from the §3.10.1 residual) took the # 6-cert detailed-common-wall cohort 2.43 -> 1.25; corpus -> 67.6% (MAE 0.979). +# The §3.9.2 Simplified Type-2 RR mapper (room_in_roof_type_2: gable quadratic + +# common-wall L×(0.25+H), MIRRORING the worksheet-validated Summary path, +# cross-mapper-parity-exact on cert 000565) -> 67.9% (MAE 0.959). _MIN_WITHIN_HALF_SAP = 0.67 -_MAX_SAP_MAE = 0.99 +_MAX_SAP_MAE = 0.97 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current From fa131cca0b7650e19f823a6e548c8652493c32aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:37:05 +0000 Subject: [PATCH 03/13] =?UTF-8?q?feat(conservatory):=20read=20=C2=A76.1=20?= =?UTF-8?q?geometry=20through=20extractor=20+=20mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §6.1 (PDF p.49) models a non-separated (heated) conservatory as part of the dwelling. Until now the Summary §5 block was reduced to an inert `has_conservatory` bool and the geometry (floor area, glazed perimeter, glazing, storey height) was dropped on both paths. Plumbing only — no cascade consumer yet (Slices B/C/D wire §3/§6): - ElmhurstSiteNotesExtractor reads the §5 Conservatory block into a new `Conservatory` site-notes record (scoped to §5 so the generic "Floor Area"/"Room Height" labels can't collide with §4 dimensions); - domain gains a frozen `SapConservatory` (floor area, glazed perimeter, double/single glazing, thermally-separated guard, equivalent storey count) on `EpcPropertyData.sap_conservatory`; - the Elmhurst mapper threads it through, dropping SEPARATED conservatories per §6.2 ("A separated conservatory ... is disregarded"). Verified against the simulated case-44 Summary (RefNo 001431): extracts floor_area=12.0, glazed_perimeter=9.0, double_glazed=True, 1 storey. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 51 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 31 +++++++++++ datatypes/epc/domain/mapper.py | 21 ++++++++ datatypes/epc/surveys/elmhurst_site_notes.py | 21 ++++++++ 4 files changed, 124 insertions(+) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index a8c9e596..6f7e4936 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -7,6 +7,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( BathsAndShowers, BuildingPartDimensions, CommunityHeating, + Conservatory, ElmhurstSiteNotes, ExtensionPart, FloorDetails, @@ -30,6 +31,17 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( ) +def _parse_conservatory_storeys(raw: Optional[str]) -> float: + """Parse the §5 "Room Height" lodgement ("1 Storey", "1.5 Storey", + "1½ Storey") into the equivalent-storey count RdSAP 10 §6.1 translates + to a metre height. Defaults to 1.0 (single storey) when unparseable.""" + if not raw: + return 1.0 + text = raw.replace("½", ".5") + m = re.search(r"\d+(?:\.\d+)?", text) + return float(m.group(0)) if m else 1.0 + + def _parse_solar_pitch_deg(raw: Optional[str]) -> Optional[int]: """Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°", or a bare integer). Returns None when absent or unparseable.""" @@ -81,6 +93,13 @@ class ElmhurstSiteNotesExtractor: except (ValueError, IndexError): return 0 + def _float_val(self, label: str) -> Optional[float]: + v = self._next_val(label) + if not v: + return None + m = re.search(r"-?\d+(?:\.\d+)?", v) + return float(m.group(0)) if m else None + def _date_val(self, label: str) -> date: v = self._next_val(label) if not v: @@ -179,8 +198,39 @@ class ElmhurstSiteNotesExtractor: v = self._local_val(lines, label) return v is not None and v.lower() == "yes" + def _local_float(self, lines: List[str], label: str) -> Optional[float]: + v = self._local_val(lines, label) + if not v: + return None + m = re.search(r"-?\d+(?:\.\d+)?", v) + return float(m.group(0)) if m else None + # --- section extractors --- + def _extract_conservatory(self) -> Optional[Conservatory]: + """Summary §5.0 — geometry of a conservatory (RdSAP 10 §6, PDF + p.49). Returns None when none is lodged. Scoped to the §5 block + so the generic labels ("Floor Area", "Room Height") can't collide + with §4 dimensions. A separated conservatory is still returned + (with `thermally_separated=True`); the mapper drops it per §6.2.""" + if not self._bool_val("Is there a conservatory?"): + return None + lines = self._section_lines_first_end( + "5.0 Conservatory", ("7.0 Walls", "6.0 ", "Summary Information"), + ) + return Conservatory( + thermally_separated=self._local_bool( + lines, "Is it thermally separated?" + ), + floor_area_m2=self._local_float(lines, "Floor Area [m2]") or 0.0, + double_glazed=self._local_bool(lines, "Double Glazed"), + glazed_perimeter_m=self._local_float(lines, "Glazed Perimeter [m]") + or 0.0, + room_height_storeys=_parse_conservatory_storeys( + self._local_val(lines, "Room Height") + ), + ) + def _extract_surveyor_info(self) -> SurveyorInfo: return SurveyorInfo( surveyor_code=self._str_val("Surveyor"), @@ -1820,6 +1870,7 @@ class ElmhurstSiteNotesExtractor: construction_age_band=self._extract_main_age_band(), dimensions=self._extract_dimensions(), has_conservatory=self._bool_val("Is there a conservatory?"), + conservatory=self._extract_conservatory(), walls=self._extract_walls(), roof=self._extract_roof(), floor=self._extract_floor(), diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index baf1db00..c12d87f0 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -261,6 +261,33 @@ class SapRoofWindow: window_location: Union[int, str] = 0 +@dataclass(frozen=True) +class SapConservatory: + """RdSAP 10 §6.1 (PDF p.49) — a NON-SEPARATED (heated) conservatory. + + Its floor area and volume are added to the dwelling total (TFA (4), + volume (5)); its fully-glazed walls bill as a window (27) and its + fully-glazed roof as a rooflight (27a); the floor adds a ground-loss + term (28a). U-values come from RdSAP 10 Table 25 (p.51): double 6 mm + window 3.1 / roof 3.4 / g 0.76; single window 4.8 / roof 5.3 / g 0.85. + + `room_height_storeys` is the equivalent number of storey heights of + the dwelling to the nearest half (Summary §5 "Room Height", gov-API + glazed building part), translated to a metre height per §6.1: + 1 storey → ground-floor room height; 1½ → ground + 0.25 + 0.5×first; + 2 → ground + 0.25 + first; etc. + + A SEPARATED conservatory (§6.2) is disregarded entirely and is never + represented here (`thermally_separated` stays a guard for the cascade). + """ + + floor_area_m2: float + glazed_perimeter_m: float + double_glazed: bool + thermally_separated: bool + room_height_storeys: float + + @dataclass class SapWindow: frame_material: Optional[str] @@ -774,6 +801,10 @@ class EpcPropertyData: # has no roof windows; for cert-cascade fixtures the bootstrap path # lodges per-window area + raw U. sap_roof_windows: Optional[List[SapRoofWindow]] = None + # RdSAP 10 §6.1 — geometry of a non-separated (heated) conservatory. + # None when no conservatory is lodged or it is thermally separated + # (§6.2 disregards separated conservatories). + sap_conservatory: Optional[SapConservatory] = None calculation_software_version: Optional[str] = None # Do we care about this? mechanical_vent_duct_placement: Optional[int] = None mechanical_vent_duct_insulation: Optional[int] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b79b51d1..acd24efd 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -24,6 +24,7 @@ from datatypes.epc.domain.epc_property_data import ( SapEnergySource, SapFlatDetails, SapFloorDimension, + SapConservatory, SapHeating, SapRoofWindow, SapRoomInRoof, @@ -68,6 +69,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( AlternativeWall as ElmhurstAlternativeWall, BuildingPartDimensions as ElmhurstBuildingPartDimensions, CommunityHeating, + Conservatory as ElmhurstConservatory, ElmhurstSiteNotes, FloorDetails as ElmhurstFloorDetails, MainHeating as ElmhurstMainHeating, @@ -420,6 +422,7 @@ class EpcPropertyDataMapper: built_form=built_form, property_type=property_type, has_conservatory=survey.has_conservatory, + sap_conservatory=_map_elmhurst_conservatory(survey.conservatory), blocked_chimneys_count=survey.ventilation.blocked_chimneys_count, number_of_storeys=survey.number_of_storeys, hydro=survey.renewables.hydro_electricity_generated_kwh > 0, @@ -5226,6 +5229,24 @@ def _elmhurst_roof_window_u_value(w: ElmhurstWindow) -> float: return w.u_value + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K +def _map_elmhurst_conservatory( + cons: Optional[ElmhurstConservatory], +) -> Optional[SapConservatory]: + """RdSAP 10 §6 — translate the Summary §5 conservatory geometry into + the domain `SapConservatory`. A SEPARATED conservatory (§6.2, PDF + p.49) is disregarded entirely, so it maps to None (the cascade adds + nothing). Returns None when no conservatory is lodged.""" + if cons is None or cons.thermally_separated: + return None + return SapConservatory( + floor_area_m2=cons.floor_area_m2, + glazed_perimeter_m=cons.glazed_perimeter_m, + double_glazed=cons.double_glazed, + thermally_separated=cons.thermally_separated, + room_height_storeys=cons.room_height_storeys, + ) + + def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow: return SapRoofWindow( area_m2=w.area_m2, diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 4614d33c..02f27849 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -478,6 +478,21 @@ class ExtensionPart: room_in_roof: Optional[RoomInRoof] = None +@dataclass +class Conservatory: + """Summary §5 geometry of a NON-SEPARATED conservatory (RdSAP 10 + §6.1). `room_height_storeys` is the lodged equivalent-storey count + ("1 Storey" → 1.0, "1.5 Storey" → 1.5); the mapper/cascade translate + it to a metre height. A SEPARATED conservatory (§6.2) is disregarded, + so `thermally_separated=True` records are dropped before the cascade.""" + + thermally_separated: bool + floor_area_m2: float + double_glazed: bool + glazed_perimeter_m: float + room_height_storeys: float + + @dataclass class ElmhurstSiteNotes: surveyor_info: SurveyorInfo @@ -560,3 +575,9 @@ class ElmhurstSiteNotes: # cold loft instead of a room-in-roof). The mapper translates the # surface table into a `SapRoomInRoof` attached to the Main bp. room_in_roof: Optional[RoomInRoof] = None + + # §5.0 Conservatory geometry — None when the dwelling has no + # conservatory (`has_conservatory=False`). Populated (incl. for + # separated conservatories) so the mapper can apply the §6.1/§6.2 + # rule; the mapper drops separated ones. + conservatory: Optional[Conservatory] = None From d4d2b222fc8a7474b18aca1e1f9a847a30f937d5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:59:26 +0000 Subject: [PATCH 04/13] =?UTF-8?q?feat(conservatory):=20=C2=A76.1=20fabric?= =?UTF-8?q?=20cascade=20(27/27a/28a=20+=20TFA/volume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the non-separated conservatory into the §3 heat-transmission + §1 dimensions cascade per RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51): "The floor area and volume of a non-separated conservatory are added to the total floor area and volume of the dwelling. Its roof area is taken as its floor area divided by cos(20°), and wall area is taken as the product of its exposed perimeter and its height. ... The conservatory walls and roof are taken as fully glazed ... Glazed walls are taken as windows, glazed roof as rooflight." New `worksheet/conservatory.py` derives the geometry: - height from the equivalent storey count (§6.1: 1 storey → ground-floor room height; 1½ → ground + 0.25 + 0.5×first; etc.); - glazed WALL → window (27) at Table 25 U (double 3.1 / single 4.8) with the §3.2 curtain resistance (R=0.04) → U_eff 2.758; - glazed ROOF → rooflight (27a) at Table 25 roof U (double 3.4 / single 5.3) + curtain → U_eff 2.993; - FLOOR → (28a) via BS EN ISO 13370 as an uninsulated SOLID ground floor with 300 mm walls (§5.12, spec p.43), exposed perimeter = glazed perimeter → U 0.89; - glazed wall + roof + floor areas join (31)/(36); the fully-glazed structure walls/roof add nothing (the glazing IS the window/rooflight). `dimensions_from_cert` adds the conservatory floor area to TFA (4) and floor area × height to volume (5) (feeds ventilation (8)), without making it a storey (avg storey height for §2 infiltration is unchanged). Pinned against the simulated case-44 P960 §3 at abs=1e-4 — every line ref EXACT: (4) 95.3800, (5) 257.1630, (27) 96.1169, (27a) 38.2201, (28a) 21.4164, (29a) 35.5852, (30) 7.4688, (31) 294.2900, (33) 207.3274, (36) 23.5432. The remaining whole-dwelling SAP/CO2 gap is the §6 solar gains, closed in the next slice. Worksheet harness stays 47/47 0-raised. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_case44.pdf | Bin 0 -> 77581 bytes .../worksheet/conservatory.py | 149 ++++++++++++++++++ .../sap10_calculator/worksheet/dimensions.py | 16 +- .../worksheet/heat_transmission.py | 49 ++++++ .../_elmhurst_worksheet_001431_case44.py | 119 ++++++++++++++ .../worksheet/test_section_cascade_pins.py | 57 +++++++ 6 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf create mode 100644 domain/sap10_calculator/worksheet/conservatory.py create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4312e6c13ac6ed13d32af936eccca615d420720b GIT binary patch literal 77581 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 literal 0 HcmV?d00001 diff --git a/domain/sap10_calculator/worksheet/conservatory.py b/domain/sap10_calculator/worksheet/conservatory.py new file mode 100644 index 00000000..b9729e1f --- /dev/null +++ b/domain/sap10_calculator/worksheet/conservatory.py @@ -0,0 +1,149 @@ +"""RdSAP 10 §6.1 — non-separated (heated) conservatory geometry. + +A non-separated conservatory is treated as part of the dwelling +(RdSAP 10 Specification, 9th June 2025, §6.1 + Table 25, pages 49-51): + + - its floor area and volume are added to TFA (4) and volume (5); + - its fully-glazed walls bill as a window — line (27) — at the Table 25 + "U-value of window"; its glazed roof bills as a rooflight — line (27a) + — at the Table 25 "U-value of roof window"; both U-values already + include the §3.2 curtain resistance (R=0.04 m²K/W); + - its floor adds a ground-loss term — line (28a) — via BS EN ISO 13370, + taken as an uninsulated solid floor with 300 mm walls (§5.12 note, + spec p.43), exposed perimeter = glazed perimeter; + - its glazed wall + glazed roof + floor areas count toward the total + exposed area (31) and hence thermal bridging (36); the fully-glazed + "structure" walls/roof themselves add nothing (the glazing IS the + window/rooflight). + +Its roof area is the floor area / cos(20°) and its wall area is the +exposed perimeter × height; the height is translated from the lodged +equivalent storey count (§6.1): 1 storey → ground-floor room height; +1½ → ground + 0.25 + 0.5×first; 2 → ground + 0.25 + first; etc. + +A SEPARATED conservatory (§6.2) is disregarded entirely — the mapper +maps it to None, so it never reaches this module. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from math import cos, radians +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData + +# RdSAP 10 §6.1 — conservatory roof area = floor area / cos(20°); §6.1 +# also fixes the rooflight solar pitch at 20°. +CONSERVATORY_ROOF_PITCH_DEG: Final[float] = 20.0 +_COS_ROOF_PITCH: Final[float] = cos(radians(CONSERVATORY_ROOF_PITCH_DEG)) + +# RdSAP 10 Table 25 (PDF p.51) — default conservatory glazing U-values +# (W/m²K, INCLUSIVE of the §3.2 curtain resistance) and g-values. The +# Summary lodges only double vs single (no triple), so a bool selects the +# row: True → double (6 mm gap), False → single. +_TABLE_25_WALL_U: Final[dict[bool, float]] = {True: 3.1, False: 4.8} +_TABLE_25_ROOF_U: Final[dict[bool, float]] = {True: 3.4, False: 5.3} +_TABLE_25_G_VALUE: Final[dict[bool, float]] = {True: 0.76, False: 0.85} +_TABLE_25_FRAME_FACTOR: Final[float] = 0.70 # Table 25 — wood/PVC frame + +# SAP 10.2 §3.2 formula (2) curtain/blind resistance. Table 25 U-values +# are "adjusted for curtains" already, so the EFFECTIVE conduction U is +# 1 / (1/U_table25 + 0.04) — the same transform `heat_transmission` +# applies to regular windows/rooflights. +_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 + +# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an +# uninsulated solid ground floor with 300 mm walls. +_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300 +_AREA_ROUND_DP: Final[int] = 2 + + +def _round2(value: float) -> float: + """RdSAP 10 §15 (p.66): element areas + conservatory height → 2 d.p.""" + return float( + Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + + +@dataclass(frozen=True) +class ConservatoryGeometry: + """Derived §6.1 geometry for one non-separated conservatory. Areas and + height are rounded to 2 d.p. per RdSAP 10 §15.""" + + height_m: float + floor_area_m2: float + glazed_wall_area_m2: float + glazed_roof_area_m2: float + glazed_perimeter_m: float + wall_u_raw: float # Table 25 window U, pre-curtain + roof_u_raw: float # Table 25 roof-window U, pre-curtain + wall_u_eff: float # post-curtain conduction U for line (27) + roof_u_eff: float # post-curtain conduction U for line (27a) + g_value: float + frame_factor: float + volume_m3: float + + +def _conservatory_height_m(epc: EpcPropertyData, storeys: float) -> float: + """Translate the equivalent storey count into a metre height per + RdSAP 10 §6.1 using the dwelling's per-storey room heights: + + 1 storey → ground-floor room height + 1½ storey → ground + 0.25 + 0.5 × first-floor room height + 2 storey → ground + 0.25 + first-floor room height + etc. + + Room heights are taken from the Main building part's floor + dimensions (floor 0 = ground, 1 = first, ...). Returns 0.0 when no + storeys are lodged (defensive; the conservatory then bills no walls).""" + parts = epc.sap_building_parts or [] + heights: list[float] = [] + if parts: + fds = sorted( + parts[0].sap_floor_dimensions, + key=lambda fd: fd.floor if fd.floor is not None else 0, + ) + heights = [fd.room_height_m for fd in fds if fd.room_height_m] + if not heights: + return 0.0 + n_full = int(storeys) + height = heights[0] + for s in range(1, n_full): + height += 0.25 + heights[min(s, len(heights) - 1)] + if storeys - n_full >= 0.5: + height += 0.25 + 0.5 * heights[min(n_full, len(heights) - 1)] + return _round2(height) + + +def conservatory_geometry( + epc: EpcPropertyData, +) -> Optional[ConservatoryGeometry]: + """Build the §6.1 conservatory geometry, or None when there is no + (non-separated) conservatory.""" + cons = epc.sap_conservatory + if cons is None or cons.thermally_separated: + return None + height = _conservatory_height_m(epc, cons.room_height_storeys) + floor_area = cons.floor_area_m2 + glazed_perimeter = cons.glazed_perimeter_m + glazed_wall = _round2(glazed_perimeter * height) + glazed_roof = _round2(floor_area / _COS_ROOF_PITCH) + dg = cons.double_glazed + wall_u_raw = _TABLE_25_WALL_U[dg] + roof_u_raw = _TABLE_25_ROOF_U[dg] + return ConservatoryGeometry( + height_m=height, + floor_area_m2=floor_area, + glazed_wall_area_m2=glazed_wall, + glazed_roof_area_m2=glazed_roof, + glazed_perimeter_m=glazed_perimeter, + wall_u_raw=wall_u_raw, + roof_u_raw=roof_u_raw, + wall_u_eff=1.0 / (1.0 / wall_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W), + roof_u_eff=1.0 / (1.0 / roof_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W), + g_value=_TABLE_25_G_VALUE[dg], + frame_factor=_TABLE_25_FRAME_FACTOR, + volume_m3=floor_area * height, + ) diff --git a/domain/sap10_calculator/worksheet/dimensions.py b/domain/sap10_calculator/worksheet/dimensions.py index f48e24d5..792770b4 100644 --- a/domain/sap10_calculator/worksheet/dimensions.py +++ b/domain/sap10_calculator/worksheet/dimensions.py @@ -21,6 +21,7 @@ from dataclasses import dataclass from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart +from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 @@ -145,17 +146,28 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions: total_storey_count = max(part_storey_counts) if part_storey_counts else 0 has_storeys = sum_per_storey_area_m2 > 0 + # `avg_height` (used by §2 (9) dwelling height → infiltration) is a + # property of the dwelling's storeys, so the conservatory is excluded + # from it. The conservatory IS added to TFA (4) and volume (5) per + # RdSAP 10 §6.1 ("The floor area and volume of a non-separated + # conservatory are added to the total floor area and volume of the + # dwelling") — it just doesn't form a storey. avg_height = ( sum_per_storey_volume_m3 / sum_per_storey_area_m2 if has_storeys else _DEFAULT_STOREY_HEIGHT_M ) + cons = conservatory_geometry(epc) + cons_floor_area_m2 = cons.floor_area_m2 if cons is not None else 0.0 + cons_volume_m3 = cons.volume_m3 if cons is not None else 0.0 return Dimensions( total_floor_area_m2=( - sum_per_storey_area_m2 if has_storeys else epc.total_floor_area_m2 + sum_per_storey_area_m2 + cons_floor_area_m2 + if has_storeys + else epc.total_floor_area_m2 ), volume_m3=( - sum_per_storey_volume_m3 + sum_per_storey_volume_m3 + cons_volume_m3 if has_storeys else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M ), diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 83668e6f..4d2971d8 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -71,6 +71,7 @@ from domain.sap10_ml.rdsap_uvalues import ( u_wall, u_window, ) +from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry from math import cos, floor, radians, sqrt @@ -123,6 +124,9 @@ _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85 # deducts from that wall, not the main wall. _CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 +# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an +# uninsulated solid ground floor with 300 mm walls. +_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300 # SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and # roof windows) — turns raw window U into the worksheet's (27) effective U. _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 @@ -1368,6 +1372,51 @@ def heat_transmission_from_cert( # door line. doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area roof_windows_w_per_k = roof_windows_w_per_k_total + + # RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51) — a non-separated + # conservatory. Its fully-glazed walls bill as a window (27), its + # glazed roof as a rooflight (27a), and its floor adds a ground-loss + # term (28a) via BS EN ISO 13370 (uninsulated solid floor, 300 mm + # walls per §5.12; exposed perimeter = glazed perimeter). The glazed + # wall + roof + floor areas join (31)/(36) external area; the fully- + # glazed "structure" walls/roof add nothing (the glazing IS the + # window/rooflight). A separated conservatory (§6.2) is mapped to + # None upstream and never reaches here. + cons_geom = conservatory_geometry(epc) + cons_windows_w_per_k: float = 0.0 + if cons_geom is not None: + cons_windows_w_per_k = ( + cons_geom.glazed_wall_area_m2 * cons_geom.wall_u_eff + ) + roof_windows_w_per_k += ( + cons_geom.glazed_roof_area_m2 * cons_geom.roof_u_eff + ) + u_cons_floor = u_floor( + country=country, + age_band=primary_age, + construction=None, + insulation_thickness_mm=0, + area_m2=cons_geom.floor_area_m2, + perimeter_m=cons_geom.glazed_perimeter_m, + wall_thickness_mm=_CONSERVATORY_WALL_THICKNESS_MM, + # Force the solid-floor branch of BS EN ISO 13370 regardless of + # age band (§5.12: conservatory floor is an uninsulated SOLID + # ground floor — the A/B suspended-timber default must not fire). + description="Solid", + ) + floor += u_cons_floor * cons_geom.floor_area_m2 + cons_external_area = ( + cons_geom.glazed_wall_area_m2 + + cons_geom.glazed_roof_area_m2 + + cons_geom.floor_area_m2 + ) + total_external_area += cons_external_area + bridging += dwelling_y * cons_external_area + # Fold the conservatory glazed wall into the (27) window readout. The + # `windows` accumulator is partially-typed upstream (the per-window + # `u_value` arrives as `Any`); `float(...)` re-asserts the strict float + # type as we add the strictly-typed conservatory term. + windows = float(windows) + cons_windows_w_per_k fabric_heat_loss = ( walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33) ) diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py new file mode 100644 index 00000000..39d7da49 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py @@ -0,0 +1,119 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 44" worksheet — a 2-storey mid-terrace with a NON-SEPARATED +(heated, type-4) DOUBLE-glazed CONSERVATORY. + +Case 44 is the 1e-4 oracle for RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51). +The Summary §5 lodges: Floor Area 12.00 m², Glazed Perimeter 9.00 m, +Double Glazed Yes, thermally separated No, Room Height 1 Storey. From that +the §6.1 cascade derives (all verified against the P960 §3 to 1e-4): + + - conservatory height = ground-floor room height = 2.60 m (1 storey); + - glazed WALL → window (27): A = perimeter × height = 9.0 × 2.60 = 23.40, + U = 1/(1/3.1 + 0.04) = 2.758 (Table 25 double 3.1 + §3.2 curtain); + - glazed ROOF → rooflight (27a): A = floor_area / cos(20°) = 12.77, + U = 1/(1/3.4 + 0.04) = 2.993 (Table 25 roof 3.4 + curtain); + - FLOOR → ground floor (28a): A = 12.00, U = 0.89 via BS EN ISO 13370 + (uninsulated solid, 300 mm walls, P = glazed perimeter 9.0); + - the fully-glazed structure walls/roof bill at U=0 (the glazing IS the + window/rooflight) — they contribute nothing but DO count their glazed + area toward (31)/(36); + - TFA (4) += 12.00 → 95.38; volume (5) += 12.00 × 2.60 = 31.20 → 257.16. + +Like the other `_elmhurst_worksheet_001431_case*` fixtures this 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 44/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf` so the +test runs without depending on the unstaged workspace. + +Worksheet pin targets (P960-0001-001431, "11a. SAP rating" UK-average +rating block our cascade reproduces): +- (4) TFA, m² = 95.3800 +- (5) Dwelling volume, m³ = 257.1630 +- (27) Windows (31.5795 main + 64.5374 cons) = 96.1169 +- (27a) Roof windows (conservatory glazed roof) = 38.2201 +- (28a) Ground floor (10.7364 main + 10.6800) = 21.4164 +- (29a) External walls = 35.5852 +- (30) External roof = 7.4688 +- (31) Total net area of external elements = 294.2900 +- (33) Fabric heat loss, W/K = 207.3274 +- (36) Thermal bridges (0.080 × (31)) = 23.5432 + +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_case44.pdf" +) + +LINE_4_TFA_M2: Final[float] = 95.3800 +LINE_5_VOLUME_M3: Final[float] = 257.1630 +LINE_27_WINDOWS_W_PER_K: Final[float] = 96.1169 +LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 38.2201 +LINE_28A_FLOOR_W_PER_K: Final[float] = 21.4164 +LINE_29A_WALLS_W_PER_K: Final[float] = 35.5852 +LINE_30_ROOF_W_PER_K: Final[float] = 7.4688 +LINE_31_EXTERNAL_AREA_M2: Final[float] = 294.2900 +LINE_33_FABRIC_W_PER_K: Final[float] = 207.3274 +LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.5432 + + +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-44 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 + LINE_* + 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_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 4e7336e3..d4725df9 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -45,6 +45,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_case6 as _w001431_case6, _elmhurst_worksheet_001431_case21 as _w001431_case21, _elmhurst_worksheet_001431_case43 as _w001431_case43, + _elmhurst_worksheet_001431_case44 as _w001431_case44, ) @@ -370,6 +371,62 @@ def test_case43_detailed_rr_dryline_and_mixed_roof_match_pdf() -> None: ) +def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None: + """§3 fabric pin for simulated case 44 — a non-separated DOUBLE-glazed + conservatory (RdSAP 10 §6.1 + Table 25). The conservatory's glazed wall + bills as a window (27), its glazed roof as a rooflight (27a), its floor + adds a ground-loss term (28a), and its glazed wall + roof + floor areas + join (31)/(36); TFA (4) and volume (5) absorb its floor area + volume. + The main dwelling's walls (29a) / roof (30) are untouched — pinned to + guard against the conservatory leaking into the wrong element.""" + # Arrange + epc = _w001431_case44.build_epc() + + # Act + ht = heat_transmission_section_from_cert(epc) + dim = dimensions_from_cert(epc) + + # Assert — §1 totals + §3 fabric, each at abs=1e-4. + _pin(dim.total_floor_area_m2, _w001431_case44.LINE_4_TFA_M2, "§1 (4) case44") + _pin(dim.volume_m3, _w001431_case44.LINE_5_VOLUME_M3, "§1 (5) case44") + _pin( + ht.windows_w_per_k, + _w001431_case44.LINE_27_WINDOWS_W_PER_K, + "§3 (27) case44", + ) + _pin( + ht.roof_windows_w_per_k, + _w001431_case44.LINE_27A_ROOF_WINDOWS_W_PER_K, + "§3 (27a) case44", + ) + _pin( + ht.floor_w_per_k, + _w001431_case44.LINE_28A_FLOOR_W_PER_K, + "§3 (28a) case44", + ) + _pin( + ht.walls_w_per_k, + _w001431_case44.LINE_29A_WALLS_W_PER_K, + "§3 (29a) case44", + ) + _pin(ht.roof_w_per_k, _w001431_case44.LINE_30_ROOF_W_PER_K, "§3 (30) case44") + _pin( + ht.total_external_element_area_m2, + _w001431_case44.LINE_31_EXTERNAL_AREA_M2, + "§3 (31) case44", + ) + _pin( + ht.fabric_heat_loss_w_per_k, + _w001431_case44.LINE_33_FABRIC_W_PER_K, + "§3 (33) case44", + ) + _pin( + ht.thermal_bridging_w_per_k, + _w001431_case44.LINE_36_THERMAL_BRIDGING_W_PER_K, + "§3 (36) case44", + ) + + 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 From fe3bf4eaed2d1d6bc2ab1ce56e5597b2b57255d4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:18:17 +0000 Subject: [PATCH 05/13] fix(ventilation): read Blower Door AP50 pressure test (Summary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §2 (17)-(18): a measured/design air permeability at 50 Pa from a Blower Door test routes infiltration via `(18) = AP50/20 + (8)`, in preference to the components-based (16) estimate. The Elmhurst extractor read only the AP4 ("Pulse") column of §12.2, so a Blower Door result (§12.2 "Pressure Test Result (AP50)") fell through to the structural- infiltration default — over-counting ventilation heat loss. Surfaced by simulated case 44 (AP50 4.50): effective air change rate was 0.81 vs the worksheet's 0.58 (+38% ventilation loss). The cascade already supports `air_permeability_ap50` (preferred over AP4); this wires the read end to end (extractor → ElmhurstSiteNotes → SapVentilation → cert_to_inputs). Pinned against the case-44 P960 §2 at abs=1e-4: (18) infiltration 0.3417 (= 4.5/20 + 0.1167) and (25) Jan effective ach 0.5812. Worksheet harness stays 47/47 0-raised. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 5 +++++ datatypes/epc/domain/epc_property_data.py | 4 ++++ datatypes/epc/domain/mapper.py | 1 + datatypes/epc/surveys/elmhurst_site_notes.py | 3 +++ .../sap10_calculator/rdsap/cert_to_inputs.py | 4 ++++ .../worksheet/test_section_cascade_pins.py | 20 +++++++++++++++++++ 6 files changed, 37 insertions(+) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 6f7e4936..196291b4 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1352,6 +1352,10 @@ class ElmhurstSiteNotesExtractor: air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0]) except (ValueError, IndexError): air_permeability_ap4_m3_h_m2 = None + # SAP 10.2 §2 (17) "Measured/design AP50" from a Blower Door test. + # Routes the cascade's (18) via `AP50 / 20 + (8)` (preferred over + # AP4). Absent when the test method is "Not available". + ap50_raw = self._local_float(pressure_lines, "Pressure Test Result (AP50)") # Summary §12.1 "Mechanical Ventilation Type" — scoped to §12.1 # body so the global "Type" labels in §14 / §15 can't shadow it. mv_lines = self._section_lines( @@ -1400,6 +1404,7 @@ class ElmhurstSiteNotesExtractor: mechanical_ventilation=self._bool_val("Mechanical Ventilation"), pressure_test_method=self._str_val("Test Method"), air_permeability_ap4_m3_h_m2=air_permeability_ap4_m3_h_m2, + air_permeability_ap50_m3_h_m2=ap50_raw, mechanical_ventilation_type=mechanical_ventilation_type, mechanical_ventilation_pcdf_reference=mev_pcdf_reference, wet_rooms_count=wet_rooms_count, diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index c12d87f0..f69ede90 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -202,6 +202,10 @@ class SapVentilation: # Pulse pressure test, m³/h per m² of envelope area. When present the # cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`. air_permeability_ap4_m3_h_m2: Optional[float] = None + # SAP 10.2 §2 (17) — air permeability at 50 Pa from a Blower Door test, + # m³/h per m² of envelope area. When present the cascade routes (18) + # via `AP50 / 20 + (8)` (preferred over AP4). + air_permeability_ap50_m3_h_m2: Optional[float] = None # SAP 10.2 §2 (23a)/(24a..d) — Elmhurst "Mechanical Ventilation Type" # string mapped to the `MechanicalVentilationKind` enum name (e.g. # "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index acd24efd..514803ec 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6918,5 +6918,6 @@ def _map_elmhurst_ventilation( else (False if has_suspended_timber_floor else None) ), air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2, + air_permeability_ap50_m3_h_m2=v.air_permeability_ap50_m3_h_m2, mechanical_ventilation_kind=_elmhurst_mv_kind(v.mechanical_ventilation_type), ) diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 02f27849..3b179253 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -202,6 +202,9 @@ class VentilationAndCooling: # SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result # (AP4)" — only present when `pressure_test_method == "Pulse"`. air_permeability_ap4_m3_h_m2: Optional[float] = None + # SAP 10.2 §2 (17) AP50 reading from §12.2 "Pressure Test Result + # (AP50)" — present for a Blower Door test. Routes (18) via AP50/20. + air_permeability_ap50_m3_h_m2: Optional[float] = None # Summary §12.1 "Mechanical Ventilation Type" — e.g. "Mechanical # extract, decentralised (MEV dc)". None when `mechanical_ventilation # is False` (no MV system). diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index f4a86295..c8a59fcf 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4986,6 +4986,9 @@ def ventilation_from_cert( # cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value # falls through to the components-based (16) ach. ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None + # SAP 10.2 §2 (17) — AP50 Blower Door reading routes (18) via + # `AP50 / 20 + (8)`, preferred over AP4 when both are lodged. + ap50 = sv.air_permeability_ap50_m3_h_m2 if sv is not None else None # SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m # effective-ach formula. The Elmhurst mapper translates the lodged # "Mechanical Ventilation Type" string to an enum *name*; resolve @@ -5023,6 +5026,7 @@ def ventilation_from_cert( window_pct_draught_proofed=float(epc.percent_draughtproofed or 0), sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2, air_permeability_ap4=ap4, + air_permeability_ap50=ap50, mv_kind=mv_kind, mv_system_ach=mv_system_ach, **wind_kwargs, 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 d4725df9..19b9d7dc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -427,6 +427,26 @@ def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None: ) +def test_case44_blower_door_pressure_test_matches_pdf() -> None: + """Simulated case 44 lodges a Blower Door air-pressure test + (§12.2 "Pressure Test Result (AP50) 4.50"). SAP 10.2 §2 (17)-(18): + the AP50 reading routes infiltration via `(18) = AP50/20 + (8)` = + 4.5/20 + 0.1167 = 0.3417, in preference to the components-based (16) + estimate. The extractor previously read only the AP4 (Pulse) column, + so a Blower Door result fell through to the structural-infiltration + default (effective ach 0.81 vs the worksheet's 0.58 → ventilation + heat loss over-counted by ~38%).""" + # Arrange + epc = _w001431_case44.build_epc() + + # Act + vent = ventilation_from_cert(epc) + + # Assert — (18) infiltration + (25) Jan effective ach, at abs=1e-4. + _pin(vent.pressure_test_ach, 0.3417, "§2 (18) case44") + _pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan case44") + + 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 From 2a4d67e39659311445a8257a46138a8d7405e494 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:21:08 +0000 Subject: [PATCH 06/13] =?UTF-8?q?feat(conservatory):=20=C2=A76.1=20solar?= =?UTF-8?q?=20gains=20+=20TFA-occupancy=20(demand-side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the §6.1 conservatory demand cascade per RdSAP 10 §6.1 + Table 25. Solar gains (§6, solar_gains.py) — Table 25 note (PDF p.51): "The orientation of windows in a conservatory is not recorded, thus solar gains are calculated using the default solar flux (East/West orientation, with 20° pitch for roof windows)." The glazed wall bills onto the (76) East line (vertical, average-overshading Z); the glazed roof onto the (82) roof-window line (20° pitch, Z=1.0), both at Table 25 g=0.76, FF=0.70. TFA-occupancy (mapper) — §6.1: the conservatory floor area is added to the dwelling total floor area. TFA drives occupancy → §5 internal gains + §4 hot-water demand, so the non-separated conservatory's floor area now enters `EpcPropertyData.total_floor_area_m2` (the worksheet's (4) = 95.38 carries it). Separated conservatories (§6.2) stay excluded. Pinned against the case-44 P960 demand cascade at abs=1e-4: (73) internal gains 625.1759, (83) solar gains 495.8655, (95) useful gains 1079.6510, (99) space heating per m² 89.8073 — the full §6.1 chain reproduces EXACTLY. The whole-dwelling SAP (72.9517) / CO2 (3241.8656) are not pinned: the case-44 Summary omits the House-Coal secondary heater (SAP 633) the P960 descriptor carries (cf. case 43), so the cascade computes no secondary — the entire residual (+349.77 kg CO2). A Summary-input defect, independent of §6.1; every conservatory-affected line ref is exact. Worksheet harness stays 47/47 0-raised; corpus unchanged (API path; mirror is the next slice). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 12 +++++ .../sap10_calculator/worksheet/solar_gains.py | 46 +++++++++++++++++++ .../_elmhurst_worksheet_001431_case44.py | 25 ++++++++++ .../worksheet/test_section_cascade_pins.py | 44 ++++++++++++++++++ 4 files changed, 127 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 514803ec..c2b5c2cc 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -416,6 +416,18 @@ class EpcPropertyDataMapper: ext.room_in_roof.floor_area_m2 for ext in survey.extensions if ext.room_in_roof is not None + ) + # RdSAP 10 §6.1 (PDF p.49) — a non-separated conservatory's + # floor area is added to the dwelling total floor area. TFA + # drives occupancy → §5 internal gains + §4 hot-water demand, + # so it must include the conservatory (the worksheet's (4) = + # 95.38 carries it). Separated conservatories (§6.2) are + # disregarded. + + ( + survey.conservatory.floor_area_m2 + if survey.conservatory is not None + and not survey.conservatory.thermally_separated + else 0.0 ), 2, ), diff --git a/domain/sap10_calculator/worksheet/solar_gains.py b/domain/sap10_calculator/worksheet/solar_gains.py index 8d3d12a4..04de4982 100644 --- a/domain/sap10_calculator/worksheet/solar_gains.py +++ b/domain/sap10_calculator/worksheet/solar_gains.py @@ -36,6 +36,10 @@ from math import cos, radians, sin from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow +from domain.sap10_calculator.worksheet.conservatory import ( + CONSERVATORY_ROOF_PITCH_DEG, + conservatory_geometry, +) from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate from domain.sap10_calculator.climate.appendix_u import ( horizontal_solar_irradiance_w_per_m2, @@ -435,6 +439,48 @@ def solar_gains_from_cert( o: _sum_tuples(*per_orientation[o]) for o in Orientation } + # RdSAP 10 §6.1 (PDF p.49) + Table 25 note (p.51): "The orientation of + # windows in a conservatory is not recorded, thus solar gains are + # calculated using the default solar flux (East/West orientation, with + # 20° pitch for roof windows)." Average overshading per §7 (Table 6d). + # The glazed wall bills onto the (76) East line (vertical, Z=z_vertical); + # the glazed roof onto the (82) roof-window line (20° pitch, Z=1.0). + cons = conservatory_geometry(epc) + if cons is not None: + cons_wall_monthly = tuple( + window_solar_gain_w( + area_m2=cons.glazed_wall_area_m2, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=Orientation.E, pitch_deg=90.0, + region=region, month=m, + ), + g_perpendicular=cons.g_value, + frame_factor=cons.frame_factor, + overshading_factor=z_vertical, + ) + for m in _MONTHS + ) + cons_roof_monthly = tuple( + window_solar_gain_w( + area_m2=cons.glazed_roof_area_m2, + surface_flux_w_per_m2=surface_solar_flux_w_per_m2( + orientation=Orientation.E, + pitch_deg=CONSERVATORY_ROOF_PITCH_DEG, + region=region, month=m, + ), + g_perpendicular=cons.g_value, + frame_factor=cons.frame_factor, + overshading_factor=_HORIZONTAL_Z, + ) + for m in _MONTHS + ) + per_orientation_summed[Orientation.E] = _sum_tuples( + per_orientation_summed[Orientation.E], cons_wall_monthly, + ) + roof_windows_monthly = _sum_tuples( + roof_windows_monthly, cons_roof_monthly, + ) + total = _sum_tuples( *per_orientation_summed.values(), roof_windows_monthly, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py index 39d7da49..49318a42 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py @@ -75,6 +75,31 @@ LINE_31_EXTERNAL_AREA_M2: Final[float] = 294.2900 LINE_33_FABRIC_W_PER_K: Final[float] = 207.3274 LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.5432 +# Demand-side line refs (Jan column, UK-average rating block). These +# integrate the WHOLE §6.1 conservatory chain end-to-end: +# - (73) internal gains — the conservatory floor area enters TFA (4), +# which drives occupancy → §5 appliance/cooking/metabolic gains; +# - (83) solar gains — the glazed wall (E/W flux, 90° pitch) + glazed +# roof (E/W flux, 20° pitch) at Table 25 g=0.76, FF=0.70; +# - (95) useful gains = (84) total gains × the §7 utilisation factor — +# matches only when fabric (33), ventilation (38) AND gains (84) all +# agree, so it is the single tightest end-to-end conservatory pin; +# - (99) space heating per m² = (98c)/(4) — the integrated demand. +LINE_73_INTERNAL_GAINS_JAN_W: Final[float] = 625.1759 +LINE_83_SOLAR_GAINS_JAN_W: Final[float] = 495.8655 +LINE_95_USEFUL_GAINS_JAN_W: Final[float] = 1079.6510 +LINE_99_SPACE_HEATING_PER_M2_KWH: Final[float] = 89.8073 + +# NB — the full SAP value (72.9517) + (272) CO2 (3241.8656) are NOT pinned +# here. The case-44 Summary PDF omits the House-Coal secondary heater +# (SAP 633, 60% eff) that the P960 worksheet's descriptor block carries +# (the same secondary as case 43); routed through the extractor the +# Summary therefore yields NO secondary system, and the residual SAP/CO2 +# gap is exactly that missing secondary (main+secondary CO2 1927.31 + +# 563.92 = 2491.23 vs cascade 2141.46 → +349.77 ≈ the 350 kg deficit). +# This is a Summary-input defect, independent of §6.1 — every +# conservatory-affected line ref above reproduces the P960 EXACTLY. + def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: """Convert a Summary PDF into the per-page text format the 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 19b9d7dc..6547a585 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -427,6 +427,50 @@ def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None: ) +def test_case44_conservatory_demand_side_matches_pdf() -> None: + """End-to-end §6.1 conservatory demand pin for simulated case 44. + Beyond the §3 fabric, the conservatory ripples through the demand + cascade: its floor area enters TFA (4) → occupancy → §5 internal + gains (73); its glazing contributes §6 solar gains (83) at the + default E/W flux (Table 25 g=0.76, FF=0.70, 20° roof pitch); fabric + + ventilation + gains combine into the §7 useful gains (95) and the + space-heating demand (99). Every line ref reproduces the P960 to 1e-4. + + The full SAP/CO2 is NOT asserted: the case-44 Summary omits the + House-Coal secondary heater the P960 carries (see the provider's NB) — + a Summary-input defect downstream of, and independent of, §6.1.""" + # Arrange + epc = _w001431_case44.build_epc() + + # Act + ig = internal_gains_section_from_cert(epc) + sg = solar_gains_section_from_cert(epc) + sh = space_heating_section_from_cert(epc) + assert ig is not None # TFA present ⇒ §5 helper returns a result + + # Assert — §5/§6/§7 demand line refs, each at abs=1e-4. + _pin( + ig.total_internal_gains_monthly_w[0], + _w001431_case44.LINE_73_INTERNAL_GAINS_JAN_W, + "§5 (73) case44", + ) + _pin( + sg.total_solar_gains_monthly_w[0], + _w001431_case44.LINE_83_SOLAR_GAINS_JAN_W, + "§6 (83) case44", + ) + _pin( + sh.useful_gains_monthly_w[0], + _w001431_case44.LINE_95_USEFUL_GAINS_JAN_W, + "§7 (95) case44", + ) + _pin( + sh.space_heating_per_m2_kwh, + _w001431_case44.LINE_99_SPACE_HEATING_PER_M2_KWH, + "§7 (99) case44", + ) + + def test_case44_blower_door_pressure_test_matches_pdf() -> None: """Simulated case 44 lodges a Blower Door air-pressure test (§12.2 "Pressure Test Result (AP50) 4.50"). SAP 10.2 §2 (17)-(18): From 694cdd9c23ddc60053feb513f0e2b1130b7e4b3b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:34:33 +0000 Subject: [PATCH 07/13] feat(modelling): mark a Property as run via has_recommendations + updated_at The new pipeline left no per-Property record of a run (the old engine set property.has_recommendations and populated property_details_epc). Restore the marker: PropertyRepository.mark_modelled sets has_recommendations (true when the Plan carries measures, mirroring the old engine) and bumps updated_at, so a first-run under the new process is identifiable as updated_at >= 2026-06-01. ModellingOrchestrator marks each Property after its Scenarios (true if any Scenario yielded a measure); run_modelling_e2e's --persist path marks it too (its compute runs on in-memory fakes, so the DB UoW sets it directly). Adds the has_recommendations/updated_at columns to the PropertyRow mirror. Co-Authored-By: Claude Opus 4.8 --- infrastructure/postgres/property_table.py | 8 +++++ orchestration/modelling_orchestrator.py | 8 +++++ .../property/property_postgres_repository.py | 14 ++++++++- repositories/property/property_repository.py | 9 ++++++ scripts/run_modelling_e2e.py | 6 ++++ tests/orchestration/fakes.py | 5 ++++ ...test_bulk_upload_finaliser_orchestrator.py | 5 ++++ .../property/test_property_repository.py | 29 +++++++++++++++++++ 8 files changed, 83 insertions(+), 1 deletion(-) diff --git a/infrastructure/postgres/property_table.py b/infrastructure/postgres/property_table.py index c333cad4..56d0b2fa 100644 --- a/infrastructure/postgres/property_table.py +++ b/infrastructure/postgres/property_table.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import ClassVar, Optional from sqlalchemy import Column @@ -48,3 +49,10 @@ class PropertyRow(SQLModel, table=True): user_inputted_address: Optional[str] = Field(default=None) user_inputted_postcode: Optional[str] = Field(default=None) lexiscore: Optional[float] = Field(default=None) + + # FE-owned columns the modelling pipeline now WRITES to record a run: the old + # engine set `has_recommendations` (engine.py); we mirror that, and bump + # `updated_at` so a run is datable (a first-run under the new process is + # `updated_at >= 2026-06-01`, the cutoff the old pipeline predates). + has_recommendations: Optional[bool] = Field(default=None) + updated_at: Optional[datetime] = Field(default=None) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 867cb8b2..55ae531d 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -126,6 +126,7 @@ class ModellingOrchestrator: solar_potential: Optional[SolarPotential] = _solar_potential_for( uow.solar, prop.identity.uprn ) + has_recommendations = False for scenario in scenarios: plan = self._plan_for( scorer, @@ -145,6 +146,13 @@ class ModellingOrchestrator: portfolio_id=portfolio_id, is_default=scenario.is_default, ) + has_recommendations = has_recommendations or bool(plan.measures) + # Record the run on the Property: the old engine's per-Property + # `has_recommendations` marker (true if any Scenario yielded a + # measure), with `updated_at` bumped so the run is datable. + uow.property.mark_modelled( + property_id, has_recommendations=has_recommendations + ) uow.commit() def _plan_for( diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py index 3549d0fc..cca62df6 100644 --- a/repositories/property/property_postgres_repository.py +++ b/repositories/property/property_postgres_repository.py @@ -2,8 +2,9 @@ from __future__ import annotations from typing import Optional, cast -from sqlalchemy import Table +from sqlalchemy import Table, func from sqlalchemy import select as sa_select +from sqlalchemy import update as sa_update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlmodel import Session, col, select @@ -116,6 +117,17 @@ class PropertyPostgresRepository(PropertyRepository): return {} return self._spatial_repo.get_for_uprns(uprns) + def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: + # The old engine set `has_recommendations` per Property; we mirror it and + # bump `updated_at` (DB clock) so a new-process run is datable against the + # 2026-06-01 cutoff. Does not commit — the Unit of Work owns the txn. + stmt = ( + sa_update(self._table) + .where(self._table.c.id == property_id) + .values(has_recommendations=has_recommendations, updated_at=func.now()) + ) + self._session.execute(stmt) # pyright: ignore[reportDeprecated] + def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: if not rows: return 0 diff --git a/repositories/property/property_repository.py b/repositories/property/property_repository.py index 5b22c874..e2f8284a 100644 --- a/repositories/property/property_repository.py +++ b/repositories/property/property_repository.py @@ -47,6 +47,15 @@ class PropertyRepository(ABC): input ids.""" ... + @abstractmethod + def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: + """Record that a Property has been run through the modelling pipeline: + set ``has_recommendations`` (the old engine's per-Property marker — true + when the Plan carries measures) and bump ``updated_at`` so the run is + datable (a first-run under the new process is ``updated_at >= + 2026-06-01``). Idempotent — re-running overwrites the same row.""" + ... + @abstractmethod def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: """Bulk-insert identity rows, skipping any whose ``(portfolio_id, uprn)`` diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index a9e39fa6..6dab17d4 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -334,6 +334,12 @@ def _persist( portfolio_id=portfolio_id, is_default=scenario.is_default, ) + # Mark the Property as run under the new process (old engine's + # `has_recommendations` marker + a bumped `updated_at`); the modelling + # compute above runs on in-memory fakes, so this DB UoW must set it. + uow.property.mark_modelled( + property_id, has_recommendations=bool(plan.measures) + ) uow.commit() diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 72f7cb4b..7b180ca3 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -63,6 +63,11 @@ class FakePropertyRepo(PropertyRepository): def get_many(self, property_ids: list[int]) -> Properties: return Properties([self._hydrate(property_id) for property_id in property_ids]) + def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: + # Record the marker so tests can assert the pipeline set it. + self.modelled: dict[int, bool] = getattr(self, "modelled", {}) + self.modelled[property_id] = has_recommendations + def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: self.inserted: list[PropertyIdentityInsert] = list(rows) return len(rows) diff --git a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py index 94742dca..335a3e91 100644 --- a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py +++ b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py @@ -50,6 +50,11 @@ class FakePropertyRepository(PropertyRepository): def get_many(self, property_ids: list[int]) -> Properties: # pragma: no cover raise NotImplementedError + def mark_modelled( # pragma: no cover + self, property_id: int, *, has_recommendations: bool + ) -> None: + raise NotImplementedError + class FakeStatusWriter(BulkUploadStatusWriter): def __init__(self) -> None: diff --git a/tests/repositories/property/test_property_repository.py b/tests/repositories/property/test_property_repository.py index c075964f..ab757bde 100644 --- a/tests/repositories/property/test_property_repository.py +++ b/tests/repositories/property/test_property_repository.py @@ -111,3 +111,32 @@ def test_get_many_defaults_to_unrestricted_when_uprn_has_no_spatial_row( # Assert — an uncovered UPRN means unrestricted, not blocked (per legacy # `empty_spatial_df`; ADR-0020). assert properties.items[0].planning_restrictions == PlanningRestrictions() + + +def test_mark_modelled_sets_has_recommendations_and_bumps_updated_at( + db_engine: Engine, +) -> None: + # Arrange — a freshly-inserted property with no run recorded yet. + with Session(db_engine) as session: + row = PropertyRow(portfolio_id=7, uprn=12345) + session.add(row) + session.commit() + property_id = row.id + assert property_id is not None + assert row.has_recommendations is None + assert row.updated_at is None + + # Act — record a run that produced recommendations. + with Session(db_engine) as session: + PropertyPostgresRepository(session).mark_modelled( + property_id, has_recommendations=True + ) + session.commit() + + # Assert — the marker is set and updated_at is stamped, so the run is datable + # against the 2026-06-01 new-process cutoff. + with Session(db_engine) as session: + refreshed = session.get(PropertyRow, property_id) + assert refreshed is not None + assert refreshed.has_recommendations is True + assert refreshed.updated_at is not None From d501535cbc2332d25260d25a349ca012fe79ba27 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:37:25 +0000 Subject: [PATCH 08/13] =?UTF-8?q?fix(mapper):=20map=20dropped=20=C2=A76.1?= =?UTF-8?q?=20non-separated=20conservatory=20(API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov API lodges a NON-SEPARATED conservatory (conservatory_type=4) as a glazed "building part" carrying only {floor_area, room_height, double_glazed, glazed_perimeter} — no fabric, no floor dimensions. The four fields were undeclared on the 21.0.1 SapBuildingPart, so `from_dict` dropped them and the conservatory was silently lost: it billed no §6.1 window/rooflight/floor and added nothing to TFA (5 corpus certs over-rated — too little heat loss → SAP too high). Fix (21.0.1 schema + mapper): - declare the four glazed fields on `SapBuildingPart`; - `_api_sap_conservatory` builds `EpcPropertyData.sap_conservatory` from the glazed BP (identified by a lodged `glazed_perimeter`; only type-4 conservatories lodge it — separated ones, §6.2, lodge nothing); - exclude the glazed BP from the fabric building-part loop (it is billed by the §6.1 cascade, not as a dwelling part); - `_total_floor_area_from_building_parts` adds the conservatory floor area to TFA (drives occupancy → §4/§5 demand). Validation is cross-mapper parity, NOT a corpus back-solve: the API mapper feeds the SAME worksheet-validated §6.1 cascade (`conservatory_geometry`, pinned to 1e-4 against the case-44 Summary) as the Elmhurst path — so the API conservatory fabric is correct by construction. `from_api_response` on an injected type-4 cert reproduces the glazed wall (perimeter × ground- floor room height = 22.05), glazed roof (floor/cos20 = 12.77) and Table 25 double U_eff (2.758 wall / 2.993 roof); a separated (type 2/3) cert lodges no glazed BP → disregarded per §6.2. Gauges: corpus within-0.5 67.9% → 68.6% (MAE 0.959 → 0.942; floor 0.67→0.68, ceiling 0.97→0.95); /tmp eval mean|err| 0.822 → 0.817. Harness 47/47 0-raised; regression = the 3 pre-existing fails; pyright net-zero (65=65). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 40 +++++++++ .../domain/tests/test_from_rdsap_schema.py | 85 +++++++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 10 +++ .../epc_client/test_sap_accuracy_corpus.py | 8 +- 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c2b5c2cc..84a1dba8 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2091,8 +2091,14 @@ class EpcPropertyDataMapper: else None ), ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; it is carried as `sap_conservatory` below and + # billed by the §6.1 cascade (window/rooflight/floor), not as + # a dwelling building part. for bp in schema.sap_building_parts + if getattr(bp, "glazed_perimeter", None) is None ], + sap_conservatory=_api_sap_conservatory(schema.sap_building_parts), renewable_heat_incentive=RenewableHeatIncentive( space_heating_kwh=float( schema.renewable_heat_incentive.space_heating_existing_dwelling @@ -2426,6 +2432,33 @@ def _measurement_value(field: Any) -> float: return float(field) +def _api_sap_conservatory(building_parts: Any) -> Optional[SapConservatory]: + """Build the domain `SapConservatory` from the gov-API glazed + conservatory building part — the part the API uses for a NON-SEPARATED + conservatory (RdSAP 10 §6.1, PDF p.49), identified by a lodged + `glazed_perimeter` (real dwelling parts carry fabric + floor dimensions + instead, never `glazed_perimeter`). Only type-4 (non-separated) + conservatories lodge this BP; separated ones (§6.2) lodge nothing, so + its presence is the §6.1 signal. Mirror of `_map_elmhurst_conservatory` + for the API path — proven equivalent by cross-mapper parity (the cascade + reads `epc.sap_conservatory` identically). Returns None when absent.""" + if not building_parts: + return None + for bp in building_parts: + if getattr(bp, "glazed_perimeter", None) is None: + continue + return SapConservatory( + floor_area_m2=_measurement_value(bp.floor_area), + glazed_perimeter_m=_measurement_value(bp.glazed_perimeter), + double_glazed=bp.double_glazed == "Y", + # The gov API only lodges this glazed BP for NON-separated + # (type-4) conservatories; separated ones (§6.2) lodge no BP. + thermally_separated=False, + room_height_storeys=float(_measurement_value(bp.room_height)), + ) + return None + + def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float]: """Sum per-bp `sap_floor_dimensions[*].total_floor_area` (plus each bp's `sap_room_in_roof.floor_area` when present) to recover the precise @@ -2450,6 +2483,13 @@ def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float total = 0.0 found = False for bp in building_parts: + # RdSAP 10 §6.1 — a non-separated conservatory's glazed BP (no floor + # dimensions) adds its floor area to the dwelling TFA. TFA drives + # occupancy → §4/§5 demand, so the conservatory must be in the sum. + if getattr(bp, "glazed_perimeter", None) is not None: + total += _measurement_value(bp.floor_area) + found = True + continue floor_dims: Any = bp.sap_floor_dimensions or [] for fd in floor_dims: total += _measurement_value(fd.total_floor_area) diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 84795363..77be7628 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2272,3 +2272,88 @@ class TestRoomInRoofType2SimplifiedQuadratic: assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9 assert len(commons) == 2 assert abs(commons[0].area_m2 - 10.00) <= 1e-9 + + +class TestNonSeparatedConservatoryApiMirror: + """RdSAP 10 §6.1 (PDF p.49) — the gov API lodges a NON-SEPARATED + conservatory (conservatory_type=4) as a glazed "building part" carrying + only {floor_area, room_height, double_glazed, glazed_perimeter}. The + block was undeclared → `from_dict` dropped it → the conservatory was + silently lost (5 corpus certs over-rating). The mapper now splits it + into `EpcPropertyData.sap_conservatory`, excludes it from the fabric + building-part loop, and adds its floor area to TFA. + + Validation is cross-mapper parity, NOT a corpus back-solve: the API + mapper feeds the SAME worksheet-validated §6.1 cascade + (`conservatory_geometry`, pinned to 1e-4 against the case-44 Summary) + as the Elmhurst path — so the API conservatory fabric is correct by + construction.""" + + def test_from_api_response_splits_out_conservatory_building_part( + self, + ) -> None: + # Arrange — a 1-BP dwelling (ground-floor room height 2.45 m) plus a + # non-separated double-glazed conservatory glazed BP. + from datatypes.epc.domain.epc_property_data import SapConservatory + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + baseline_tfa = EpcPropertyDataMapper.from_api_response( + load("21_0_1.json") + ).total_floor_area_m2 + + cert = load("21_0_1.json") + cert["conservatory_type"] = 4 + cert["sap_building_parts"].append( + { + "floor_area": 12.0, + "room_height": 1, + "double_glazed": "Y", + "glazed_perimeter": 9.0, + } + ) + + # Act + epc = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — conservatory split out; the glazed BP is NOT a fabric part. + assert epc.sap_conservatory == SapConservatory( + floor_area_m2=12.0, + glazed_perimeter_m=9.0, + double_glazed=True, + thermally_separated=False, + room_height_storeys=1.0, + ) + assert len(epc.sap_building_parts) == 1 + # §6.1: the conservatory floor area joins TFA (drives occupancy). + assert abs(epc.total_floor_area_m2 - (baseline_tfa + 12.0)) <= 1e-9 + + # Cross-mapper parity: the shared §6.1 cascade derives the same + # surfaces it does for the case-44 Summary — glazed wall = exposed + # perimeter × ground-floor room height (9.0 × 2.45 = 22.05); glazed + # roof = floor / cos(20°) (12.0 / 0.9397 = 12.77); Table 25 double + # U_eff = 1/(1/3.1 + 0.04) = 2.758 (wall) / 1/(1/3.4 + 0.04) = 2.993. + geom = conservatory_geometry(epc) + assert geom is not None + assert abs(geom.glazed_wall_area_m2 - 22.05) <= 1e-4 + assert abs(geom.glazed_roof_area_m2 - 12.77) <= 1e-4 + assert abs(geom.wall_u_eff - 2.7580) <= 1e-4 + assert abs(geom.roof_u_eff - 2.9930) <= 1e-4 + + def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None: + # Arrange — a separated conservatory (type 2/3) lodges NO glazed BP + # (verified across the gov corpus); the dwelling is unchanged. + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + cert = load("21_0_1.json") + cert["conservatory_type"] = 2 + + # Act + epc = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — §6.2: disregarded; no conservatory geometry. + assert epc.sap_conservatory is None + assert conservatory_geometry(epc) is None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 0c6379d9..45f1ee46 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -382,6 +382,16 @@ class SapBuildingPart: # redacts the backing insulation. Previously undeclared → dropped. wall_u_value: Optional[float] = None floor_u_value: Optional[float] = None + # RdSAP 10 §6.1 (PDF p.49) — a NON-SEPARATED conservatory is lodged by + # the gov API as a glazed "building part" carrying ONLY these four + # fields (no fabric, no floor dimensions); `conservatory_type == 4` at + # the property level. Previously undeclared → dropped by `from_dict`, + # so the conservatory was silently lost on the API path. The mapper + # splits this BP out into `EpcPropertyData.sap_conservatory`. + floor_area: Optional[Union[Measurement, int, float]] = None + room_height: Optional[Union[Measurement, int, float]] = None + double_glazed: Optional[str] = None + glazed_perimeter: Optional[Union[Measurement, int, float]] = None @dataclass diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 5f8d3669..1cddd87f 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -98,8 +98,12 @@ _CORPUS = Path( # The §3.9.2 Simplified Type-2 RR mapper (room_in_roof_type_2: gable quadratic + # common-wall L×(0.25+H), MIRRORING the worksheet-validated Summary path, # cross-mapper-parity-exact on cert 000565) -> 67.9% (MAE 0.959). -_MIN_WITHIN_HALF_SAP = 0.67 -_MAX_SAP_MAE = 0.97 +# The §6.1 non-separated conservatory mapper (the gov API's glazed building +# part → SapConservatory → §6.1 window/rooflight/floor cascade + TFA, MIRRORING +# the case-44 Summary path pinned to 1e-4) -> 68.6% (MAE 0.942). 5 type-4 +# certs were over-rating (conservatory dropped → too little heat loss). +_MIN_WITHIN_HALF_SAP = 0.68 +_MAX_SAP_MAE = 0.95 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current From ea72ee97bf7b7a8bad4febf052a2f90df9502115 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:45:23 +0000 Subject: [PATCH 09/13] feat(scripts): add full AraFirstRunPipeline local runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/run_first_run_e2e.py runs the real Ingestion -> Baseline -> Modelling pipeline against the DB by composing build_first_run_pipeline + dispatch_first_run with the live source clients (the Lambda handler can't run locally — its _source_clients_from_env still raises, #1136). Unlike run_modelling_e2e it runs real ingestion (persists EPC/spatial/solar) and has no inspect-only mode, so it's gated behind --confirm (preview otherwise); measure scoping comes only from the Scenario's exclusions (the pipeline threads no --measures), and the modelling batch is all-or-nothing, both documented. Extract the shared env/engine/S3 plumbing into scripts/e2e_common.py (public load_env/build_engine/s3_parquet_reader) so both runners share one source and neither imports the other's privates. Co-Authored-By: Claude Opus 4.8 --- scripts/e2e_common.py | 68 +++++++++++++++ scripts/run_first_run_e2e.py | 162 +++++++++++++++++++++++++++++++++++ scripts/run_modelling_e2e.py | 65 +++----------- 3 files changed, 241 insertions(+), 54 deletions(-) create mode 100644 scripts/e2e_common.py create mode 100644 scripts/run_first_run_e2e.py diff --git a/scripts/e2e_common.py b/scripts/e2e_common.py new file mode 100644 index 00000000..56c82543 --- /dev/null +++ b/scripts/e2e_common.py @@ -0,0 +1,68 @@ +"""Shared configuration + client plumbing for the local e2e runner scripts +(``run_modelling_e2e`` and ``run_first_run_e2e``). + +Loads ``backend/.env`` and builds the DB engine from the FastAPI-layer ``DB_*`` +vars (the ``infrastructure/postgres`` layer reads ``POSTGRES_*``, which the .env +does not carry), plus an S3-backed ``ParquetReader`` for the geospatial +repository. Secrets live in the .env and the ambient ``~/.aws`` profile; this +module never hard-codes them. +""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +from typing import Any, cast + +import boto3 +import pandas as pd +from sqlalchemy import Engine, create_engine + +from repositories.geospatial.geospatial_s3_repository import ParquetReader + +_REPO_ROOT = Path(__file__).resolve().parents[1] +ENV_PATH = _REPO_ROOT / "backend" / ".env" + + +def load_env(path: Path = ENV_PATH) -> None: + """Load `KEY=value` lines from `backend/.env` into the environment (without + overriding anything already set), so the DB creds + API tokens are present.""" + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def db_url() -> str: + """The connection string from the FastAPI-layer `DB_*` env vars.""" + env = os.environ + return ( + f"postgresql+psycopg2://{env['DB_USERNAME']}:{env['DB_PASSWORD']}" + f"@{env['DB_HOST']}:{env['DB_PORT']}/{env['DB_NAME']}" + ) + + +def build_engine() -> Engine: + """A connection-pooled engine to the target DB (DB_* creds).""" + return create_engine( + db_url(), pool_pre_ping=True, connect_args={"connect_timeout": 10} + ) + + +def s3_parquet_reader(bucket: str) -> ParquetReader: + """A `ParquetReader` (key -> DataFrame) backed by `bucket` in S3, for the + `GeospatialS3Repository`. AWS creds come from the ambient `~/.aws` profile; + pyarrow reads the parquet bytes (s3fs is not installed here).""" + # boto3 ships only partial type stubs, so the client is an untyped boundary. + client = cast(Any, boto3.client("s3")) # pyright: ignore[reportUnknownMemberType] + + def read(key: str) -> pd.DataFrame: + body = cast(bytes, client.get_object(Bucket=bucket, Key=key)["Body"].read()) + return pd.read_parquet(io.BytesIO(body)) + + return read diff --git a/scripts/run_first_run_e2e.py b/scripts/run_first_run_e2e.py new file mode 100644 index 00000000..b4c46f90 --- /dev/null +++ b/scripts/run_first_run_e2e.py @@ -0,0 +1,162 @@ +"""Run the **full** ``AraFirstRunPipeline`` (Ingestion → Baseline → Modelling) +end-to-end against the real database, locally. + +This is the production pipeline the ``ara_first_run`` Lambda runs, driven from a +shell instead of an SQS event. The Lambda ``handler`` itself cannot run locally — +``applications/ara_first_run/handler.py::_source_clients_from_env`` deliberately +raises until the deploy/Terraform wiring lands (#1136). So this script composes +the same pipeline directly via the existing ``build_first_run_pipeline`` seam, +supplying the three source clients that ``run_modelling_e2e`` already proves out +(EPC API, geospatial S3, Google Solar), then calls ``dispatch_first_run``. + +How it differs from ``run_modelling_e2e``: + * It runs the **real Ingestion stage** — fetches each Property's EPC by UPRN, + resolves spatial + Google Solar, and **persists** them (``epc_property`` / + ``property_details_spatial`` / ``solar``) — then Baseline, then Modelling. + ``run_modelling_e2e`` does ingestion inline and only models. + * **There is no inspect-only mode**: the stages persist as they go (ADR-0012), + so any run writes to the DB. This script is gated behind ``--confirm``; without + it the script previews what it would do and exits. + * **The modelling batch is all-or-nothing**: each stage commits once per batch, + so one Property raising aborts the whole batch (no per-Property recovery like + ``run_modelling_e2e``). Make sure the inputs are clean first. + +Measure scoping comes **only from the Scenario's exclusions** — the pipeline +threads no ``--measures`` override (issue #1130). So if the live ``material`` +catalogue cannot price/represent a measure a Property is eligible for (today: +``secondary_heating_removal``, absent from the ``material.type`` enum), that +Property's modelling raises and aborts the batch. Exclude it on the Scenario +first, e.g.:: + + UPDATE scenario SET exclusions = '{secondary_heating_removal}' WHERE id = 1266; + +EPC Prediction (ADR-0031) is left **off** — its Landlord-Override attributes +reader is not wired here, so an EPC-less Property is not gap-filled. + +Config + secrets are loaded exactly as ``run_modelling_e2e`` does: ``backend/.env`` +for the DB creds (``DB_*``), the EPC Bearer token (``OPEN_EPC_API_TOKEN``), the +Google Solar key (``GOOGLE_SOLAR_API_KEY``) and the S3 bucket (``DATA_BUCKET``); +AWS creds from the ambient ``~/.aws`` profile. Run from the worktree root:: + + # preview only (no writes): print what would run, then exit + python -m scripts.run_first_run_e2e --scenario-ids 1266 --portfolio-id 785 \ + 709634 709635 709636 + # actually run the full pipeline and persist (Ingestion -> Baseline -> Modelling) + python -m scripts.run_first_run_e2e --scenario-ids 1266 --portfolio-id 785 \ + --confirm 709634 709635 709636 +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from uuid import uuid4 + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap + +from applications.ara_first_run.ara_first_run_trigger_body import ( # noqa: E402 + AraFirstRunTriggerBody, +) +from applications.ara_first_run.handler import ( # noqa: E402 + build_first_run_pipeline, + dispatch_first_run, +) +from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402 +from infrastructure.solar.google_solar_api_client import ( # noqa: E402 + GoogleSolarApiClient, +) +from repositories.geospatial.geospatial_s3_repository import ( # noqa: E402 + GeospatialS3Repository, +) +from repositories.postgres_unit_of_work import PostgresUnitOfWork # noqa: E402 +from scripts.e2e_common import ( # noqa: E402 + ENV_PATH, + build_engine, + load_env, + s3_parquet_reader, +) +from sqlmodel import Session # noqa: E402 + + +def _parse_ids(raw: str) -> list[int]: + """Parse a comma-separated id list (e.g. ``--scenario-ids 1266,1270``).""" + return [int(token.strip()) for token in raw.split(",") if token.strip()] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "property_ids", type=int, nargs="+", help="Property ids to run" + ) + parser.add_argument( + "--scenario-ids", + required=True, + help="comma-separated Scenario ids to model against (exclusions come " + "from each Scenario)", + ) + parser.add_argument( + "--portfolio-id", type=int, required=True, help="portfolio id for the run" + ) + parser.add_argument( + "--confirm", + action="store_true", + default=False, + help="actually run the pipeline and WRITE to the DB (default: preview only)", + ) + args = parser.parse_args() + + scenario_ids = _parse_ids(args.scenario_ids) + + load_env(ENV_PATH) + engine = build_engine() + + body = AraFirstRunTriggerBody( + # task/sub_task drive the Lambda SubTask lifecycle only; running the + # pipeline directly bypasses the @subtask_handler decorator, so synthetic + # ids satisfy validation without touching the task tables. + task_id=uuid4(), + sub_task_id=uuid4(), + portfolio_id=args.portfolio_id, + property_ids=args.property_ids, + scenario_ids=scenario_ids, + ) + + print( + f"full AraFirstRunPipeline (Ingestion -> Baseline -> Modelling) · " + f"{len(args.property_ids)} propertie(s) · scenarios {scenario_ids} · " + f"portfolio {args.portfolio_id}" + ) + if not args.confirm: + print( + "\nPREVIEW ONLY — no writes. This run WOULD fetch + persist EPC/" + "spatial/solar, rebaseline, and model+persist Plans for:\n" + f" properties: {args.property_ids}\n" + "Re-run with --confirm to execute. NOTE: the modelling batch is " + "all-or-nothing; ensure each Scenario excludes any measure the live " + "catalogue cannot price (e.g. secondary_heating_removal)." + ) + return + + epc_fetcher = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"]) + geospatial_repo = GeospatialS3Repository( + s3_parquet_reader(os.environ["DATA_BUCKET"]) + ) + solar_fetcher = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"]) + + pipeline = build_first_run_pipeline( + unit_of_work=lambda: PostgresUnitOfWork(lambda: Session(engine)), + epc_fetcher=epc_fetcher, + geospatial_repo=geospatial_repo, + solar_fetcher=solar_fetcher, + ) + + print("running... (Ingestion -> Baseline -> Modelling, persisting per stage)\n") + dispatch_first_run(body.model_dump(), pipeline=pipeline) + print("done — EPC/spatial/solar + Baseline + Plans persisted for the batch.") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index 6dab17d4..fb919f82 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -53,14 +53,10 @@ Google leg. from __future__ import annotations import argparse -import io import os import sys from pathlib import Path -from typing import Any, Optional, cast - -import boto3 -import pandas as pd +from typing import Any, Optional _REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap @@ -84,7 +80,6 @@ from infrastructure.solar.google_solar_api_client import ( # noqa: E402 ) from repositories.geospatial.geospatial_s3_repository import ( # noqa: E402 GeospatialS3Repository, - ParquetReader, ) from repositories.product.product_postgres_repository import ( # noqa: E402 ProductPostgresRepository, @@ -93,51 +88,20 @@ from repositories.postgres_unit_of_work import PostgresUnitOfWork # noqa: E402 from repositories.scenario.scenario_postgres_repository import ( # noqa: E402 ScenarioPostgresRepository, ) -from sqlalchemy import Engine, create_engine, text # noqa: E402 +from scripts.e2e_common import ( # noqa: E402 + ENV_PATH, + build_engine, + load_env, + s3_parquet_reader, +) +from sqlalchemy import Engine, text # noqa: E402 from sqlmodel import Session # noqa: E402 -_ENV_PATH = _REPO_ROOT / "backend" / ".env" _MARKDOWN_PATH = Path("modelling_e2e.md") _CSV_PATH = Path("modelling_e2e.csv") _CANDIDATES_CSV_PATH = Path("modelling_e2e_candidates.csv") -def _load_env(path: Path) -> None: - """Load `KEY=value` lines from `backend/.env` into the environment (without - overriding anything already set), so the DB creds + EPC token are present.""" - if not path.exists(): - return - for raw in path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) - - -def _db_url() -> str: - """The connection string from the FastAPI-layer `DB_*` env vars.""" - env = os.environ - return ( - f"postgresql+psycopg2://{env['DB_USERNAME']}:{env['DB_PASSWORD']}" - f"@{env['DB_HOST']}:{env['DB_PORT']}/{env['DB_NAME']}" - ) - - -def _s3_parquet_reader(bucket: str) -> ParquetReader: - """A `ParquetReader` (key -> DataFrame) backed by `bucket` in S3, for the - `GeospatialS3Repository`. AWS creds come from the ambient `~/.aws` profile; - pyarrow reads the parquet bytes (s3fs is not installed here).""" - # boto3 ships only partial type stubs, so the client is an untyped boundary. - client = cast(Any, boto3.client("s3")) # pyright: ignore[reportUnknownMemberType] - - def read(key: str) -> pd.DataFrame: - body = cast(bytes, client.get_object(Bucket=bucket, Key=key)["Body"].read()) - return pd.read_parquet(io.BytesIO(body)) - - return read - - def _spatial_for(repo: GeospatialS3Repository, uprn: int) -> Optional[SpatialReference]: """The UPRN's spatial reference (coordinates + planning protections), or None when S3 doesn't cover it — a missing reference must not abort the run, @@ -166,13 +130,6 @@ def _solar_insights_for( return None # no Google solar coverage at this point — model without it -def _engine() -> Engine: - """A connection-pooled engine to DevAssessmentModelDB (DB_* creds).""" - return create_engine( - _db_url(), pool_pre_ping=True, connect_args={"connect_timeout": 10} - ) - - def _uprns_for(engine: Engine, property_ids: list[int]) -> dict[int, Optional[int]]: """Read each Property's UPRN from the DB (read-only).""" with engine.connect() as conn: @@ -389,14 +346,14 @@ def main() -> None: if args.persist and (args.scenario_id is None or args.portfolio_id is None): parser.error("--persist requires --scenario-id and --portfolio-id") - _load_env(_ENV_PATH) + load_env(ENV_PATH) # The new gov EPC API (Bearer) authenticates with OPEN_EPC_API_TOKEN — the # name is misleading; EPC_AUTH_TOKEN is dead (403). Verified against the # /api/domestic/search endpoint. epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"]) - geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"])) + geospatial = GeospatialS3Repository(s3_parquet_reader(os.environ["DATA_BUCKET"])) solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"]) - engine = _engine() + engine = build_engine() cli_considered = _resolve_considered( _parse_measures(args.measures), _parse_measures(args.exclude_measures) ) From c5aa5620ca532b09e91736a083d9defe2704a307 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 17 Jun 2026 00:26:25 +0000 Subject: [PATCH 10/13] =?UTF-8?q?fix(uvalues):=20apply=20=C2=A75.8=20insul?= =?UTF-8?q?ation=20R=20to=20stone=20walls=20(RdSAP=2010=20p.41-42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5.8 Table-14 added-insulation R-value adjustment was gated to WALL_SOLID_BRICK, so a stone (granite/sandstone) wall lodging wall_insulation_type 1/3 ("External"/"Internal") + a thickness fell through the §5.6 thin-wall branch and was billed at its UNINSULATED U (e.g. sandstone 520 mm + 100 mm internal: 1.64 instead of 0.30 → ~5× the wall heat loss). Mirror the brick insulation branch into the stone block, feeding the RAW §5.6 U₀ into the §5.8 chain per the same rule the brick branch and the dry-lined granite pin 000565 already follow (the Table-6 footnote (a) 1.7 cap does not apply on the insulated path). Corpus cert 100052159386 (sandstone 520 mm + 100 mm internal): -26.20 -> -4.08 SAP, walls 300 -> 55 W/K. RdSAP-21.0.1 corpus within-0.5 68.6% -> 68.8% (SAP MAE 0.942 -> 0.888; PE MAE 14.3 -> 13.9; CO2 0.27 -> 0.26); floors/ceilings ratcheted. Unit-pinned in test_rdsap_uvalues. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 29 ++++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 74 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 17 ++++- 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index b2e76b8f..33b9741c 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -589,6 +589,35 @@ def u_wall( ): u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm) if u0 is not None: + # RdSAP 10 §5.8 + Table 14 (PDF p.41-42) — added External/Internal + # insulation on a stone wall: U = 1/(1/U₀ + R_ins), with U₀ the + # RAW §5.6 stone result (the Table-6 footnote (a) 1.7 cap does NOT + # apply to the insulated path — same rule the brick branch below + # and the dry-lined granite pin 000565 follow). λ defaults to + # 0.04 W/m·K per §5.8 final note. Mirrors the WALL_SOLID_BRICK + # insulated branch; without it a stone wall lodging code 1/3 + + # a thickness was billed at its UNINSULATED U (e.g. sandstone + # 520 mm + 100 mm internal: 1.64 → 0.30), the dominant cause of + # the wall_insulation_type=3 corpus under-rate cluster. + if ( + wall_insulation_type in ( + _WALL_INSULATION_EXTERNAL, _WALL_INSULATION_INTERNAL, + ) + and insulation_thickness_mm is not None + and insulation_thickness_mm > 0 + ): + r_ins = _r_insulation_table_14( + insulation_thickness_mm, + _resolve_wall_insulation_lambda_w_per_mk( + wall_insulation_thermal_conductivity + ), + ) + u_unrounded = 1.0 / (1.0 / u0 + r_ins) + return float( + Decimal(str(u_unrounded)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ) if dry_lined: # Round to 2 d.p. — worksheet (29a) A×U product uses # the 2-d.p.-displayed U (cf. 000565 Main alt_wall_1: diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 8d4e3612..38efc93c 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -725,6 +725,80 @@ def test_u_wall_stone_sandstone_thin_wall_age_a_120mm_uses_5_6_sandstone_formula assert abs(result - 3.7408) <= 1e-3 +def test_u_wall_stone_sandstone_with_internal_insulation_applies_5_8_table_14_r_value() -> None: + # Arrange — RdSAP 10 §5.8 + Table 14 (PDF p.41-42): a stone wall lodging + # External/Internal insulation (wall_insulation_type 1/3) + a thickness + # gets the same R-value adjustment as solid brick, applied to the RAW §5.6 + # U₀. Mirrors corpus cert 100052159386 (Sandstone, 520 mm, 100 mm internal): + # U₀ = 54.876 × 520^(-0.561) = 1.6433 + # R = 0.025 × 100 + 0.25 = 2.75 (Table 14, λ = 0.04) + # U = 1 / (1/1.6433 + 2.75) = 0.2977 → 0.30 (2 d.p.) + # Before this branch the wall was billed at its UNINSULATED U (≈1.64), + # the dominant cause of the wall_insulation_type=3 corpus under-rate cluster. + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=100, + insulation_present=True, + wall_insulation_type=3, + dry_lined=False, + wall_thickness_mm=520, + ) + + # Assert + assert abs(result - 0.30) <= 1e-4 + + +def test_u_wall_stone_sandstone_insulated_feeds_raw_u0_not_table_6_cap() -> None: + # Arrange — the Table-6 footnote (a) 1.7 cap applies ONLY to the as-built + # row; the insulated §5.8 path takes the RAW §5.6 U₀ (same rule the brick + # branch and the dry-lined granite pin 000565 follow). At W=120 mm the raw + # sandstone U₀ = 3.7408 (> 1.7), so the 100 mm internal result must be + # 1 / (1/3.7408 + 2.75) = 0.331 → 0.33 (raw), + # NOT the capped 1 / (1/1.7 + 2.75) = 0.30. The 0.33 vs 0.30 split proves + # the cap is bypassed on the insulated path. + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=100, + insulation_present=True, + wall_insulation_type=3, + dry_lined=False, + wall_thickness_mm=120, + ) + + # Assert + assert abs(result - 0.33) <= 1e-4 + + +def test_u_wall_stone_granite_with_external_insulation_applies_5_8_table_14_r_value() -> None: + # Arrange — granite/whinstone §5.6 formula + §5.8 external insulation: + # U₀ = 45.315 × 120^(-0.513) = 3.8871 + # R = 0.025 × 50 + 0.25 = 1.50 (Table 14, λ = 0.04) + # U = 1 / (1/3.8871 + 1.50) = 0.567 → 0.57 (2 d.p.) + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=50, + insulation_present=True, + wall_insulation_type=1, + dry_lined=False, + wall_thickness_mm=120, + ) + + # Assert + assert abs(result - 0.57) <= 1e-4 + + def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None: # Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula # to "age bands A to E". For age F onwards Table 6 gives literal diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 1cddd87f..8944b576 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -102,10 +102,19 @@ _CORPUS = Path( # part → SapConservatory → §6.1 window/rooflight/floor cascade + TFA, MIRRORING # the case-44 Summary path pinned to 1e-4) -> 68.6% (MAE 0.942). 5 type-4 # certs were over-rating (conservatory dropped → too little heat loss). -_MIN_WITHIN_HALF_SAP = 0.68 -_MAX_SAP_MAE = 0.95 -_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current +# STONE WALL + INTERNAL/EXTERNAL INSULATION (RdSAP 10 §5.8 + Table 14, p.41-42): +# the §5.8 added-insulation R-value adjustment was applied ONLY to WALL_SOLID_ +# BRICK; a stone (granite/sandstone) wall lodging wall_insulation_type 1/3 + a +# thickness fell through the §5.6 branch and was billed at its UNINSULATED U +# (e.g. sandstone 520 mm + 100 mm internal: 1.64 instead of 0.30 → 5× wall heat +# loss). Mirroring the brick branch into the stone block recovered the worst of +# the wall_insulation_type=3 under-rate cluster (cert 100052159386 -26.2 -> -4.1 +# SAP, walls 300 -> 55 W/K). within-0.5 68.6% -> 68.8% (MAE 0.942 -> 0.888; +# PE MAE 14.3 -> 13.9; CO2 MAE 0.27 -> 0.26). Unit-pinned in test_rdsap_uvalues. +_MIN_WITHIN_HALF_SAP = 0.685 +_MAX_SAP_MAE = 0.89 +_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 14.5 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: From e136e937d6f7657ad3f5c66a5d0f2e77ef92271c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 17 Jun 2026 00:48:50 +0000 Subject: [PATCH 11/13] =?UTF-8?q?fix(heat-transmission):=20match=20roof=20?= =?UTF-8?q?description=20per=20part=20by=20kind=20(RdSAP=2010=20=C2=A75.11?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deduplicated `epc.roofs[]` list cannot be indexed 1:1 against the building parts (190/329 multi-part certs have len(roofs) != len(parts)), so every part's `u_roof` consumed a SINGLE join of all roof descriptions. That leaked one part's insulation state onto another: a "Flat, no insulation" extension dragged a "Pitched, insulated (assumed)" main roof to the uninsulated 2.30, ~3x over-stating its heat loss. 3-part certs systematically under-rated (56% within-0.5, mean -0.79 SAP). Partition the non-RR roof descriptions into flat vs pitched/sloping and match each part to its own kind (`_main_roof_descriptions_by_kind`), falling back to the global join when a part's kind has no matching entry. Corpus cert 100010129331: roof 110.5 -> 31.3 W/K, +13.10 -> -0.05 SAP. RdSAP-21.0.1 within-0.5 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 -> 13.6); 3-part cohort 56% -> 61%. Floors/ceilings ratcheted. Pinned in test_heat_transmission (by_kind split + mixed-roof no-contamination). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../worksheet/heat_transmission.py | 42 ++++++++++- .../worksheet/test_heat_transmission.py | 75 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 14 +++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 4d2971d8..d12b7da9 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -400,6 +400,32 @@ def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]: return " | ".join(parts) +def _main_roof_descriptions_by_kind( + roofs: list[Any], +) -> tuple[Optional[str], Optional[str]]: + """Partition the non-RR roof descriptions into ``(pitched, flat)`` joins. + + The deduplicated ``epc.roofs[]`` list cannot be indexed 1:1 against the + building parts (190/329 multi-part certs have len(roofs) != len(parts)), + so each part's ``u_roof`` historically consumed the SINGLE join of every + roof description. That leaks one part's insulation state onto another: a + "Flat, no insulation" extension dragged a "Pitched, insulated (assumed)" + main roof to the uninsulated 2.30, ~3x over-stating its heat loss (cert + 100010129331: roof 110.5 -> ~28 W/K, +13 SAP). Splitting by flat vs + pitched/sloping lets each part match its own kind; the global join + (`_joined_main_roof_descriptions`) stays the fallback when a part's kind + has no matching entry. "Roof room(s)" entries are dropped (they carry + their own §3.9/§3.10 shell cascade).""" + pitched: list[str] = [] + flat: list[str] = [] + for e in roofs: + d = getattr(e, "description", "") + if not d or "roof room" in d.lower(): + continue + (flat if "flat" in d.lower() else pitched).append(d) + return (" | ".join(pitched) or None, " | ".join(flat) or None) + + def _part_geometry(part: SapBuildingPart) -> dict[str, float]: if not part.sap_floor_dimensions: # A part with no floor dimensions has no derivable RR shell or @@ -617,6 +643,9 @@ def heat_transmission_from_cert( country = Country.from_code(epc.country_code) roof_description = _joined_main_roof_descriptions(epc.roofs) + pitched_roof_description, flat_roof_description = ( + _main_roof_descriptions_by_kind(epc.roofs) + ) wall_description = _joined_descriptions(epc.walls) floor_description = _joined_descriptions(epc.floors) @@ -888,8 +917,19 @@ def heat_transmission_from_cert( roof_thickness_explicitly_zero = ( isinstance(raw_roof_thickness, int) and raw_roof_thickness == 0 ) + # RdSAP 10 §5.11 — match THIS part's roof to its own kind's lodged + # description (flat vs pitched/sloping) rather than the global join, + # so a flat "no insulation" part does not drag a pitched insulated + # part to the uninsulated 2.30. Fall back to the global join when the + # part's kind has no matching `epc.roofs[]` entry. + part_roof_is_flat = "flat" in (part.roof_construction_type or "").lower() + matched_roof_description = ( + flat_roof_description if part_roof_is_flat else pitched_roof_description + ) + if matched_roof_description is None: + matched_roof_description = roof_description effective_roof_description = ( - None if roof_thickness_explicitly_zero else roof_description + None if roof_thickness_explicitly_zero else matched_roof_description ) # RdSAP 10 §5.11 Table 18 page 45: column (3) "Flat roof" applies # when the per-bp roof construction lodges as a flat roof and the diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index d1ed0c92..d841746e 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -38,6 +38,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( from domain.sap10_calculator.worksheet.heat_transmission import ( _alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage] _joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage] + _main_roof_descriptions_by_kind, # pyright: ignore[reportPrivateUsage] _part_geometry, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage] _window_bp_index, # pyright: ignore[reportPrivateUsage] @@ -82,6 +83,80 @@ def test_joined_main_roof_descriptions_keeps_pure_rr_fallback() -> None: assert result == "Roof room(s), no insulation (assumed)" +def test_main_roof_descriptions_by_kind_splits_flat_from_pitched() -> None: + # Arrange — a cert with a pitched insulated main roof + a flat + # uninsulated extension. The deduplicated epc.roofs[] cannot be indexed + # 1:1 against the parts, so each part must match its own KIND's + # description: the flat part's "no insulation" must not leak onto the + # pitched part (which would force the whole pitched roof to U=2.30). + roofs = [ + _Desc("Pitched, insulated (assumed)"), + _Desc("Flat, no insulation"), + _Desc("Roof room(s), no insulation (assumed)"), + ] + + # Act + pitched, flat = _main_roof_descriptions_by_kind(roofs) + + # Assert — RR dropped; flat and pitched kept apart. + assert pitched == "Pitched, insulated (assumed)" + assert flat == "Flat, no insulation" + + +def test_mixed_flat_pitched_roof_does_not_contaminate_pitched_u_value() -> None: + # Arrange — 2-part dwelling: a 100 m² pitched insulated-assumed main + # roof (U=0.40) + a 2 m² flat uninsulated extension (U=2.30). Before the + # per-kind split, the joined "Pitched, insulated (assumed) | Flat, no + # insulation" description leaked the flat's "no insulation" onto the + # pitched part, billing the WHOLE roof at 2.30 (100×2.30 + 2×2.30 = + # 234.6 W/K). Correct: 100×0.40 + 2×2.30 = 44.6 W/K. Mirrors corpus + # cert 100010129331 (roof 110.5 -> 31.3 W/K, +13 -> 0 SAP). + main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + ext = make_building_part( + identifier=BuildingPartIdentifier.EXTENSION_1, + construction_age_band="C", + roof_construction=5, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=2.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=6.0, floor=0, + ), + ], + ) + ext.roof_construction_type = "Flat" + epc = make_minimal_sap10_epc( + total_floor_area_m2=102.0, + country_code="ENG", + sap_building_parts=[main, ext], + ) + epc.roofs = [ + EnergyElement( + description="Pitched, insulated (assumed)", + energy_efficiency_rating=4, environmental_efficiency_rating=4, + ), + EnergyElement( + description="Flat, no insulation", + energy_efficiency_rating=1, environmental_efficiency_rating=1, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — pitched main billed at its insulated U, not the flat's 2.30. + assert abs(result.roof_w_per_k - 44.6) <= 2.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 8944b576..9aed0f34 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -111,10 +111,18 @@ _CORPUS = Path( # the wall_insulation_type=3 under-rate cluster (cert 100052159386 -26.2 -> -4.1 # SAP, walls 300 -> 55 W/K). within-0.5 68.6% -> 68.8% (MAE 0.942 -> 0.888; # PE MAE 14.3 -> 13.9; CO2 MAE 0.27 -> 0.26). Unit-pinned in test_rdsap_uvalues. -_MIN_WITHIN_HALF_SAP = 0.685 -_MAX_SAP_MAE = 0.89 +# PER-PART ROOF DESCRIPTION (RdSAP 10 §5.11): the deduplicated epc.roofs[] list +# was joined into ONE description fed to EVERY building part's u_roof, so a flat +# "no insulation" extension dragged a pitched "insulated (assumed)" main roof to +# the uninsulated 2.30 (3-part certs systematically under-rated: 56% within, +# -0.79 mean). Matching each part to its own kind (flat vs pitched) fixed cert +# 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 +_MAX_SAP_MAE = 0.86 _MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 14.5 # kWh / m2 / yr vs energy_consumption_current +_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: From edf1003dcf4eaff7f87b5f25f276684f70633cc7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 17 Jun 2026 01:45:18 +0000 Subject: [PATCH 12/13] fix(epc): hydrate recorded performance, RHI, and dates on read The Baseline stage is the first consumer to read these off a persisted EPC end-to-end, surfacing three gaps that only manifest on real API data: - Only the 21.0.1 mapper copied through the recorded current-performance scalars (SAP rating, CO2, PEUI) and *no* mapper mapped the EPC band, so Lodged Performance raised for 17.x/18.0/19.0/20.0.0 certs. Overlay all four from the raw payload in `from_api_response`, once, for every schema version. - Likewise the `renewable_heat_incentive` block (baseline space/water-heating kWh) was only mapped by the 21.x paths. Gap-fill it centrally from the raw payload when a mapper left it unset. - The FE-owned `epc_property` date columns are Postgres `timestamp`s while the SQLModel mirror types them `str`, so a read hands back a `datetime` and `date.fromisoformat()` raised. Normalise via `_as_date()`. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 142 +++++++++++++++----- repositories/epc/epc_postgres_repository.py | 28 +++- 2 files changed, 129 insertions(+), 41 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b3fc944f..159c0f92 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5,6 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal from typing import Any, Dict, Final, List, Optional, Sequence, TypeVar, Union, cast from datatypes.epc.schema.helpers import from_dict +from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import ( BASEMENT_WALL_CONSTRUCTION_CODE, Addendum, @@ -2268,61 +2269,53 @@ class EpcPropertyDataMapper: if schema == "RdSAP-Schema-21.0.1": from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_21_0_1( - from_dict(RdSapSchema21_0_1, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_21_0_1( + from_dict(RdSapSchema21_0_1, data) ) - if schema == "RdSAP-Schema-21.0.0": + elif schema == "RdSAP-Schema-21.0.0": from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_21_0_0( - from_dict(RdSapSchema21_0_0, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_21_0_0( + from_dict(RdSapSchema21_0_0, data) ) - if schema == "RdSAP-Schema-20.0.0": + elif schema == "RdSAP-Schema-20.0.0": from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_20_0_0( - from_dict(RdSapSchema20_0_0, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_20_0_0( + from_dict(RdSapSchema20_0_0, data) ) - if schema == "RdSAP-Schema-19.0": + elif schema == "RdSAP-Schema-19.0": from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_19_0( - from_dict(RdSapSchema19_0, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_19_0( + from_dict(RdSapSchema19_0, data) ) - if schema == "RdSAP-Schema-18.0": + elif schema == "RdSAP-Schema-18.0": from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_18_0( - from_dict(RdSapSchema18_0, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_18_0( + from_dict(RdSapSchema18_0, data) ) - if schema == "RdSAP-Schema-17.1": + elif schema == "RdSAP-Schema-17.1": from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_17_1( - from_dict(RdSapSchema17_1, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_17_1( + from_dict(RdSapSchema17_1, data) ) - if schema == "RdSAP-Schema-17.0": + elif schema == "RdSAP-Schema-17.0": from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 - return _clear_basement_flag_when_system_built( - EpcPropertyDataMapper.from_rdsap_schema_17_0( - from_dict(RdSapSchema17_0, data) - ) + mapped = EpcPropertyDataMapper.from_rdsap_schema_17_0( + from_dict(RdSapSchema17_0, data) ) + else: + raise ValueError(f"Unsupported EPC schema: {schema!r}") - raise ValueError(f"Unsupported EPC schema: {schema!r}") + return _clear_basement_flag_when_system_built( + _with_renewable_heat_incentive( + _with_recorded_performance(mapped, data), data + ) + ) # --------------------------------------------------------------------------- @@ -2330,6 +2323,85 @@ class EpcPropertyDataMapper: # --------------------------------------------------------------------------- +def _with_recorded_performance( + epc: EpcPropertyData, data: Dict[str, Any] +) -> EpcPropertyData: + """Overlay the recorded current-performance scalars from the raw API payload. + + The current SAP rating, EPC band, Primary Energy Intensity + (``energy_consumption_current``) and CO2 are top-level fields on every RdSAP + schema response, but only a couple of the per-schema mappers copy them + through (and none map the band). Baseline's Lodged Performance reads all four + off the EPC, so map them here, once, for every schema version. An absent key + leaves the mapped value untouched. + """ + band = data.get("current_energy_efficiency_band") + co2 = data.get("co2_emissions_current") + consumption = data.get("energy_consumption_current") + rating = data.get("energy_rating_current") + return replace( + epc, + current_energy_efficiency_band=( + Epc(band) if band is not None else epc.current_energy_efficiency_band + ), + co2_emissions_current=( + float(co2) if co2 is not None else epc.co2_emissions_current + ), + energy_consumption_current=( + int(consumption) + if consumption is not None + else epc.energy_consumption_current + ), + energy_rating_current=( + int(rating) if rating is not None else epc.energy_rating_current + ), + ) + + +def _with_renewable_heat_incentive( + epc: EpcPropertyData, data: Dict[str, Any] +) -> EpcPropertyData: + """Gap-fill the RHI block (baseline space/water-heating kWh) from the raw + payload. + + The ``renewable_heat_incentive`` object is present on every schema response, + but only the 21.x mappers copy it through; Baseline reads + ``space_heating_kwh`` / ``water_heating_kwh`` off it. Only fills when a mapper + left it unset, and only when the block carries both required kWh figures — + otherwise the EPC is returned untouched. + """ + if epc.renewable_heat_incentive is not None: + return epc + rhi = data.get("renewable_heat_incentive") + if not isinstance(rhi, dict): + return epc + rhi_obj = cast(Dict[str, Any], rhi) + space = rhi_obj.get("space_heating_existing_dwelling") + water = rhi_obj.get("water_heating") + if space is None or water is None: + return epc + return replace( + epc, + renewable_heat_incentive=RenewableHeatIncentive( + space_heating_kwh=float(space), + water_heating_kwh=float(water), + impact_of_loft_insulation_kwh=_optional_float( + rhi_obj.get("impact_of_loft_insulation") + ), + impact_of_cavity_insulation_kwh=_optional_float( + rhi_obj.get("impact_of_cavity_insulation") + ), + impact_of_solid_wall_insulation_kwh=_optional_float( + rhi_obj.get("impact_of_solid_wall_insulation") + ), + ), + ) + + +def _optional_float(value: Any) -> Optional[float]: + return float(value) if value is not None else None + + def _clear_basement_flag_when_system_built( epc: EpcPropertyData, ) -> EpcPropertyData: diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 8e38c32b..faa86323 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from datetime import date +from datetime import date, datetime from typing import Optional, Protocol, TypeVar from sqlmodel import Session, col, delete, select @@ -57,6 +57,24 @@ def _require(value: Optional[_T], field: str) -> _T: return value +def _as_date(value: object) -> date: + """Normalise an ``epc_property`` date column value to a ``date``. + + The FE-owned date columns (``inspection_date`` / ``completion_date`` / + ``registration_date``) are Postgres ``timestamp``s even though the SQLModel + mirror types them ``str`` (it stores the writer's ``isoformat()`` string). + So a read hands back a ``datetime``, while a value still in flight may be + the ISO string — accept both. + """ + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, str): + return date.fromisoformat(value) + raise TypeError(f"unexpected inspection_date value: {value!r}") + + class _HasEpcPropertyId(Protocol): epc_property_id: int @@ -425,7 +443,7 @@ class EpcPostgresRepository(EpcRepository): return EpcPropertyData( dwelling_type=p.dwelling_type, - inspection_date=date.fromisoformat(p.inspection_date), + inspection_date=_as_date(p.inspection_date), tenure=p.tenure, transaction_type=p.transaction_type, address_line_1=_require(p.address_line_1, "address_line_1"), @@ -480,12 +498,10 @@ class EpcPostgresRepository(EpcRepository): pressure_test=p.pressure_test, language_code=p.language_code, completion_date=( - date.fromisoformat(p.completion_date) if p.completion_date else None + _as_date(p.completion_date) if p.completion_date else None ), registration_date=( - date.fromisoformat(p.registration_date) - if p.registration_date - else None + _as_date(p.registration_date) if p.registration_date else None ), measurement_type=p.measurement_type, conservatory_type=p.conservatory_type, From c57ee578de143b0bd57881803b580fbdcb3951f6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 17 Jun 2026 01:45:42 +0000 Subject: [PATCH 13/13] fix(modelling): mirror the FE-owned property_baseline_performance columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLModel had drifted to a `bill_` prefix on the Bill Derivation block, but the FE-owned Drizzle table uses unprefixed names (`heating_kwh`, `hot_water_kwh` … `total_annual_bill_gbp`) plus a nullable `fuel_rates_period`. INSERTs failed with UndefinedColumn. Rename the columns to mirror the live table column-for- column (the prefix's anti-clash purpose is moot: `heating_kwh` != the recorded `space_heating_kwh`), and add the `fuel_rates_period` column — left None until Bill Derivation threads the snapshot period through. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property_baseline_performance_table.py | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 03906c0c..89019478 100644 --- a/infrastructure/postgres/property_baseline_performance_table.py +++ b/infrastructure/postgres/property_baseline_performance_table.py @@ -72,26 +72,31 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): space_heating_kwh: float water_heating_kwh: float + # The Fuel Rates snapshot period the bill was priced against (FE-owned column, + # nullable). Not yet threaded through Bill Derivation, so left None for now. + fuel_rates_period: Optional[str] = Field(default=None) + # Bill Derivation block (ADR-0014 §6). Nullable: all None when no calculator - # ran (stub path). The ``bill_`` prefix avoids clashing with the + # ran (stub path). Column names are unprefixed to mirror the FE-owned table — + # the per-section ``heating_kwh`` / ``hot_water_kwh`` do not clash with the # recorded-demand ``space_heating_kwh`` / ``water_heating_kwh`` above. - bill_heating_kwh: Optional[float] = Field(default=None) - bill_heating_cost_gbp: Optional[float] = Field(default=None) - bill_hot_water_kwh: Optional[float] = Field(default=None) - bill_hot_water_cost_gbp: Optional[float] = Field(default=None) - bill_lighting_kwh: Optional[float] = Field(default=None) - bill_lighting_cost_gbp: Optional[float] = Field(default=None) - bill_appliances_kwh: Optional[float] = Field(default=None) - bill_appliances_cost_gbp: Optional[float] = Field(default=None) - bill_cooking_kwh: Optional[float] = Field(default=None) - bill_cooking_cost_gbp: Optional[float] = Field(default=None) - bill_pumps_fans_kwh: Optional[float] = Field(default=None) - bill_pumps_fans_cost_gbp: Optional[float] = Field(default=None) - bill_cooling_kwh: Optional[float] = Field(default=None) - bill_cooling_cost_gbp: Optional[float] = Field(default=None) - bill_standing_charges_gbp: Optional[float] = Field(default=None) - bill_seg_credit_gbp: Optional[float] = Field(default=None) - bill_total_annual_bill_gbp: Optional[float] = Field(default=None) + heating_kwh: Optional[float] = Field(default=None) + heating_cost_gbp: Optional[float] = Field(default=None) + hot_water_kwh: Optional[float] = Field(default=None) + hot_water_cost_gbp: Optional[float] = Field(default=None) + lighting_kwh: Optional[float] = Field(default=None) + lighting_cost_gbp: Optional[float] = Field(default=None) + appliances_kwh: Optional[float] = Field(default=None) + appliances_cost_gbp: Optional[float] = Field(default=None) + cooking_kwh: Optional[float] = Field(default=None) + cooking_cost_gbp: Optional[float] = Field(default=None) + pumps_fans_kwh: Optional[float] = Field(default=None) + pumps_fans_cost_gbp: Optional[float] = Field(default=None) + cooling_kwh: Optional[float] = Field(default=None) + cooling_cost_gbp: Optional[float] = Field(default=None) + standing_charges_gbp: Optional[float] = Field(default=None) + seg_credit_gbp: Optional[float] = Field(default=None) + total_annual_bill_gbp: Optional[float] = Field(default=None) @classmethod def from_domain( @@ -122,15 +127,15 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): return for section, stem in _SECTION_COLUMN_STEM.items(): cost = bill.sections.get(section) - setattr(self, f"bill_{stem}_kwh", cost.kwh if cost is not None else None) + setattr(self, f"{stem}_kwh", cost.kwh if cost is not None else None) setattr( self, - f"bill_{stem}_cost_gbp", + f"{stem}_cost_gbp", cost.cost_gbp if cost is not None else None, ) - self.bill_standing_charges_gbp = bill.standing_charges_gbp - self.bill_seg_credit_gbp = bill.seg_credit_gbp - self.bill_total_annual_bill_gbp = bill.total_gbp + self.standing_charges_gbp = bill.standing_charges_gbp + self.seg_credit_gbp = bill.seg_credit_gbp + self.total_annual_bill_gbp = bill.total_gbp def to_domain(self) -> PropertyBaselinePerformance: return PropertyBaselinePerformance( @@ -157,18 +162,18 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): not-None discriminator: a persisted bill always sets it, so its absence means no calculator ran and the bill was None. A section is rebuilt only when its kWh column is not None (paired with its cost).""" - if self.bill_total_annual_bill_gbp is None: + if self.total_annual_bill_gbp is None: return None sections: dict[BillSection, BillSectionCost] = {} for section, stem in _SECTION_COLUMN_STEM.items(): - kwh = cast(Optional[float], getattr(self, f"bill_{stem}_kwh")) + kwh = cast(Optional[float], getattr(self, f"{stem}_kwh")) if kwh is None: continue - cost_gbp = cast(float, getattr(self, f"bill_{stem}_cost_gbp")) + cost_gbp = cast(float, getattr(self, f"{stem}_cost_gbp")) sections[section] = BillSectionCost(kwh=kwh, cost_gbp=cost_gbp) return Bill( sections=sections, - standing_charges_gbp=cast(float, self.bill_standing_charges_gbp), - seg_credit_gbp=cast(float, self.bill_seg_credit_gbp), - total_gbp=self.bill_total_annual_bill_gbp, + standing_charges_gbp=cast(float, self.standing_charges_gbp), + seg_credit_gbp=cast(float, self.seg_credit_gbp), + total_gbp=self.total_annual_bill_gbp, )