diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index a8c9e596..196291b4 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"), @@ -1302,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( @@ -1350,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, @@ -1820,6 +1875,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/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf new file mode 100644 index 00000000..4312e6c1 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf differ diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index baf1db00..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 @@ -261,6 +265,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 +805,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 159c0f92..448abaff 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -25,6 +25,7 @@ from datatypes.epc.domain.epc_property_data import ( SapEnergySource, SapFlatDetails, SapFloorDimension, + SapConservatory, SapHeating, SapRoofWindow, SapRoomInRoof, @@ -69,6 +70,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, @@ -415,12 +417,25 @@ 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, ), 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, @@ -2077,8 +2092,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 @@ -2483,6 +2504,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 @@ -2507,6 +2555,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) @@ -4003,12 +4058,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, *, @@ -5222,6 +5353,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, @@ -6893,5 +7042,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/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 054e9864..77be7628 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2228,3 +2228,132 @@ 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 + + +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_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..45f1ee46 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 @@ -358,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/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 4614d33c..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). @@ -478,6 +481,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 +578,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 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/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..d12b7da9 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 @@ -396,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 @@ -613,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) @@ -884,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 @@ -1368,6 +1412,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/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/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/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..49318a42 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py @@ -0,0 +1,144 @@ +"""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 + +# 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 + 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_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/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 4e7336e3..6547a585 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,126 @@ 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_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): + 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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index aa05c9af..9aed0f34 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -95,10 +95,34 @@ _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). -_MIN_WITHIN_HALF_SAP = 0.67 -_MAX_SAP_MAE = 0.99 -_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 +# 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). +# 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). +# 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. +# 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.0 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: