diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index b3fde06b..2523acb0 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -281,11 +281,7 @@ class ElmhurstSiteNotesExtractor: # with the §8 Roofs / §9 Floors blocks. None when the PDF # omits the line (no retrofit lodged). ins_thickness_raw = self._local_val(lines, "Insulation Thickness") - insulation_thickness_mm = ( - int(ins_thickness_raw.split()[0]) - if ins_thickness_raw and ins_thickness_raw.split()[0].isdigit() - else None - ) + insulation_thickness_mm = self._parse_thickness_mm(ins_thickness_raw) return WallDetails( wall_type=self._local_str(lines, "Type"), insulation=self._local_str(lines, "Insulation"), @@ -323,11 +319,7 @@ class ElmhurstSiteNotesExtractor: if area <= 0: continue thickness_raw = self._local_val(lines, f"Alternative Wall {n} Thickness") - thickness_mm = ( - int(thickness_raw.split()[0]) - if thickness_raw and thickness_raw.split()[0].isdigit() - else None - ) + thickness_mm = self._parse_thickness_mm(thickness_raw) result.append(AlternativeWall( area_m2=area, wall_type=self._local_str(lines, f"Alternative Wall {n} Type"), @@ -356,11 +348,25 @@ class ElmhurstSiteNotesExtractor: lines = [l.strip() for l in main_body.splitlines() if l.strip()] return self._wall_details_from_lines(lines) + @staticmethod + def _parse_thickness_mm(raw: Optional[str]) -> Optional[int]: + """Parse an Elmhurst "Insulation Thickness" cell ("100 mm", + "400+ mm") to integer mm. The bucket-cap "400+ mm" (Table 17/18 + max tabulated row) carries a trailing "+" that a bare + `.split()[0].isdigit()` test rejects — strip to the leading + digits so the cap parses through to the cascade with its numeric + value (simulated case 5: roof "400+ mm" was silently dropped → + u_roof fell back to the age-J default 0.16 instead of the + 300mm+ value 0.11). Returns None when the cell is absent or + carries no leading number ("As Built", "N None").""" + if not raw: + return None + match = re.match(r"\d+", raw.strip()) + return int(match.group()) if match else None + def _roof_details_from_lines(self, lines: List[str]) -> RoofDetails: thickness_raw = self._local_val(lines, "Insulation Thickness") - thickness_mm = ( - int(thickness_raw.split()[0]) if thickness_raw and thickness_raw.split()[0].isdigit() else None - ) + thickness_mm = self._parse_thickness_mm(thickness_raw) insulation = self._local_str(lines, "Insulation") # The Summary PDF omits the "Insulation Thickness" line entirely # when no retrofit insulation is lodged (e.g. "Insulation: N None" @@ -391,11 +397,7 @@ class ElmhurstSiteNotesExtractor: # via the per-thickness column. Mirror of the §8 roof extractor # at `_roof_details_from_lines`. thickness_raw = self._local_val(lines, "Insulation Thickness") - thickness_mm = ( - int(thickness_raw.split()[0]) - if thickness_raw and thickness_raw.split()[0].isdigit() - else None - ) + thickness_mm = self._parse_thickness_mm(thickness_raw) return FloorDetails( location=self._local_str(lines, "Location"), floor_type=self._local_str(lines, "Type"), @@ -799,6 +801,12 @@ class ElmhurstSiteNotesExtractor: "North", "South", "East", "West", "NE", "NW", "SE", "SW", }) _BP_INLINE_TOKENS = frozenset({"Main"}) # "Extension" only appears as suffix + # A room-in-roof window (rooflight) lodges its §11 "Location" cell as + # "Roof of Room in Roof", which the layout preprocessor wraps onto two + # tokens ("Roof of Room" in the prefix block, "in Roof" in the suffix). + # Detected so the window routes to a roof window (worksheet (27a)) + # and the tokens don't leak into the glazing-type phrase. + _ROOF_OF_ROOM_LOCATION_TOKENS = frozenset({"Roof of Room", "in Roof"}) # The Elmhurst Summary PDF lodges each window's glazing-type as a # capitalised phrase like "Double between 2002" / "Double with unknown" # / "Single" / "Triple" / "Secondary". The first token of that phrase @@ -1018,6 +1026,18 @@ class ElmhurstSiteNotesExtractor: before = [lines[j].strip() for j in range(before_start, data_idx) if lines[j].strip()] after = [lines[j].strip() for j in range(manuf_idx + 4, after_end) if lines[j].strip()] + # Room-in-roof windows lodge their location as "Roof of Room in + # Roof" (wrapped across the prefix/suffix blocks). Detect it, pull + # those tokens out so they don't contaminate the glazing-type + # phrase, and override the wall-keyed `location` with the roof-of- + # room marker the roof-window classifier keys on. + if any( + t in self._ROOF_OF_ROOM_LOCATION_TOKENS for t in (*before, *after) + ): + location = "Roof of Room" + before = [t for t in before if t not in self._ROOF_OF_ROOM_LOCATION_TOKENS] + after = [t for t in after if t not in self._ROOF_OF_ROOM_LOCATION_TOKENS] + glazing_type, building_part, orientation = self._compose_window_descriptors( before=before, after=after, @@ -1326,6 +1346,8 @@ class ElmhurstSiteNotesExtractor: fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"), percentage_of_heat=pct, main_heating_sap_code=main_heating_sap_code, + heat_emitter=self._local_str(lines, "Heat Emitter"), + heating_controls_sap=self._local_str(lines, "Main Heating Controls Sap"), ) def _extract_community_heating(self) -> Optional[CommunityHeating]: diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf new file mode 100644 index 00000000..6c31c0d8 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf new file mode 100644 index 00000000..335e6242 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf new file mode 100644 index 00000000..258f56ac Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf new file mode 100644 index 00000000..8c42d24a Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf new file mode 100644 index 00000000..d806f5b0 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf new file mode 100644 index 00000000..34934393 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf differ diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 94a3b927..216af22c 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4330,3 +4330,53 @@ def test_from_elmhurst_site_notes_matches_hand_built_000516() -> None: f"hand-built EpcPropertyData for cohort cert 000516:\n " + "\n ".join(diffs) ) + + +def test_elmhurst_jacket_cylinder_insulation_maps_to_loose_jacket_code_2() -> None: + # Arrange — an Elmhurst §15.1 "Cylinder Insulation Type: Jacket" + # lodging is a loose jacket, which SAP 10.2 Table 2 Note 1 gives a + # separate (higher) storage-loss factor than factory foam. The SAP10 + # `cylinder_insulation_type` enum uses 2 for loose jacket (1 = factory + # foam), matching the GOV.UK API path — so the Summary "Jacket" label + # must resolve to 2 for cross-mapper parity, and so the + # loose-jacket storage-loss branch (S0380.224) fires. Observed on the + # simulated-case-19 worksheet (210 L jacket cylinder + storage heaters). + from datatypes.epc.domain.mapper import _elmhurst_cylinder_insulation_code # pyright: ignore[reportPrivateUsage] + + # Act + code = _elmhurst_cylinder_insulation_code("Jacket", cylinder_present=True) + + # Assert + assert code == 2 + + +def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> None: + # Arrange — regression guard: the factory-foam label is unchanged. + from datatypes.epc.domain.mapper import _elmhurst_cylinder_insulation_code # pyright: ignore[reportPrivateUsage] + + # Act + code = _elmhurst_cylinder_insulation_code("Foam", cylinder_present=True) + + # Assert + assert code == 1 + + +def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None: + # Arrange — "SY System build" and "B Basement wall" both map to SAP10 + # wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit + # basement flag separates them: only "B" is a basement wall (drives + # RdSAP §5.17 u_basement_wall); "SY" is False so it routes through the + # normal system-built U-value table; any other code → None (the + # gov-EPC API code-6 heuristic still applies). + from datatypes.epc.domain.mapper import _elmhurst_wall_construction_int # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import _elmhurst_wall_is_basement # pyright: ignore[reportPrivateUsage] + + # Act / Assert — system-built keeps code 6 but is NOT basement. + assert _elmhurst_wall_construction_int("SY System build") == 6 + assert _elmhurst_wall_is_basement("SY System build") is False + # Genuine basement: code 6 AND flagged basement. + assert _elmhurst_wall_construction_int("B Basement wall") == 6 + assert _elmhurst_wall_is_basement("B Basement wall") is True + # Other constructions defer to the API code-6 heuristic. + assert _elmhurst_wall_is_basement("CA Cavity") is None + assert _elmhurst_wall_is_basement("") is None diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 6cae0b52..150108fc 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -435,13 +435,24 @@ class SapAlternativeWall: # Mirrors `SapBuildingPart.wall_thickness_mm` per the # [[feedback-no-misleading-insulation-type]] convention. wall_thickness_mm: Optional[int] = None + # Explicit basement determination. RdSAP10 `wall_construction == 6` is + # canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement + # heuristic hijacked it because Elmhurst lodges both "SY System build" + # and "B Basement wall" as code 6. When the source can tell them apart + # (the Elmhurst mapper, from the distinct "SY"/"B" codes) it sets this + # flag; None falls back to the gov-EPC API code-6 heuristic so the API + # path (basement lodged as integer 6, no flag) is unchanged. + is_basement: Optional[bool] = None @property def is_basement_wall(self) -> bool: - """True iff this alt sub-area is the dwelling's basement wall — - identified by RdSAP10 wall_construction code = 6 (see module - constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 - applies a special U-value lookup to basement walls.""" + """True iff this alt sub-area is the dwelling's basement wall. + Honours the explicit `is_basement` flag when set; otherwise falls + back to the gov-EPC API basement sentinel `wall_construction == 6` + (`BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 applies + a special U-value lookup to basement walls.""" + if self.is_basement is not None: + return self.is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @@ -472,7 +483,15 @@ class SapBuildingPart: ) wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes wall_thickness_mm: Optional[int] = None - wall_insulation_thickness: Optional[str] = None + # Union[str, int]: a numeric mm value when the API lodges + # `wall_insulation_thickness == "measured"` (resolved from the + # separate measured field), else the lodged string ("NI", a numeric + # string, etc.). Mirrors `roof_insulation_thickness`. + wall_insulation_thickness: Optional[Union[str, int]] = None + # RdSAP 10 §5.8 thermal-conductivity code for measured wall insulation + # (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence + # R-value path when a measured wall thickness is lodged alongside it. + wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None sap_alternative_wall_1: Optional[SapAlternativeWall] = None sap_alternative_wall_2: Optional[SapAlternativeWall] = None @@ -506,12 +525,23 @@ class SapBuildingPart: # The dwelling-wide `construction_age_band` does NOT govern curtain # walls; this field decouples them per spec. curtain_wall_age: Optional[str] = None + # Explicit basement determination for the primary wall. See + # `SapAlternativeWall.is_basement` — RdSAP10 code 6 is canonically + # SYSTEM-BUILT, so the Elmhurst mapper sets this flag from the distinct + # "SY"/"B" codes (False for system-built, True for basement); None + # preserves the gov-EPC API code-6 heuristic. + wall_is_basement: Optional[bool] = None @property def main_wall_is_basement(self) -> bool: """True iff this part's primary wall (not an alt sub-area) is the basement wall — happens when the whole part sits below grade. - Empirically 54 of 67k parts in the 2026 sweep; rare but real.""" + Empirically 54 of 67k parts in the 2026 sweep; rare but real. + Honours the explicit `wall_is_basement` flag when set (so a + SYSTEM-BUILT wall, which shares code 6, is not mis-flagged); + otherwise falls back to the gov-EPC API code-6 heuristic.""" + if self.wall_is_basement is not None: + return self.wall_is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @property diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0ddb4baa..ee551080 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -560,7 +560,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, floor_insulation_thickness=None, roof_construction=bp.roof_construction, @@ -693,7 +699,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, floor_insulation_thickness=None, roof_construction=bp.roof_construction, @@ -826,7 +838,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -985,7 +1003,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1161,7 +1185,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1378,7 +1408,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1587,9 +1623,17 @@ class EpcPropertyDataMapper: schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER, ), ), - # SAP windows + # SAP windows — split vertical wall windows (27) from roof + # windows (27a) on the RdSAP `window_wall_type=4` signal. sap_windows=[ - _api_sap_window(w) for w in schema.sap_windows + _api_sap_window(w) + for w in schema.sap_windows + if not _api_is_roof_window(w) + ], + sap_roof_windows=[ + _api_sap_roof_window(w) + for w in schema.sap_windows + if _api_is_roof_window(w) ], # SAP energy source sap_energy_source=SapEnergySource( @@ -1632,7 +1676,13 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1861,6 +1911,7 @@ class EpcPropertyDataMapper: """ data = _normalize_shower_outlets(data) + data = _default_missing_post_town(data) schema = data.get("schema_type", "") if schema == "RdSAP-Schema-21.0.1": from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 @@ -2031,6 +2082,26 @@ def _normalize_shower_outlets(data: Dict[str, Any]) -> Dict[str, Any]: return {**data, "sap_heating": new_sap_heating} +def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]: + """Default an absent top-level `post_town` to "" before `from_dict`. + + `RdSapSchema21_0_x.post_town` is a required (no-default) field, so a + real-API cert that omits it (observed on a 2026-register cert whose + town sits only in `address_line_3`) makes `from_dict` raise + "missing required field 'post_town'", blocking the whole cert. + `post_town` is address metadata that the SAP cascade never reads, so + defaulting it to "" is inert for the calculation while keeping the + cert mappable. The schema dataclass can't simply give the field a + default — it is a plain (non-kw_only) dataclass with 57 required + fields after `post_town`, so a mid-list default would break field + ordering; pre-processing here mirrors `_normalize_shower_outlets`. + + Mutates a shallow copy so the caller's dict is untouched.""" + if "post_town" in data: + return data + return {**data, "post_town": ""} + + def _count_shower_outlets_by_type( schema_shower_outlets: Any, target_type: int, ) -> Optional[int]: @@ -2090,6 +2161,10 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { "SG": 1, # Stone: granite or whinstone (cert 000565 Ext1) — the # granite-specific Elmhurst variant of "ST"; same SAP10 # WALL_STONE_GRANITE=1 cascade entry. + "SS": 2, # Stone: sandstone or limestone (simulated case 5 / cert + # 0240 archetype) — SAP10 WALL_STONE_SANDSTONE=2. The + # sandstone-specific Elmhurst variant; the API path lodges + # the same wall as integer wall_construction=2. "SB": 3, # Solid brick (cohort cert lodgement) "SO": 3, # Solid brick (newer Elmhurst PDF variant — same SAP10 # mapping; cert 9501 lodges "SO Solid Brick" where the @@ -2097,16 +2172,16 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { "CA": 4, # Cavity "TF": 5, # Timber frame "TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping) - "SY": 6, # System build - "B": 6, # Basement wall (cert 000565 Ext3+Ext4) — routes to the - # `BASEMENT_WALL_CONSTRUCTION_CODE=6` canonical signal so - # the cascade's `part.main_wall_is_basement` triggers the - # RdSAP 10 §5.17 / Table 23 `u_basement_wall` override - # (heat_transmission.py:640). Collides numerically with - # "SY" System build — the cascade's basement check - # precedes `u_wall(construction=6)` so SY would be - # silently mis-routed to u_basement_wall today; no cohort - # fixture exercises SY yet so the conflict is dormant. + "SY": 6, # System build — canonical RdSAP10 WALL_SYSTEM_BUILT=6. + "B": 6, # Basement wall (cert 000565 Ext3+Ext4). Numerically + # collides with "SY" System build on code 6, so the + # basement vs system-built distinction is carried by the + # explicit `is_basement` / `wall_is_basement` flag (set + # via `_elmhurst_wall_is_basement`) rather than the code: + # only "B" triggers the cascade's `main_wall_is_basement` + # → RdSAP 10 §5.17 / Table 23 `u_basement_wall` override. + # "SY" sets the flag False so it routes through the normal + # `u_wall(construction=6)` system-built table instead. "CO": 7, # Cob "PH": 8, # Park home "CW": 9, # Curtain wall @@ -2188,6 +2263,32 @@ def _elmhurst_wall_construction_int(coded: str) -> Optional[int]: return _ELMHURST_WALL_CODE_TO_SAP10[code] +# Elmhurst wall codes that both resolve to SAP10 wall_construction=6 but +# carry opposite basement meaning: "B" Basement wall vs "SY" System build +# (see `_ELMHURST_WALL_CODE_TO_SAP10`). RdSAP10 code 6 is canonically +# WALL_SYSTEM_BUILT; the explicit basement flag lets the cascade route a +# genuine basement to RdSAP §5.17 `u_basement_wall` without mis-flagging +# a system-built wall. +_ELMHURST_BASEMENT_WALL_CODE: Final[str] = "B" +_ELMHURST_SYSTEM_BUILT_WALL_CODE: Final[str] = "SY" + + +def _elmhurst_wall_is_basement(coded: str) -> Optional[bool]: + """Disambiguate the SAP10 code-6 collision from the Elmhurst wall_type + string. Returns True for "B Basement wall", False for "SY System + build", and None for every other code (so the SapBuildingPart / + SapAlternativeWall properties fall back to the gov-EPC API code-6 + heuristic — unchanged for the API path). Empty lodging → None.""" + code = _leading_code(coded) + if not code: + return None + if code == _ELMHURST_BASEMENT_WALL_CODE: + return True + if code == _ELMHURST_SYSTEM_BUILT_WALL_CODE: + return False + return None + + # Elmhurst Party Wall Type codes — distinct category-set from the Wall # Type field; the codes describe construction class for `u_party_wall` # (Table 4 / RdSAP §S.3.2) rather than a specific SAP10 wall-type. Maps @@ -2319,9 +2420,33 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i # distinguishes "Suspended" vs everything-else (the solid-branch is # the default fall-through), so the additional code maps to the same # "Solid" string as code 1. -_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = { +# Code 3 = "suspended, not timber" (e.g. beam-and-block / suspended +# concrete). RdSAP 10 field 3-1 "Floor construction" enumerates the +# lowest-floor construction as solid / suspended timber / suspended, +# not timber. The timber/not-timber split is load-bearing: the spec's +# "Suspended not timber (structural infiltration 0)" means only +# "Suspended timber" triggers the §5 (12) 0.1/0.2 floor-infiltration +# adjustment (see `_has_suspended_timber_floor_per_spec`), while a +# not-timber suspended floor is infiltration 0. Mapping to the canonical +# "Suspended, not timber" string (also used by the site-notes mapper) +# takes the suspended U-value branch via the "Suspended" prefix yet +# correctly fails the exact-match timber gate. Observed on 53/1000 of a +# random 2026 API sample (was raising UnmappedApiCode, blocking the cert). +# +# Code 0 = "not recorded / not applicable" → None. It pairs +# overwhelmingly with floor_heat_loss=6 ("another dwelling below" — an +# upper-floor flat with no ground floor to describe) but also appears +# with mixed Solid / unheated-space descriptions, so there is no single +# construction to assert. None defers to RdSAP 10 Table 19 ("where floor +# construction is unknown" → age-band default), exactly as an unlodged +# floor_construction does. Empirically inert: floor W/K is identical to +# any explicit construction across all 37 code-0 certs in the 2026 +# sample (the heat loss is governed by floor_heat_loss, not this field). +_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { + 0: None, 1: "Solid", 2: "Suspended timber", + 3: "Suspended, not timber", 4: "Solid", } @@ -2334,11 +2459,28 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = { # are observed on cert 001479; the wider RdSAP10 roof-construction # enum (1=Flat, 3=Pitched no-access, 5=Vaulted, etc.) is mapped as # best-effort against SAP10 nomenclature. -_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, str] = { +# +# Codes 6 and 7 → None. This field is read ONLY for the sloping-ceiling +# inclined factor; the base roof U-value comes from the global +# roofs[].description, so a non-sloping code carries no information the +# cascade consumes here, and None correctly avoids the cos(30°) false- +# trigger: +# 6 = "Thatched, with additional insulation" — its U is set by the +# global description; not a sloping ceiling. +# 7 = "(same dwelling above)" / "(another dwelling above)" — an +# internal ceiling with no roof heat loss (the roof-side analogue +# of floor_construction code 0). Heat loss is governed by the +# roof_heat_loss / description path, not this field. +# Empirically inert: roof W/K is identical whether 6/7 map to None or to +# an explicit pitched string across all code-6/7 certs in the 2026 +# sample (were raising UnmappedApiCode, blocking the cert). +_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { 1: "Flat", 3: "Pitched (slates/tiles), no access to loft", 4: "Pitched (slates/tiles), access to loft", 5: "Pitched (vaulted ceiling)", + 6: None, + 7: None, 8: "Pitched, sloping ceiling", } @@ -2627,6 +2769,50 @@ def _api_secondary_fuel_type( return lodged_fuel_type +# RdSAP API `window_wall_type` code 4 = roof window ("Roof of Room" +# rooflight / inclined glazing). Codes 1=main wall, 2=alt wall 1, 3=alt +# wall 2 (see `_window_on_alt_wall`). A roof window is billed on worksheet +# (27a) at the Table 6e Note 2 inclination-adjusted U and draws 45°- +# inclined solar gains, NOT on (27) as vertical glazing. Cert 0240's 6 +# "Roof of Room" windows lodge this code; the simulated-case-6 worksheet +# confirms the (27a) treatment at U_eff 2.1062. +_API_WINDOW_WALL_TYPE_ROOF: Final[int] = 4 + + +def _api_is_roof_window(w: Any) -> bool: + """True when an API sap_windows entry is a roof window (rooflight), + keyed on `window_wall_type == 4`. `window_type` is NOT the signal — + certs 0390 / 7536 lodge `window_type=2` on ordinary main-wall + (wall_type=1) windows.""" + return w.window_wall_type == _API_WINDOW_WALL_TYPE_ROOF + + +def _api_sap_roof_window(w: Any) -> SapRoofWindow: + """Build a `SapRoofWindow` from one API roof-window entry + (`window_wall_type=4`). The lodged glazing type gives the vertical + U / g / frame-factor via the same SAP 10.2 Table 24 lookup the + vertical-window path uses; the U is then raised by the SAP 10.2 + Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to + the inclined-position value the worksheet bills on (27a). Mirror of + the site-notes `_map_elmhurst_roof_window`.""" + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + vertical_u = transmission[0] if transmission is not None else 2.0 + g_perp = transmission[1] if transmission is not None else 0.76 + frame_factor = w.frame_factor + if frame_factor is None: + frame_factor = transmission[2] if transmission is not None else 0.70 + return SapRoofWindow( + area_m2=_measurement_value(w.window_width) * _measurement_value(w.window_height), + u_value_raw=vertical_u + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K, + orientation=w.orientation, + pitch_deg=45.0, + g_perpendicular=g_perp, + frame_factor=frame_factor, + glazing_type=_api_cascade_glazing_type(w.glazing_type), + window_location=w.window_location, + ) + + def _api_sap_window(w: Any) -> SapWindow: """Build a `SapWindow` from one API schema sap_windows entry, routing the glazing-type + glazing-gap pair through the spec @@ -2710,20 +2896,84 @@ def _api_build_sap_floor_dimensions( return out +# RdSAP 10 §3.9.1 (PDF p.21): a Simplified Type 1 RR gable has no measured +# height — the worksheet applies the default RR storey height of 2.45 m, so +# the gable area is L × 2.45 (cert 6035 Summary lodges H=2.45 explicitly, +# matching this default; gable area 4.65 × 2.45 = 11.39 m²). +_RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45 + +# GOV.UK API `room_in_roof_type_1.gable_wall_type_*` integer → the +# `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by +# U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔ +# "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25): +# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25) +# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall") +_API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = { + 0: "gable_wall", + 1: "gable_wall_external", +} + + +def _api_type_1_gable_kind(gable_type: Optional[int]) -> str: + """Map a `gable_wall_type_*` code to the cascade's RR surface kind. + + `None` (type unlodged) defaults to `gable_wall` (party) — the modal + RR gable and the conservative choice (party billing at U=0.25 vs the + main-wall U). A lodged integer outside the known set raises + `UnmappedApiCode` so a new gable variant forces an explicit mapping + rather than silently mis-routing its U-value (mirror of the strict- + raise pattern on the other API helpers).""" + if gable_type is None: + return "gable_wall" + if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND: + raise UnmappedApiCode("gable_wall_type", gable_type) + return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type] + + +def _api_type_1_gable_surfaces( + type_1: Any, +) -> Optional[List[SapRoomInRoofSurface]]: + """Translate the Simplified Type 1 scalar gable fields into the + per-surface list the cascade's Detailed-RR branch consumes. + + Gable area = L × the §3.9.1 default RR storey height (2.45 m); the + `gable_wall_type_*` code routes the kind (Exposed vs Party). U-values + are left to the cascade (Exposed falls through to the main-wall U; + Party uses the fixed 0.25). Returns None when no gable length is + lodged so the cascade keeps its existing residual-only behaviour.""" + surfaces: List[SapRoomInRoofSurface] = [] + for gable_type, length in ( + (type_1.gable_wall_type_1, type_1.gable_wall_length_1), + (type_1.gable_wall_type_2, type_1.gable_wall_length_2), + ): + if length is None or length <= 0: + continue + surfaces.append( + SapRoomInRoofSurface( + kind=_api_type_1_gable_kind(gable_type), + area_m2=_round_half_up_2dp( + float(length), _RIR_TYPE_1_GABLE_HEIGHT_M + ), + ) + ) + return surfaces or None + + def _api_build_room_in_roof( bp_rir: Any, *, is_flat: bool = False, ) -> Optional[SapRoomInRoof]: """Build `SapRoomInRoof` from the API schema's per-bp RR block. Two real-API shapes coexist: - `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1 - Simplified Type 1 — gable lengths only, cascade applies the - 2.45 m default storey height. + Simplified Type 1 — gable lengths only (no heights). Built into + `detailed_surfaces` using the 2.45 m default RR storey height so + the cascade's residual deducts the gables from the A_RR shell. - `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling detail. - When the Detailed block is present, build `detailed_surfaces` so the - cascade's per-surface RR branch (heat_transmission.py:629) picks - up exact gable + flat-ceiling areas instead of falling through to - the Table 18 col(4) "all elements" default U. + For BOTH shapes we build `detailed_surfaces` so the cascade's + per-surface RR branch picks up exact gable + flat-ceiling areas and + the §3.9.1(e) residual roof (A_RR shell − Σ gables), instead of + billing the whole shell at the Table 18 col(4) "all elements" U. """ if bp_rir is None: return None @@ -2733,10 +2983,20 @@ def _api_build_room_in_roof( ) type_1 = getattr(bp_rir, "room_in_roof_type_1", None) if type_1 is not None: - # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights — - # the cascade applies the 2.45 m default storey height). + # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights). rir.gable_1_length_m = type_1.gable_wall_length_1 rir.gable_2_length_m = type_1.gable_wall_length_2 + # Route the gables through `detailed_surfaces` so the cascade's + # Detailed-RR residual deducts each gable from the A_RR shell + # (RdSAP 10 §3.9.1(e) p.21: A_RR_final = 12.5√(A_RR_floor/1.5) − + # Σ gables) — the same path the site-notes mapper builds. The + # scalar `gable_*_length_m` fields alone can't trigger this: the + # cascade's `_part_geometry` gable formula needs a height and + # silently drops height-less gables, billing the WHOLE shell as + # roof (a ~52 W/K over-count on cert 6035). Gable area = L × the + # §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) 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) @@ -2798,6 +3058,31 @@ def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]: return int(m.group(1)) if m else None +def _api_resolve_wall_insulation_thickness( + wall_insulation_thickness: Union[str, int, None], + wall_insulation_thickness_measured: Union[str, int, None], +) -> Union[str, int, None]: + """Resolve the wall insulation thickness for the cascade. + + When the cert lodges `wall_insulation_thickness == "measured"` the + actual value sits in the separate `wall_insulation_thickness_measured` + field (mm). RdSAP 10 §5.7/Table 8 use the measured thickness to pick + the insulated-wall U-value row; without it the cascade falls back to + the 50 mm "insulation present, unknown thickness" default (e.g. cert + 2130 Ext1: solid brick band B + internal insulation lodged 100 mm → + Table 8 U=0.32, not the 50 mm default 0.55). + + Any other lodgement (numeric string, "NI", None) passes through + unchanged.""" + if ( + isinstance(wall_insulation_thickness, str) + and wall_insulation_thickness.strip().lower() == "measured" + and wall_insulation_thickness_measured is not None + ): + return wall_insulation_thickness_measured + return wall_insulation_thickness + + def _api_resolve_sloping_ceiling_thickness( roof_construction: Optional[int], roof_insulation_thickness: Union[str, int, None], @@ -3126,6 +3411,7 @@ def _map_elmhurst_building_part( identifier=identifier, construction_age_band=age_code, wall_construction=_elmhurst_wall_construction_int(walls.wall_type), + wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type), wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation), wall_thickness_measured=not walls.thickness_unknown, party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type), @@ -3204,6 +3490,7 @@ def _map_elmhurst_alternative_wall( wall_thickness_measured="Y" if not a.thickness_unknown else "N", wall_insulation_thickness=None, wall_thickness_mm=measured_thickness_mm, + is_basement=_elmhurst_wall_is_basement(a.wall_type), ) @@ -3416,6 +3703,25 @@ def _map_elmhurst_rir_surface( if prefix is None: return None kind = _RIR_KIND_FROM_NAME_PREFIX[prefix] + # RdSAP 10 §3.9.1 (PDF p.21) Simplified assessment: the roof-going + # surfaces (slope / flat ceiling / stud wall) are NOT measured — the + # Summary lodges placeholder Length/Height cells (e.g. a 40 m ceiling + # height, a 32 m slope on a 4.65 m-wide gable). The spec instead + # derives one timber-framed "remaining area" from the floor area: + # A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d) + # A_RR_final = A_RR − ΣA_RR_gable/other §3.9.1(e) + # The cascade computes A_RR_final itself (heat_transmission.py — the + # `12.5 × √(A_RR_floor / 1.5) − rr_walls_in_a_rr_area` residual), + # but ONLY when `detailed_surfaces` carries no roof-going kind + # (`has_roof_lodgement` gate). Emitting these placeholder rows flips + # that gate and bills their raw L×H as explicit roof area (a 7.5× + # heat-loss explosion). Drop them for Simplified so the cascade's + # residual formula fires — matching how the API path already handles + # the same Simplified RR (scalar gable fields, no roof-going + # detailed_surfaces; cert 6035) and the gables-only cert 000565. + # Detailed (§3.10) assessments DO measure these surfaces — keep them. + if is_simplified and kind in ("slope", "flat_ceiling", "stud_wall"): + return None u_value_override: Optional[float] = None if kind == "gable_wall" and surface.gable_type == "Sheltered": kind = "gable_wall_external" @@ -3688,6 +3994,11 @@ def _is_elmhurst_roof_window( """ if w.glazing_type.startswith("Single"): return False + # Explicit "Roof of Room" location lodging (simulated case 6): the + # surveyor placed the window on the room-in-roof, so it's a rooflight + # regardless of BP roof type or U-value. + if "roof of room" in (w.location or "").lower(): + return True bp_roof_type = _elmhurst_bp_roof_type(w, survey) if bp_roof_type is not None and bp_roof_type.startswith( _ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS @@ -3703,6 +4014,12 @@ def _is_elmhurst_roof_window( # worksheet's (27a) line. The cohort exercises only "Double pre 2002". _ELMHURST_ROOF_WINDOW_U_BY_GLAZING: Dict[str, float] = { "Double pre 2002": 3.4, + # Simulated case 6 rooflights: the Summary lodges the already-inclined + # roof-window U=2.30 for DG-2002-2021 glazing (vs 2.00 vertical for the + # same glazing on a wall) — the worksheet bills it on (27a) at U_eff + # 2.1062 (= 2.30 with the §3.2 R=0.04 curtain transform). Keyed here so + # the inclination adjustment isn't double-applied. + "Double between 2002 and 2021": 2.30, } @@ -4421,8 +4738,14 @@ _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10: Dict[str, int] = { # which SAP 10.2 Table 2 Note 2 treats as factory-applied PU foam). # Other labels (Loose Jacket, None) raise `UnmappedElmhurstLabel` # until a fixture exercises them. +# SAP10 cylinder_insulation_type enum: 1 = factory-applied foam, +# 2 = loose jacket (matching the GOV.UK API codes). SAP 10.2 Table 2 +# Note 1 gives loose jacket a separate, ~2× higher storage-loss factor; +# the cascade's loose-jacket branch is wired (S0380.224), so "Jacket" +# resolves to 2 for cross-mapper parity with the API path. _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = { "Foam": 1, + "Jacket": 2, } @@ -4652,6 +4975,18 @@ _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE: Final[re.Pattern[str]] = re.compile( _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE: Final[re.Pattern[str]] = re.compile( r"\s+Summary Information$|\s+Alternative wall.*$" ) +# Fallback only: pdftotext wraps the §11 glazing-GAP column ("6 mm" / +# "12 mm" / "16 mm or more") onto the glazing-TYPE token on hand-entered +# worksheets, e.g. "Double between 2002 and 2021 16 mm or [1st]". When the +# lightly-cleaned label isn't a known key, strip the trailing gap +# descriptor (and any building-part fragment after it) and retry. Applied +# AFTER the direct lookup so explicitly-mapped interleaved variants (e.g. +# "Double with unknown 16 mm or install date more") are unaffected. The +# gap drives the API-path U-value lookup, not the site-notes glazing-type +# enum, so dropping it here is loss-free for the cascade. +_ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE: Final[re.Pattern[str]] = re.compile( + r"\s+\d+\s*mm\b.*$" +) def _elmhurst_glazing_type_code(label: Optional[str]) -> int: @@ -4668,9 +5003,15 @@ def _elmhurst_glazing_type_code(label: Optional[str]) -> int: cleaned = _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE.sub("", label) cleaned = _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE.sub("", cleaned).strip() code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(cleaned) - if code is None: - raise UnmappedElmhurstLabel("glazing_type", label) - return code + if code is not None: + return code + # Fallback: strip a trailing wrapped glazing-gap descriptor and retry. + degapped = _ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE.sub("", cleaned).strip() + if degapped != cleaned: + code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(degapped) + if code is not None: + return code + raise UnmappedElmhurstLabel("glazing_type", label) def _elmhurst_main_heating_category( @@ -4701,6 +5042,10 @@ def _elmhurst_main_heating_category( def _map_elmhurst_main_heating_2( mh2: Optional[ElmhurstMainHeating2], + *, + fallback_fuel_type: Union[int, str, None] = None, + main_floor: Optional[ElmhurstFloorDetails] = None, + main_age_band: Optional[str] = None, ) -> Optional[MainHeatingDetail]: """Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2 block. Returns None when no Main 2 is lodged (extractor convention: @@ -4736,25 +5081,51 @@ def _map_elmhurst_main_heating_2( and mh2.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES ): main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE + # §14.1 Main Heating2 often omits the "Fuel Type" cell when the + # second main system shares Main 1's fuel (simulated case 6: one oil + # boiler feeding radiators + underfloor, so the Summary lodges the + # fuel once on §14.0). Inherit Main 1's resolved fuel so the §9a + # two-main split (213)m can apply system 2's own efficiency. + resolved_fuel: Union[int, str] = ( + main_fuel_int + if main_fuel_int is not None + else ( + fallback_fuel_type + if (not mh2.fuel_type and fallback_fuel_type is not None) + else mh2.fuel_type + ) + ) category: Optional[int] = None if pcdb_index is not None and heat_pump_record(pcdb_index) is not None: category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP elif pcdb_index is not None and mh2.fuel_type in _ELMHURST_GAS_BOILER_FUEL_TYPES: category = _ELMHURST_HEATING_CATEGORY_GAS_BOILER + # §14.1 lodges Main 2's own "Heat Emitter" + "Main Heating Controls + # Sap" when the two systems heat different parts of the dwelling + # (simulated case 6: Main 1 radiators / 2106, Main 2 underfloor / + # 2110). Map them through the same helpers as Main 1 so the SAP 10.2 + # p.186 two-systems-different-parts MIT can read system 2's + # responsiveness (underfloor → emitter 2 → R=0.75) + control type. + # Empty-string sentinels preserved for the legacy DHW-only Main 2 + # (cert 000565: §14.1 omits emitter/control → consumers key off + # Main 1). + emitter_int = _elmhurst_heat_emitter_int( + mh2.heat_emitter, main_floor=main_floor, main_age_band=main_age_band + ) + control_int = _elmhurst_sap_control_code(mh2.heating_controls_sap) return MainHeatingDetail( # Main 2 doesn't carry its own FGHRS lodgement in §14.1; the # cert-level renewables block is the single source of truth and # is already wired into Main 1. has_fghrs=False, - main_fuel_type=main_fuel_int if main_fuel_int is not None else mh2.fuel_type, - # §14.1 doesn't lodge a heat emitter (the emitter is Main 1's - # radiator/UFH); leave as empty-string sentinel for cascade - # consumers that key off Main 1's emitter. - heat_emitter_type="", + main_fuel_type=resolved_fuel, + heat_emitter_type=( + emitter_int if emitter_int is not None else mh2.heat_emitter + ), emitter_temperature="", fan_flue_present=mh2.fan_assisted_flue, boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type), - main_heating_control="", + main_heating_control=control_int if control_int is not None else "", main_heating_category=category, main_heating_number=2, main_heating_fraction=mh2.percentage_of_heat, @@ -4950,7 +5321,12 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: # services DHW via `Water Heating SapCode 914` ("from second main # system") while Main 1 handles space heat. None when the §14.1 # block is absent or lodges only placeholder zeros. - main_2_detail = _map_elmhurst_main_heating_2(mh.main_heating_2) + main_2_detail = _map_elmhurst_main_heating_2( + mh.main_heating_2, + fallback_fuel_type=main_1_detail.main_fuel_type, + main_floor=survey.floor, + main_age_band=survey.construction_age_band, + ) main_heating_details = ( [main_1_detail, main_2_detail] if main_2_detail is not None diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index e91ca73a..5a4796a5 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -697,3 +697,231 @@ class TestFromRdSapSchema21_0_1: assert rhi.impact_of_cavity_insulation_kwh == -122.0 assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0 + + +# --------------------------------------------------------------------------- +# Measured wall insulation thickness (`wall_insulation_thickness == "measured"`) +# --------------------------------------------------------------------------- + + +class TestApiResolveWallInsulationThickness: + """`wall_insulation_thickness == "measured"` resolves to the separate + `wall_insulation_thickness_measured` field (previously dropped by + `from_dict`, leaving the cascade on the 50 mm unknown-thickness + default). Cert 2130 Ext1 lodges solid brick band B + internal + insulation "measured"/100 mm → RdSAP 10 Table 8 U=0.32, not 0.55.""" + + def test_measured_string_resolves_to_measured_value(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_wall_insulation_thickness, + ) + + # Act + resolved = _api_resolve_wall_insulation_thickness("measured", 100) + + # Assert + assert resolved == 100 + + def test_non_measured_lodgement_passes_through_unchanged(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_wall_insulation_thickness, + ) + + # Act + ni: object = _api_resolve_wall_insulation_thickness("NI", 100) + none_thk: object = _api_resolve_wall_insulation_thickness(None, None) + + # Assert + assert ni == "NI" + assert none_thk is None + + def test_measured_without_value_passes_through(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_wall_insulation_thickness, + ) + + # Act + resolved: object = _api_resolve_wall_insulation_thickness("measured", None) + + # Assert + assert resolved == "measured" + + +# --------------------------------------------------------------------------- +# Glazing-type label cleaning — pdftotext gap-column wrap +# --------------------------------------------------------------------------- + + +class TestElmhurstGlazingTypeWrappedGap: + """When a hand-entered Elmhurst worksheet is dumped via pdftotext, the + glazing-GAP column ("16 mm or more") wraps onto the glazing-TYPE token, + yielding labels like "Double between 2002 and 2021 16 mm or" (plus a + trailing building-part fragment). The extractor must strip the trailing + gap descriptor and map the clean type, not raise UnmappedElmhurstLabel.""" + + def test_trailing_gap_descriptor_stripped(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code + + # Act + code = _elmhurst_glazing_type_code( + "Double between 2002 and 2021 16 mm or" + ) + + # Assert — clean "Double between 2002 and 2021" → SAP10 code 3 + assert code == 3 + + def test_trailing_gap_plus_building_part_fragment_stripped(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code + + # Act + code = _elmhurst_glazing_type_code( + "Double between 2002 and 2021 16 mm or 1st" + ) + + # Assert + assert code == 3 + + def test_clean_label_still_maps(self) -> None: + # Arrange — regression guard: an un-wrapped label is unaffected. + from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code + + # Act + code = _elmhurst_glazing_type_code("Double pre 2002") + + # Assert + assert code == 2 + + +class TestApiFloorConstructionCode: + """`_api_floor_construction_str` maps the GOV.UK API integer + floor_construction code to the description string the cascade's + `u_floor` + the §5 (12) infiltration rule read. RdSAP 10 field 3-1 + "Floor construction" enumerates the lowest-floor construction as one + of: solid / suspended timber / suspended, not timber. The spec's + "Suspended not timber (structural infiltration 0)" makes the + timber/not-timber split load-bearing: only "Suspended timber" + triggers the §5 (12) 0.1/0.2 floor-infiltration adjustment; + "suspended, not timber" is structural-infiltration 0.""" + + def test_code_3_maps_to_suspended_not_timber(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(3) + + # Assert — suspended U-value branch fires (starts "Suspended"), + # but the exact-match "Suspended timber" (12) rule does NOT — + # per RdSAP 10 "suspended not timber (structural infiltration 0)". + # Same canonical string the site-notes mapper already uses. + assert result == "Suspended, not timber" + + def test_code_2_still_maps_to_suspended_timber(self) -> None: + # Arrange — regression guard: the timber code is unchanged. + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(2) + + # Assert + assert result == "Suspended timber" + + def test_code_0_maps_to_none_unknown_construction(self) -> None: + # Arrange — code 0 is the "not recorded / not applicable" + # sentinel: it pairs overwhelmingly with floor_heat_loss=6 + # ("another dwelling below", an upper-floor flat with no ground + # floor to describe), but also appears with mixed Solid / unheated + # descriptions. There is no single construction to assert, so it + # maps to None — RdSAP 10 Table 19 ("where floor construction is + # unknown" → age-band default), the same treatment as an unlodged + # floor_construction. Empirically inert: floor W/K is identical to + # any explicit construction string across all observed code-0 + # certs (the heat loss is governed by floor_heat_loss, not this). + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(0) + + # Assert — no raise; None defers to the cascade's Table 19 default. + assert result is None + + +class TestDefaultMissingPostTown: + """`_default_missing_post_town` keeps a cert mappable when the + register omits the required `post_town` field (observed on a 2026 + cert whose town sits only in address_line_3). post_town is address + metadata the SAP cascade never reads, so defaulting it to "" before + `from_dict` is inert for the calculation.""" + + def test_absent_post_town_is_defaulted_to_empty_string(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"postcode": "EX31 2LE"} + + # Act + result = _default_missing_post_town(doc) + + # Assert + assert result["post_town"] == "" + + def test_present_post_town_is_untouched(self) -> None: + # Arrange — regression guard: a lodged town passes through. + from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"post_town": "BARNSTAPLE"} + + # Act + result = _default_missing_post_town(doc) + + # Assert + assert result["post_town"] == "BARNSTAPLE" + + +class TestApiRoofConstructionCode: + """`_api_roof_construction_str` maps the GOV.UK API integer + roof_construction code to the string the cascade reads ONLY for the + "sloping ceiling" cos(30°) inclined-surface factor (Slice 89). Codes + 6 and 7 are neither sloping ceilings nor base-U drivers (the roof + U-value comes from the global roofs[].description), so both map to + None: code 6 = "Thatched" (its U is set by the description, not this + field) and code 7 = "(same/another dwelling above)" — an internal + ceiling with no roof heat loss, the roof-side analogue of + floor_construction code 0. Empirically inert: roof W/K is identical + whether 6/7 map to None or to an explicit pitched string across all + code-6/7 certs in the 2026 sample.""" + + def test_code_7_same_dwelling_above_maps_to_none(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(7) + + # Assert — None: no sloping-ceiling signal (avoids the cos(30°) + # false-trigger); the internal ceiling has no roof heat loss. + assert result is None + + def test_code_6_thatched_maps_to_none(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(6) + + # Assert — None: thatched is not a sloping ceiling; its U-value is + # carried by the global roof description, not roof_construction_type. + assert result is None + + def test_code_8_still_maps_to_sloping_ceiling(self) -> None: + # Arrange — regression guard: the sloping-ceiling code is unchanged. + from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(8) + + # Assert + assert result == "Pitched, sloping ceiling" diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 383a4a6e..dee7002d 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -175,10 +175,11 @@ class SapFloorDimension: class RoomInRoofType1: """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. - `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; - full enum not yet mapped). `gable_wall_length_*` is the run of the - external gable in metres. Heights are NOT lodged here — the cascade - applies the §3.9.1 default storey height (2.45 m).""" + `gable_wall_type_*` is the Table 4 gable variant (0 = Party, + 1 = Exposed — established from cert 6035's Summary; other variants + not yet seen). `gable_wall_length_*` is the run of the gable in + metres. Heights are NOT lodged here — the mapper applies the §3.9.1 + default RR storey height (2.45 m).""" gable_wall_type_1: Optional[int] = None gable_wall_type_2: Optional[int] = None gable_wall_length_1: Optional[float] = None @@ -240,6 +241,16 @@ class SapBuildingPart: sap_alternative_wall_2: Optional[SapAlternativeWall] = None wall_thickness: Optional[int] = None wall_insulation_thickness: Optional[str] = None + # Lodged measured insulation thickness (mm) backing a + # `wall_insulation_thickness == "measured"` lodgement. Previously + # undeclared, so `from_dict` silently dropped it and the cascade fell + # back to the 50 mm "insulation present, unknown thickness" default. + wall_insulation_thickness_measured: Optional[Union[str, int]] = None + # Lodged thermal-conductivity code for measured wall insulation + # (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared + # → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence + # R-value path when a measured wall thickness is also lodged. + wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index e8925863..87cbf91e 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -207,10 +207,11 @@ class SapFloorDimension: class RoomInRoofType1: """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. - `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; - full enum not yet mapped). `gable_wall_length_*` is the run of the - external gable in metres. Heights are NOT lodged here — the cascade - applies the §3.9.1 default storey height (2.45 m).""" + `gable_wall_type_*` is the Table 4 gable variant (0 = Party, + 1 = Exposed — established from cert 6035's Summary; other variants + not yet seen). `gable_wall_length_*` is the run of the gable in + metres. Heights are NOT lodged here — the mapper applies the §3.9.1 + default RR storey height (2.45 m).""" gable_wall_type_1: Optional[int] = None gable_wall_type_2: Optional[int] = None gable_wall_length_1: Optional[float] = None @@ -278,6 +279,16 @@ class SapBuildingPart: sap_alternative_wall_2: Optional[SapAlternativeWall] = None wall_thickness: Optional[int] = None wall_insulation_thickness: Optional[str] = None + # Lodged measured insulation thickness (mm) backing a + # `wall_insulation_thickness == "measured"` lodgement. Previously + # undeclared, so `from_dict` silently dropped it and the cascade fell + # back to the 50 mm "insulation present, unknown thickness" default. + wall_insulation_thickness_measured: Optional[Union[str, int]] = None + # Lodged thermal-conductivity code for measured wall insulation + # (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared + # → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence + # R-value path when a measured wall thickness is also lodged. + wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index eb2b8885..f524ac79 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -244,6 +244,15 @@ class MainHeating2: fan_assisted_flue: bool = False percentage_of_heat: int = 0 main_heating_sap_code: Optional[int] = None + # §14.1 "Heat Emitter" (e.g. "Underfloor Heating") + "Main Heating + # Controls Sap" (e.g. "SAP code 2110, ..."). Lodged when the two main + # systems serve different parts of the dwelling with their own + # emitter + control (simulated case 6: Main 1 radiators / control + # 2106, Main 2 underfloor / control 2110). Needed for the SAP 10.2 + # p.186 two-systems-different-parts MIT (weighted responsiveness + + # elsewhere two-control blend). + heat_emitter: str = "" + heating_controls_sap: str = "" @dataclass diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 9caf6772..f7099f18 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -451,9 +451,15 @@ def _solve_month( # SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh` # (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly. q_heat = inputs.space_heating_monthly_kwh[month - 1] - # SAP 10.2 §9a — (211)m/(215)m precomputed upstream by + # SAP 10.2 §9a — (211)m/(213)m/(215)m precomputed upstream by # `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline. - fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1] + # `main_heating_fuel_kwh` aggregates both main systems (213)m is zero + # for single-main certs, so this is the per-system sum for dual-main + # dwellings (cert 0240 / simulated case 6) and main-1 alone otherwise. + fuel_main = ( + inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1] + + inputs.energy_requirements.main_2_fuel_monthly_kwh[month - 1] + ) fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1] # SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh` diff --git a/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md b/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md new file mode 100644 index 00000000..91507e27 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md @@ -0,0 +1,212 @@ +# Handover — closing golden cert 0240-0200-5706-2365-8010 to 1e-4 + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +accuracy bar, and pipeline. This records the state of cert **0240** and the +concrete path to driving its residual to zero. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `7344f600` (S0380.207). Confirm with `git rev-parse HEAD`. +- **Baseline:** `2372 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). + +--- + +## What the last session shipped (S0380.201–207) + +Closed **simulated case 6** (`001431_case6`) to 1e-4 on the full SapResult and +promoted it to an e2e fixture. It is the worksheet-backed proxy for 0240's +**dual-oil, different-parts** archetype (Main 1 rads/2106 + Main 2 UFH/2110, +51/49, 6 "Roof of Room" rooflights, no boiler interlock). Slices: + +| slice | spec | what | +|---|---|---| +| S0380.201 | Table 4f note c) | 2nd-main circulation pump → (231) | +| S0380.202 | Table 5a note a) | 2nd-main pump **gain** → (70) | +| S0380.203 | RdSAP §3.7 | "Roof of Room" rooflights deduct from the §3.10.1 RR residual → (30) | +| S0380.204 | extractor/mapper | capture Main 2's §14.1 emitter + control | +| S0380.205 | SAP 10.2 **p.186** | two-systems-different-parts MIT: weighted R + elsewhere two-control blend → (87)/(90)/(98c) | +| S0380.206 | Appendix D Eq D1 | Q_space = DHW boiler's own (204) share, not (202) → (219) | +| S0380.207 | test | promote case 6 to a full e2e fixture | + +**0240 was re-pinned at each step** (it shares the archetype) and its residual +improved on PE/CO2 but its SAP integer dropped 73→72. The boiler-interlock −5pp +the *previous* handover called the priority was already implemented — see +[[project_case6_interlock_already_done]]. + +--- + +## The 0240 problem — and why case 6 did NOT close it + +### ⚠️ Critical: 0240 is API-only and its register target is INTEGER-rounded + +0240 has **no worksheet**. The golden test pins the cascade against the lodged +EPC register: +- `energy_consumption_current = 122` — **an integer** (1 kWh/m² resolution). +- `co2_emissions_current = 6.0` — **1 d.p.** (tonnes). +- `current_energy_efficiency = None` — the SAP isn't even in the JSON + (`actual_sap=73` in the test was carried from the original lodgement). + +**You cannot drive 0240 to "0 residual" at 1e-4 against these.** The register +rounds PE to the nearest whole kWh/m², so any cascade value in `[121.5, 122.5)` +*is* 122, and the true (unrounded) Elmhurst value could sit anywhere in that band +— or itself carry residual vs the rounded lodgement. Matching a rounded integer +to 1e-4 is not a well-posed target. **The only 1e-4 ground truth is a worksheet** +(the per-line `(1)..(286)` Elmhurst output), which is exactly why case 5/6 were +generated. + +Current 0240 cascade vs lodged: **PE 123.8687 vs 122 (resid +1.8687)**, **CO2 +6.0907 vs 6.0 (+0.0907)**, SAP cont 72.39 (integer 72 vs lodged 73, resid −1). + +### Why case 6 didn't close 0240 + +Case 6 validated the dual-main **structure** (MIT p.186, pumps, rooflights, +Eq-D1 fraction). But 0240 differs in cert-specific features case 6 does **not** +exercise: + +| feature | case 6 (worksheet-validated) | **0240** (unvalidated) | +|---|---|---| +| SAP code | 127 regular oil boiler | **130 condensing oil combi** (Table 4b 82/73) | +| DHW path | regular boiler **+ 110 L cylinder** → primary/storage loss | **combi, NO cylinder** → Table 3a keep-hot **600 kWh** (`combi_loss`), primary_loss 0 | +| TFA | (case-6 dwelling) | **201.53 m²** (different fabric/dimensions) | +| PV | none | **none** (the golden note's "+ PV" is STALE — `solar_water_heating=N`, no PV field) | + +So 0240's *remaining* residual lives in the parts case 6 never touched — +**the condensing-combi (130) + no-cylinder HW path** and the cert's own fabric. +The combi Eq-D1 / Table 3a keep-hot path has never been pinned against a +worksheet in the dual-main context. + +### Partial ground truth already in the 0240 JSON + +The lodged `renewable_heat_incentive` block gives two deemed-demand figures: +- `water_heating = 2842.82` — **exactly equals** the cascade's §4 HW output + `(64)` (2842.82). So the **HW demand is right**; any HW residual is in the + *efficiency* (Eq D1 combi blend), not the demand. +- `space_heating_existing_dwelling = 13254.52` vs cascade `(98c)` 12760.9 — + differ ~494 kWh (~3.7%). RHI uses its own deemed methodology so this is **not** + a clean 1e-4 check, but it's a hint the space-heat demand or the combi figures + are worth scrutinising. + +--- + +## What to do next — generate the right example + +To close 0240 properly you need a **worksheet** that exercises its +combi-HW path. Two options, best first: + +1. **Exact 0240 replica worksheet (gold standard).** Re-enter 0240's lodged data + into Elmhurst and export the worksheet PDF. Then build a mapper-driven fixture + (mirror `_elmhurst_worksheet_001431_case6.py`) and pin every line `(1)..(286)` + at 1e-4. The first diverging line localises the residual exactly. This is the + only way to get a true 1e-4 target for 0240. + +2. **"Case 7" — case 6 with 0240's combi swapped in.** If generating an exact + 0240 replica is hard, generate a `001431` variant that changes case 6's + heating to **0240's**: + - **Condensing oil combi, SAP code 130** (not 127 regular boiler). + - **NO hot water cylinder** — combi instantaneous DHW → WHC 901, Table 3a/3b + combi keep-hot loss, no primary/storage loss. + - Keep the validated dual-main rads(2106)+UFH(2110) 51/49 + RR rooflights. + This pins the **combi Eq-D1 + Table 3 keep-hot** path (the biggest unvalidated + piece) against a worksheet. Whatever it reveals applies directly to 0240. + +**The single most important differentiator to change vs case 6: regular +boiler + cylinder → condensing combi (130) with no cylinder.** That is the one +HW path 0240 uses that has never seen a worksheet in this archetype. + +### Reframing the goal +If a worksheet is genuinely unavailable, "0 residual vs the lodged register" is +not achievable at 1e-4 (integer rounding). The realistic target then is the +**±0.5 SAP fallback** (AGENT_GUIDE §1) — and 0240's continuous SAP 72.39 vs +lodged 73 is ~0.6 off, just outside it. Closing that last 0.6 still requires +knowing the true (worksheet) value, so the worksheet is the unblocker either way. + +--- + +## 0240 worksheet input specification (build THIS in Elmhurst) + +Reproduce cert **0240-0200-5706-2365-8010** as closely as possible so the +worksheet is a valid 1e-4 ground truth for the cascade. All values are from the +fixture JSON (`tests/.../fixtures/golden/0240-0200-5706-2365-8010.json`). Match +the **U-values / W-per-K targets** below — those are what the cascade actually +consumes, so hitting them matters more than the exact construction wording. + +**Dwelling** +- Detached house, **construction age band J**, England. Postcode **LE15 9LB** + (drives the demand/EPC climate). 1 extension. 7 habitable rooms. +- Storey height **2.28 m**. Total floor area **≈202 m²** = + Main ground floor **97.72** + Extension 1 ground floor **20.61** + the + room-in-roof floor **83.2**. + +**Heating — the load-bearing difference vs case 6.** TWO main systems, both a +**condensing oil combi, SAP main heating code 130** (Table 4b winter **82** / +summer **73**), oil fuel, **balanced flue** (not fan-assisted), efficiency from +the **SAP table** (no PCDB boiler index), central-heating pump age **unknown**. +They heat **different parts** (so the p.186 MIT applies, already implemented): +- **Main 1** — **radiators**, control **2106** (programmer + room thermostat + + TRVs), **51%**, serves the living area. +- **Main 2** — **underfloor heating in screed** (R=0.75), control **2110** (time + + temperature zone control), **49%**, serves elsewhere. + +**Domestic hot water — the other key difference vs case 6.** Heated **from the +main system** (WHC 901), oil. It is a **COMBI with NO hot-water cylinder** — +instantaneous DHW → SAP 10.2 **Table 3a keep-hot loss 600 kWh/yr** (`combi_loss` +600, `primary_loss` 0). 3 **mixer** showers + 1 bath; no effective WWHRS. +NB the lodged RHI `water_heating = 2842.82` already equals the cascade HW output +exactly, so get the DHW *demand* inputs right and any residual is in the combi +*efficiency* (Eq D1 winter/summer blend). +- **Boiler interlock: YES** for 0240 (combi + room thermostat 2106, no cylinder) + → **no −5pp penalty**, both systems run at base eff 82/73. (This is the + OPPOSITE of case 6, which had a regular boiler + cylinder with no cylinder stat + → −5pp. Get this right or the efficiencies — hence everything — will be off.) + +**Fabric — target W/K (cascade values to reproduce; total external area 328.97 m²):** +| element | W/K | notes | +|---|---|---| +| Walls (29a) | 24.45 | age J, **uninsulated** (NI), not dry-lined, not measured | +| Roof (30) | 32.331 | Main = pitched, **access to loft**, insulation at ceiling, **400 mm+** ; Ext1 = pitched **vaulted ceiling**, **no insulation (NI)** | +| Floor (28) | 29.4297 | **solid**; Main heat-loss perimeter 36.45, Ext1 13.45 | +| Party/gable (32) | 7.84 | RR gables billed as party at U=0.25 | +| Windows (27) | 22.7407 | see below | +| Roof windows (27a) | 12.6374 | see below | +| Doors (26) | 11.1 | **2 doors, uninsulated** | +| Thermal bridging (36) | 36.1867 | = 0.11 × 328.97 | +| **(33) fabric total** | **140.5288** | | +| **(37)+vent feeds (39)** | total transmission **176.7155** | | + +**Room-in-roof** (Main only): floor area **83.2 m²**, **two gables L = 6.40 m** +— one **Exposed**, one **Party** (per the case-5/6 sandstone replica convention), +age J. This is the same Simplified/detailed-gable RR structure case 6 validated. + +**Windows** (all **double glazed, PVC frame**, glazing "DG 2002+", U≈2.0, g=0.72): +- 5 **vertical** wall windows: 1.4×1.3, 1.2×1.3 (orient N), 1.6×1.3, 2.5×2.0 + (orient E), 1.4×1.3 (orient S, on Extension 1). +- 6 **"Roof of Room" rooflights** (window_wall_type 4): all **1.0×1.0**, at 45°, + 3 orient N + 3 orient S. These bill on (27a) and deduct from the RR residual + (S0380.203) — keep them as roof-of-room, not vertical glazing. + +**Ventilation / lighting / other** +- Natural ventilation; **no** mechanical ventilation, **no** extract fans, **no** + chimneys/flues. 85% draught-proofed. +- Lighting: **8 LED bulbs, 100% low-energy** (no CFL/incandescent). +- **No PV**, no solar thermal, **no secondary heating**, no air-conditioning. +- Electricity meter type 3 (standard), smart meter present, not export-capable. + +### The three things that MUST differ from case 6 (or you've just rebuilt case 6) +1. **Condensing oil combi, SAP code 130** (case 6 = regular oil boiler 127). +2. **Combi, NO cylinder** → Table 3a keep-hot 600 kWh (case 6 = boiler + 110 L + cylinder → primary/storage loss). +3. **Boiler interlock PRESENT → no −5pp** (case 6 = no interlock → −5pp). Driven + automatically by "combi + room thermostat, no cylinder", but verify the + worksheet shows base eff 82/73, not 77/68. + +Everything else (dual-main different-parts MIT, two pumps, rooflight→RR, Eq-D1 +(204) share) is already implemented and validated by case 6 — the new worksheet +just confirms the combi-HW path on top of that closed structure. + +--- + +## Pointers +- Golden pin + full slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py` (cert `0240-0200-5706-2365-8010`, line ~83). +- Case-6 fixture to mirror: `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py` + its e2e pins in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`. +- Memories: [[project_case6_mit_two_system_rootcause]] (the p.186 MIT, now CLOSED), [[project_case6_interlock_already_done]], [[feedback-worksheet-not-api-reference]] (API matches the worksheet, not the lodged register), [[feedback-software-no-special-handling]]. +- Repro 0240: `EpcPropertyDataMapper.from_api_response(json.load(...0240.json))` → `cert_to_inputs` / `cert_to_demand_inputs` → `calculate_sap_from_inputs`. The §2.4 section helpers are UNFAITHFUL (skip the interlock penalty + two-system MIT params) — diagnose against the real `cert_to_inputs` cascade. +- Process: one slice = one commit, spec citation (page+line), `Co-Authored-By: Claude Opus 4.8 `. SAP 10.2 only. No tolerance widening. mapper.py + cert_to_inputs.py each carry 32 pre-existing pyright errors (baseline-compare with `git stash`). diff --git a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md new file mode 100644 index 00000000..2aa3ff68 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md @@ -0,0 +1,223 @@ +# Handover — wide-scale API accuracy study + next steps + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, the +1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `0f6b4023` (S0380.229). Next slice: **S0380.230**. +- **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` + → green (2397 passed, 1 skipped). Pre-existing out-of-scope failures unchanged + (stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`). +- **Toolkit (committed):** `scripts/fetch_2026_epc_sample.py`, + `scripts/eval_api_sap_accuracy.py`, `scripts/analyse_api_sap_clusters.py`. The 1,000 cached + JSONs live in `/tmp/epc_2026_sample/` (gitignored scratch — re-fetch with the sampler; + `EPC_SAMPLE_CACHE` overrides the dir). Re-run the eval after any mapper/calculator change to + watch the headline move. + +--- + +## What this study did + +Fetched a **random 1,000-cert sample of domestic EPCs lodged Jan–May 2026** from the +GOV.UK EPB register (the `/api/domestic/search` date-windowed endpoint to enumerate cert +numbers across random pages → `/api/certificate` per cert for the full schema-21 JSON), ran +each through the **API path** (`from_api_response → cert_to_inputs → continuous SAP`), and +compared to the lodged rounded `energy_rating_current`. + +**This is the first measurement of raw-API behaviour on an unbiased population** — the curated +golden cohort (~exact) masked it. + +### Reproduce +- Sampler/fetcher: `/tmp/sample_fetch_2026.py` → caches JSONs to `/tmp/epc_2026_sample/`. +- Evaluator: `/tmp/eval_sap_accuracy.py` → per-cert CSV + summary (`% <0.5`, buckets, worst-40, + raise breakdown). Cluster analysis: `/tmp/analyze2.py`. (Token in `backend/.env` + `OPEN_EPC_API_TOKEN`; `date_end` must be < today.) +- **These scripts are uncommitted (in /tmp).** Worth promoting to `scripts/` if this becomes + a recurring measurement. + +--- + +## Headline (at HEAD `9c0a373f`) + +| metric | value | +|---|---| +| computed | **882 / 1000** (100 unsupported pre-21 schema; 18 still raise) | +| **% \|err\| < 0.5** (of computed) | **41.8%** | +| % < 1.0 / < 2.0 / < 5.0 | 54.9% / 71.9% / 87.8% | +| median / mean \|err\| | 0.79 / ~2.4 | +| mean signed err | +0.2 (slight over-rate) | + +**Accuracy is dominated by heating type** (the load-bearing cut): + +| main_heating_category | n | mean \|err\| | %<0.5 | status | +|---|---|---|---|---| +| 2 = gas boiler (PCDB-indexed) | 579 | 1.30 | 48% | the well-trodden path | +| **7 = electric storage heaters** | 39 | **7.33** | **3%** | **broken — #1 lever** | +| **10 = electric room heaters** | 43 | **10.26** | **9%** | **broken — #2 lever** | +| 6 = community scheme | 38 | 2.28 | 34% | known-hard | +| Flats (any heating) | 242 | 3.19 | 29% | geometry + communal | + +--- + +## Work shipped this session (S0380.219–225) + +Coverage unblocked **788 → 882 computed (+94)**; one real accuracy bug fixed (+22 certs). + +| slice | fix | certs | +|---|---|---| +| S0380.219 | floor_construction 3 → "Suspended, not timber" (RdSAP 10 field 3-1) | ~44 | +| S0380.220 | floor_construction 0 → None (Table 19 unknown; proven inert) | 37 | +| S0380.221 | default missing `post_town` (unused metadata) | 1 | +| S0380.222 | roof_construction 6 (thatched) + 7 (dwelling above) → None (inert) | 5 | +| S0380.223 | `_part_geometry` early-return key contract (RR KeyError) | 5 | +| **S0380.224** | **loose-jacket cylinder storage loss (Table 2 Note 1)** — was None'd out → zero loss | **22** (mean err +2.29 → +0.45) | +| S0380.225 | §10.7 no-water-heating default A-F → 12mm loose jacket | 2 | +| S0380.226 | Elmhurst "Jacket" cylinder insulation → loose-jacket code 2 (Summary path) | (unblocked case 19) | + +Headline at HEAD: **882 / 1000 computed, 41.8% < 0.5** (re-run the eval to refresh). + +--- + +## ★ Active worksheet: simulated case 19 — the electric-storage-heater debug + +The user generated `sap worksheets/golden fixture debugging/simulated case 19/` +(`Summary_001431 (2).pdf` + `P960-0001-001431 - 2026-06-04T174437.228.pdf`), purpose-built to +hit the #1 cluster. It exercises **electric storage heaters** (SAP code 402, control 2402 +auto-charge, 7-hr off-peak tariff) + a **loose-jacket 210 L cylinder** + **WHS 911** (gas +boiler for water only) + **room-in-roof gables (Party + Exposed) + an alternative wall + +exposed floor + electric secondary**. + +**S0380.226 unblocked extraction** (the "Jacket" label was raising). The worksheet has FOUR +blocks: **block 1 = rating** (UK-avg region 0; cost (255)=1816.58, SAP (258)=51, TF (53)=0.60, +(51)=0.0330), **block 2 = demand** (postcode; CO2 (272)=3125.85, PE (286)=30271.76), blocks 3/4 += the potential/improved variants. Pin the rating block for SAP/cost, the demand block for +PE/CO2. Worksheet header line 116 lodges **"Separate Time Control: No"** (NOT in the Summary §15 +PDF — only in the P960 header). + +**Three slices shipped (S0380.227–229)** — closed the +9 cluster signature; SAP cont +60.2 → **50.33** (worksheet ~51.22): + +| slice | line ref | fix | SAP cont | +|---|---|---|---| +| **S0380.227** | TF (53) 0.54→**0.60**; (59) h=3→**h=5** | dedicated DHW-only system (WHS 911) is NOT separately timed → no Table 2b ×0.9 (RdSAP 10 §10.5.1). `_separately_timed_dhw` gated on WHC ∈ {901,902,914}. Worksheet-pins S0380.224's loose-jacket (51)=0.0330/(53)=0.60/(55)=3.4531/(56-57)Jan=107.0456 at 1e-4. | 60.2→60.1 | +| **S0380.228** | cost (255) | electric SECONDARY on off-peak bills at Table 12a `OTHER_DIRECT_ACTING_ELECTRIC` (7-hr high-frac **1.00** = £0.1529), not the flat off-peak low (£0.0550). Worksheet (242): "1.00*15.29 + 0.00*5.50". THE primary cost driver (−340). | 60.1→**50.67** | +| **S0380.229** | (62) 2493.30→**3169.98** | dedicated water-heating boiler/circulator (WHC 911-931) feeds the cylinder via a primary loop → Table 3 row 1 primary loss applies (keyed off `water_heating_code`, since `_water_heating_main` returns the electric SPACE main). Restored the missing (59)=676.68 kWh/yr. | 50.67→50.33 | + +**The ONE remaining case-19 cause — the PV diverter (63b) — is S0380.230 (next).** Worksheet +header line 124 "Diverter = Yes"; Summary §19 "Diverter present: Yes". Per **SAP 10.2 Appendix +G4 (PDF p.72-73)** surplus PV is diverted to the cylinder immersion: +`S_PV,diverter,m = EPV,m × (1 − βm) × 0.8 × 0.9`, clamped to ≤ (62)m + (63a)m, entered as a +NEGATIVE (63b)m. (64)m = (62)m + (63a)m + (63b)m + … → (219)m = (64)m / eff. All four G4 +inclusion conditions are met (PV connected to dwelling; cylinder 210 L > (43)=74.24; no solar +HW; no battery). Worksheet (63b) annual ≈ −1097.67 kWh → (64) drops 3169.98 → 2072.31, (219) +4876.9 → 3188.17. It ALSO changes the PV β-split (export drops: worksheet dwelling 1280.39 / +exported 184.16 vs our 1496.20 / 1187.98 with no diverter). This is a 3-layer feature +(extractor `Diverter present` → mapper flag → calculator (63b) + β-split interaction) — +implement as one focused slice. Spec note p.5485: for the β calc, (219)m must EXCLUDE the +diverter saving. + +Smaller residuals after the diverter lands: main fuel (211) ours 20250.22 vs ws 19910.30 +(+340), secondary (215) 3573.57 vs 3513.58 (+60), fabric (33) +1.0 (gable/alt-wall). Current +demand block: CO2 (272) 3331.04 vs 3125.85, PE (286) 31653.23 vs 30271.76 — both will drop with +the diverter (less grid import). + +**Debug recipe** (reuse the `/tmp/case19*.py` throwaways or rebuild): +```python +pages → ElmhurstSiteNotesExtractor(...).extract() → from_elmhurst_site_notes +→ cert_to_inputs / cert_to_demand_inputs → calculate_sap_from_inputs +# CI._cylinder_storage_loss_override(epc, main) → (57)m; CI._primary_loss_override(epc, age) → (59)m +# CI._water_heating_worksheet_and_gains(epc=…, water_efficiency_pct=0.65, is_instantaneous=False, +# primary_age=, pcdb_record=None) → wh_result with (45)/(46)/(57)/(59)/(62)/(64) +``` + +--- + +## Remaining work, prioritised + +### A. Accuracy clusters (highest value — 80+ certs, mean err 7–10) +1. **Electric storage heaters (cat 7, 39 certs).** Distinct cascade — off-peak tariff split, + charge control (2401/2402), 7-hr/24-hr charge, Table 4a efficiency, responsiveness. **No + worksheet currently validates this path.** Errs both directions (−27..+16). +2. **Electric room heaters (cat 10, 43 certs).** Likewise (controls 2601/2602/2603). Worst + cluster by mean (10.26). +3. **Flats (242, 29% <0.5)** and **PV (40, 28%)** — secondary. + +### B. Remaining raises (18 certs — all U-value / heat-loss-sensitive, NOT enum guesses) +- **`gable_wall_type` 2 & 3 (14 certs).** RdSAP 10 **Table 4** RR walls: 0=Party (U=0.25), + 1=Exposed (U=common wall), 2/3 = **Sheltered (U=external×R0.5)** + **Adjacent-to-heated + (U=0)**, code↔type order unconfirmed (schema says "not yet seen"). Needs (i) a worksheet to + pin which code is which + the U-values, and (ii) **calculator support** — the cascade only + has `gable_wall`/`gable_wall_external` kinds; Sheltered (R=0.5) and Adjacent (U=0) are new. + Best real example: `2818-3053-3203-2655-9204` lodges BOTH gable 2 and 3. +- **`main_heating_category: 9` = warm air, mains gas (1 cert).** Needs §9 warm-air dispatch. +- **`wall_insulation_thermal_conductivity` 3 (1 cert).** Verified it shifts wall U + (53.96→51.61 across λ) → worksheet-backed (the resolver's own discipline). +- **`floor_heat_loss` 8 (2 certs).** Semantically unconfirmed; inert for the 2 observed + (non-Main bp) but potentially "heated space below" (→ should exclude the floor, a calculator + change). Don't guess. + +The clean mapper-enum raises are **exhausted** — every remaining raise changes the answer, which +is what the strict-raise guard exists to prevent. + +--- + +## ★ Additional worksheets that would help most + +Case 19 (above) already covers electric storage heaters + loose-jacket cylinder + RR. The two +that would add the most NEW coverage: +1. **An electric ROOM-heater dwelling** (SAP code ~691, control 2601/2602) — the **cat-10 + cluster (43 certs, worst by mean error 10.26)**, which case 19 does not touch. +2. **A room-in-roof with a SHELTERED gable and an ADJACENT-TO-HEATED gable** (Table 4 types + beyond Party/Exposed) — closes the `gable_wall_type` 2/3 raise (14 certs) and pins the + Sheltered (U=ext×R0.5) / Adjacent (U=0) U-values the calculator must add. + +The original "design one property" guidance (kept below for reference) is what case 19 was +built from. + +## What to generate — the single most productive worksheet (reference) + +Heating is one-per-property, so one worksheet can't cover all four broken heating types. But +**fabric is independent of heating**, so the highest-ROI single artifact bundles the #1 +accuracy cluster with the fabric that closes the gable raises and pins the loose-jacket fix. + +**Build (in Elmhurst, a simulated case is fine — same as the existing `simulated case N` +worksheets) ONE property:** + +> **A house heated by ELECTRIC STORAGE HEATERS, with a room-in-roof and a hot-water cylinder:** +> - **Heating:** electric storage heaters (off-peak / Economy-7 tariff), with a clear control +> type. *This is the load-bearing choice — it validates the 39-cert cat-7 cluster.* +> - **Hot water:** a cylinder with a **loose-jacket** insulation (not factory foam), a stated +> jacket thickness, and a cylinder thermostat. *Pins S0380.224's loose-jacket storage loss +> (56)m at 1e-4 — currently only direction-validated.* +> - **Room-in-roof** with **two gable walls of different types** — ideally one **"Sheltered"** +> and one **"Adjacent to another heated space"** (plus, if the tool allows, a Party and an +> Exposed gable). *Gives the Table 4 U-values for gable_wall_type 2 & 3 and disambiguates the +> code order — closes the 14-cert raise.* +> - **An extension (2nd building part)** with a different floor exposure (e.g. over unheated +> space or "to external air"). *Exercises multi-bp geometry + floor-exposure handling.* + +From that single worksheet I can pin, at 1e-4: the electric-storage space-heating lines +((210)/(211)/space-heat), the loose-jacket storage loss (56)m, the RR gable U-values (30)/(32), +and the multi-bp fabric (27)–(37). That's **one cluster + one fix-validation + the biggest +raise + fabric**, all in one document. + +**If you'd rather do two:** add a second worksheet that is identical but with **electric room +heaters** instead of storage heaters — together they cover cat 7 + cat 10 (≈ 82 certs, the +two worst clusters). A third for a **community-heating flat** would cover cat 6 + the flat +geometry cluster. + +### Then send me, per worksheet +The **Summary PDF** (the Elmhurst input/site-notes) + the **worksheet PDF** (the `(1)..(286)` +ground truth). With those I run both front-ends through the cascade and pin each line ref at +1e-4, exactly as for the `with api 3` pair (S0380.218). + +--- + +## Conventions (unchanged) +One cause = one slice = one commit; spec citation (page+line) in the message; AAA tests +(`# Arrange / # Act / # Assert`); `abs(x - y) <= tol` (not `pytest.approx`); SAP 10.2 only; no +tolerance widening / xfail / rel-tol. New code passes pyright strict with ZERO NEW errors +(baseline-compare with `git stash`; mapper.py / cert_to_inputs.py / heat_transmission.py carry +pre-existing errors — compare counts). Stage files by name (the tree has unrelated +`pytest.ini`/`scripts/` changes that must NOT be staged). +`Co-Authored-By: Claude Opus 4.8 `. diff --git a/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md new file mode 100644 index 00000000..2a982f38 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md @@ -0,0 +1,160 @@ +# Handover — fresh-API cross-comparison + flagged-cert debugging + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, the +1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `6d9ef114` (S0380.218). Confirm with `git rev-parse HEAD`. +- **Baseline (AGENT_GUIDE §4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` + → green (2392 passed, 1 skipped at HEAD; the golden + worksheet pins all pass). +- **Next slice number:** **S0380.219**. + +> **S0380.218 (DONE) — Part 1 closed for the "with api 3" pair.** The two +> certs the user dropped under `sap worksheets/with api 3/` — +> `0340-2467-9260-2006-6521` (Summary_000922 / dr87 000922) and +> `5500-5070-0822-0201-3663` (Summary_000920 / dr87 000920) — are **clean**. +> Fetched fresh, run through BOTH front-ends, both paths agree to <1e-4 on +> SAP/cost/CO2/PE AND reproduce the worksheet (255)/(272)/(286)/(33)/(37) +> exactly. SAP integer = lodged (resid +0) on both. **No mapper/calculator +> bug surfaced.** Dropped-field audit clean (only `created_at` + +> `_normalize_shower_outlets`-handled shower keys). Locked in as golden +> fixtures: 2 JSONs under `fixtures/golden/` + entries in `_EXPECTATIONS` +> and `_WORKSHEET_PE_CO2` (test_golden_fixtures.py). The Summary path was +> validated manually but is NOT pinned in a committed test (would need the +> Summary PDFs copied into `backend/documents_parser/tests/fixtures/` + a +> textract-preprocessed chain test) — a cheap follow-up if cross-mapper +> parity wants a standing regression guard beyond the API-path golden pin. +- **Pre-existing failures (NOT yours, out of scope):** + - `domain/sap10_ml/tests/test_rdsap_uvalues.py` — 2 stone-§5.6 thin-wall failures + (granite + sandstone band A, 3.7408 vs Table-6 1.7 cap). Run this suite when you touch + `rdsap_uvalues.py`. + - `datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestFromRdSapSchema21_0_1::test_total_floor_area` + (145.82 vs 45.82) — fails at original HEAD `ec64c39d` too. This file is NOT in the §4 + suite command. + +--- + +## ★ THE TASK — fetch fresh from the EPC API and debug, with worksheet cross-comparison + +The previous session drove the **golden-fixtures cascade** (`cert_to_inputs` → +`calculate_sap_from_inputs`) and concluded that the three then-flagged certs (7536, 2130, +0240) are "0240-like" — API-only residuals not reproducible from the register JSON. The +user pushed back ("going around in circles"), and the right next move is **fresh raw-API +data + worksheet triples**, not more simulated worksheets. + +### Part 1 — two NEW certs with API + Summary + worksheet (cross-comparison) + +The user has **two certs that have all three artifacts**: the GOV.UK API JSON, the Elmhurst +**Summary** PDF (site notes / input), and the Elmhurst **worksheet** PDF (the `(1)..(286)` +ground truth). These are gold — they let you run BOTH front-ends (`from_api_response` and +`from_elmhurst_site_notes`) through the same cascade and pin **both** against the worksheet +at 1e-4. The user will provide the cert numbers + drop the PDFs. For each: + +1. Fetch the API JSON (see **Fetching** below). +2. Run API path → cascade; run Summary path → cascade; pin **both** vs the worksheet line + refs (`pdftotext -layout` the worksheet; compare `(27)/(28a)/(29a)/(30)/(33)/(36)/(45)m/ + (62)/(233a)/(233b)/(258)…`). Cross-mapper parity: the two paths must agree to 1e-4 AND + match the worksheet (memory `feedback_cross_mapper_parity_via_cascade`). +3. The **first diverging line ref localises the bug** (AGENT_GUIDE §3): value present in + worksheet but cascade 0/wrong → calculator; input field absent in `epc` → mapper or + extractor. Fix one cause = one slice. + +### Part 2 — (secondary) re-check the previously-flagged certs on THIS branch + +A dashboard once flagged six certs (0240, 0390-2954-3640, 2130, 6035, 7536, 9390). **Those +numbers are STALE — they came from a branch WITHOUT this branch's fixes** (the user confirmed +this). Do not chase them. On THIS branch the picture is different and mostly settled: + +- 7536 (68.924, +1), 2130 (83.78, +2), 0240 (−1) — concluded **0240-like** (API-only + residuals; see per-cert notes below). 0390-2954-3640 pins at **+0** (exact). +- 6035 (+2.19) and 9390 (community, −2) carry documented open residuals (see notes) but are + lower-priority and not worksheet-backed. + +So Part 2 is only worth touching if a **fresh fetch differs from the committed fixture** +(curated/hand-corrected fixtures can mask raw-API mapper behaviour) — `diff` fresh vs fixture +and debug the delta. Otherwise these are done; the real new work is **Part 1**. + +--- + +## Fetching from the EPC API + +Token lives in `backend/.env` as `OPEN_EPC_API_TOKEN` (also `EPC_AUTH_TOKEN`). The exact +mechanism (from `scripts/fetch_cohort2_api_jsons.py`): + +```python +import httpx, os +from dotenv import load_dotenv +from infrastructure.epc_client.epc_client_service import EpcClientService +load_dotenv("backend/.env") +token = os.environ["OPEN_EPC_API_TOKEN"] +resp = httpx.get( + f"{EpcClientService.BASE_URL}/api/certificate", + params={"certificate_number": ""}, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=EpcClientService.REQUEST_TIMEOUT, +) +payload = resp.json()["data"] # <- this is the schema-21 JSON the mapper consumes +``` + +`EpcPropertyDataMapper.from_api_response(payload)` only supports `schema_type` +`RdSAP-Schema-21.0.0` / `21.0.1`; it raises for others. The persisted golden fixture IS this +`data` payload. So `diff <(fresh)` vs the committed fixture is apples-to-apples. + +--- + +## Per-cert notes carried from the previous session (verify against FRESH data) + +- **7536 (+1)** — roof bug fixed (S0380.214: as-built sloping ceiling → Table 18 col 3). + Every per-element U matches Elmhurst (cases 15-17 worksheets). Concluded 0240-like; cont + 68.924. +- **2130 (+2)** — dropped measured wall insulation captured (S0380.215 → Table 8 U=0.32), + which **exposed** the true residual (the +1 was two offsetting bugs). PV β-split **proven + exact** vs simulated case 18 worksheet (onsite 970.77 / export 1713.40 to the decimal). + Gas PE factor exact (1.13). Concluded 0240-like; cont 83.78. +- **0240 (−1)** — export-dropped 2013+ circulation-pump age (115 vs 41 kWh); WWHRS confirmed + inert (`shower_wwhrs=1` is the universal default across all 47 certs). User previously + decided NOT to re-pin. Concluded 0240-like. +- **0390-2954-3640** — pinned +0 (oil combi, Table 3a row 1). The user's −6.85 flag is the + reconciliation mystery above — START HERE; it's the clearest signal of a fresh-vs-fixture + or different-engine gap. +- **6035** — see memory `project_golden_coverage_state`: a user-simulated 6035 worksheet + closed to 1e-4, but "6035 remaining +19 PE needs its own worksheet"; flagged +2.19 SAP. +- **9390** — community heat-network (S0380.212/.213 fixed the fuel-code collision + standing + charge); left at SAP −2 with a documented ~7% demand over-count (heat-source-eff default?). + Unpinned/retired. The user's −4.24 may be the same demand over-count on fresh data. + +--- + +## What this session shipped (commits `ec64c39d..f895dd3a`) + +| slice | what | +|---|---| +| **S0380.214** | As-built "Pitched, sloping ceiling" (code 8) roof → RdSAP 10 Table 18 col (3) (band F 0.40→0.68, L 0.16→0.18) per §5.11 item 5-5 + note (b). Code-5 vaulted stays col (1) (cohort). Worksheet-validated (sim case 15). Re-pinned 7536. | +| **S0380.215** | Captured dropped `wall_insulation_thickness_measured` (schema 21 didn't declare it → `from_dict` dropped it). 2130 Ext1 "measured"/100 mm → RdSAP Table 8 U=0.32 (was 0.55 default). Exposed 2130's true +2 residual. | +| **S0380.216** | Extractor: handle pdftotext wrapping the §11 glazing-GAP column onto the glazing-TYPE token ("…16 mm or [1st]"). Fallback strip AFTER the direct lookup (preserves explicit interleaved keys). Unblocked running the cascade on hand-entered worksheet Summaries. | +| **S0380.217** | Captured dropped `wall_insulation_thermal_conductivity` (schema → domain → mapper) and wired it into `u_wall`'s §5.8 λ resolver. Code 1 = default 0.04; unmapped codes raise. Zero cascade effect today (2130's §5.8 path doesn't fire). | +| 3× docs | finalised 7536 / 2130 as 0240-like; corrected diagnoses. | + +**Audit method that found the dropped fields** (reuse it on the fresh certs): recursively +compare raw JSON keys against the parsed schema dataclass fields — anything in the JSON but +not a declared field is silently dropped by `from_dict`. The two real drops (2130's measured +wall insulation + thermal conductivity) came from this. Re-run it on the fresh fetches; new +certs may surface new dropped fields. + +--- + +## Conventions (unchanged) + +One cause = one slice = one commit; spec citation (page + line) in the message; AAA tests +(`# Arrange / # Act / # Assert`); assert with `abs(x - y) <= tol` (not `pytest.approx`); +SAP 10.2 only; no tolerance widening / xfail / rel-tol. New code passes pyright strict with +ZERO NEW errors — baseline-compare with `git stash` + `PYRIGHT_PYTHON_FORCE_VERSION=latest` +(mapper.py / cert_to_inputs.py / heat_transmission.py / rdsap_uvalues.py carry pre-existing +errors; compare counts). Stage files by name — the working tree has pre-existing unrelated +changes to `pytest.ini` / `scripts/` that must NOT be staged. +`Co-Authored-By: Claude Opus 4.8 `. + +When you re-pin a golden cert, update `expected_sap_resid` (±0), `expected_pe_resid_kwh_per_m2` +(±0.01) and `expected_co2_resid_tonnes_per_yr` (±0.001) to the exact post-fix values and +append a slice note to the cert's `notes:` explaining the cause + spec/worksheet citation. +Run the full §4 suite as the blast-radius check after any fabric/factor change. diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md new file mode 100644 index 00000000..aad9253b --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -0,0 +1,264 @@ +# Handover — golden-cert mapper/cascade bugs (post-0240 wall fix) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +the 1e-4 bar, the per-line debugging loop, and the suite command. This records the +state after closing the 0240 investigation and fixing the first of several +API-mapper/cascade bugs the audit surfaced. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `b9bbcecb` (docs after S0380.213). Confirm with `git rev-parse HEAD`. +- **Baseline:** `2386 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). + ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING + stone-formula failures there, see Thread 1. +- **Next slice number:** **S0380.214**. + +--- + +## ★ CURRENT PRIORITY — drive golden-fixture SAP residuals to ZERO + +`_EXPECTATIONS` in `test_golden_fixtures.py` holds **53 pinned golden certs**. The suite +is GREEN. **Only 3 have a non-zero SAP integer residual**, and they split into two kinds: + +| cert | lodged | cont SAP | resid | nature (from the cert's `notes:`) | +|---|---|---|---|---| +| **7536-3827-0600** | 68 | **69.071** | **+1** | +0.57 over — multi-age bps (Main D / Ext1 L / Ext2 F); **glazing U** (S0380.97 set glazing_type=2 → Table 24 spec U=2.0, but the cert's lodged U "appears higher than the spec default") | +| **2130-1033-4050** | 82 | **83.349** | **+1** | +0.85 over — end-terrace + 1 ext, gas combi PCDB 17505, **2× PV arrays**; SAP +1 came from the **cohort PV-β cascade interaction** (S0380.45/.49), not a pinpointed fabric line; PE residual −8.22 sits in gas-combi PE + secondary credit | + +**These are TWO DIFFERENT root causes — not a shared one** (an earlier audit label calling +both "multi-part wall" was wrong; trust the `notes:` above). + +- **7536** is the more tractable: a clear **glazing-U** hypothesis. S0380.97 forced + `glazing_type=2` to the Table 24 default U=2.0; the note suspects the cert's true per-bp + glazing U is higher (multi-age D/L/F geometry). Walk §3 window `(27)` per-bp vs the lodged + register window rating; the lever is likely the glazing U for one of the extensions. ASK + the user for a simulated Elmhurst worksheet mirroring 7536's glazing (double-glazed, + multi-age bps) to pin the true `(27)` U rather than guess. +- **2130** is harder: the SAP +1 is a PV-β / cohort *cascade interaction*, not a single + fabric line. Its PE residual (−8.22) is a documented gas-combi-PE + secondary-credit + deferral. Decompose which metric drives the integer flip (cost/EI vs PE) before touching + anything; this one may need the PV/secondary path, not fabric. + +Both certs are API-only (no worksheet) → bar is ±0.5 SAP vs lodged; the goal is the integer +flip (69→68 / 83→82), i.e. shave ~0.57 / ~0.85 off the continuous SAP. Per the session +methodology lesson, ASK for a worksheet rather than guess a U-value or factor. + +**0240 (−1) is NOT driveable from the JSON and the user has decided NOT to re-pin it — +document the cause only.** Continuous SAP is 72.462; the true SAP is **72**. The lodged +**73 requires a "2013 or later" circulation pump (41 kWh)**; 0240's open-data API lodges +`central_heating_pump_age=0` = **Unknown → 115 kWh**. The encoding was proven across 13 +API+Summary pairs (`0`=Unknown, `2`=2013+). The export did not preserve the pump age that +produced the lodged 73, so 73 is unreachable without inventing data. Both fabric bugs that +masked it are now fixed (wall S0380.209 + roof S0380.211 → cont 72.462). **Leave the pin at +`actual_sap=73, expected_sap_resid=-1`; the notes already record this.** Driving it to zero +would mean fudging the pump age — don't. + +Latent (lower priority): **9390** (community, −2, **unpinned**/retired) ~7% demand +over-count — see Thread 2. + +--- + +## What this session shipped + +| slice | what | +|---|---| +| **S0380.208** | Promoted **simulated case 7** (combi swap of case 6) to an e2e fixture. PROVED the condensing-oil-combi (SAP code 130, no cylinder, combi instantaneous DHW, Eq D1, Table 3a keep-hot) path is **exact at 1e-4** with zero calculator changes → exonerated the heating as the source of 0240's residual. | +| **S0380.209** | Fixed the **API-path wall U** "as built, insulated (assumed)" bug — routes to the as-built age-band row, not the 50 mm retrofit bucket. New `_described_as_retrofit_insulated` in `heat_transmission.py`. Worksheet-validated by case 9 (sandstone J → 0.35) + case 10 (solid brick J → 0.35). Re-pinned 0240 PE +1.8687 → +5.5044, CO2 +0.0907 → +0.2757 (SAP integer 72 unchanged). | +| **S0380.210** | **CLOSED cert 0390** (Thread 3). Cavity wall "as built, **partial** insulation (assumed)" (type 4) was mis-routed to the Table 6 "Filled cavity" row (band F 0.40) → should be "Cavity as built" (band F 1.0). New `_cavity_described_as_filled` in `rdsap_uvalues.py` excludes "partial insulation" from the filled trigger (keeps "insulated (assumed)" → filled). SAP +7 → +0, PE −27.97 → +0.53, CO2 −2.71 → −0.12. Mirrors S0380.209 on the cavity path. | +| **S0380.211** | **CLOSED Thread 1 (roof).** 0240 Ext1 vaulted (code 5) NI roof returned 0.68 (§5.11.4 50 mm) → should be Table 18 col (1) age-band (band J 0.16), matching 33 cohort-2 `ND` vaulted roofs. New `u_roof(is_sloping_ceiling=...)` flag threaded from heat_transmission (codes 5/8). 0240 PE +5.50 → +1.52, CO2 +0.28 → +0.07 (SAP 72). Also corrected the S0380.210 cavity unit test in `domain/sap10_ml/tests/` (suite-command gap — see Thread 1). | +| **S0380.212** | **Thread 2 CO2/PE collision FIXED.** EPC fuel 20 = "mains gas (community)" collided with Table-12 biomass code 20 → community CO2 6.4× low. New `_heat_network_factor_fuel_code` translates 20→51 for heat-network mains only (5 sites: SH+HW CO2/PE/price). 9390 CO2 0.44→3.03 t (lodged 2.8), PE 204→220. Case-14-validated ((367) 0.2100 / (467) 1.1300). Cost +4 tail open. | +| **S0380.213** | **Thread 2 cost +4 FIXED** via the heat-network standing charge. API community fuel 20 isn't a Table-32 gas code → `_is_gas_code` False → £0 standing (vs SAP 10.2 Table 12 note (l) £120; case 14 `(351)`=£120). New `_heat_network_standing_charge_gbp` (£120 full / £60 DHW-only, §C3.2) REPLACES the fuel standing for heat-network mains (no double-count; CH corpus stays £120). 9390 SAP +4 → -2 (exposes a ~7% demand over-count — follow-up). | + +Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]]. + +--- + +## The 0240 verdict — RESOLVED, not closable to 73 + +**0240's true SAP is 72**, proven three independent ways: +- Elmhurst **case 8** (pump=Unknown, 0240's actual lodged value) → worksheet SAP **72**. +- Elmhurst **case 9** (correct sandstone wall 0.35 + sloping roof 0.25) → SAP **72**. +- Our cascade with **both** the wall (done) and roof (pending) bugs fixed → SAP cont **72.31**. + +The register's **73 requires a "2013 or later" circulation pump (41 kWh)** — Elmhurst +**case 7** with that pump = 73. But 0240's API lodges `central_heating_pump_age=0` += **Unknown** → 115 kWh. The encoding was **proven from 13 API+Summary pairs**: +`0`="Unknown" (9 pairs), `2`="2013 or later" (4 pairs). The open-data export did not +preserve the pump age that produced the lodged 73. **It is genuinely unreachable from +the JSON.** Do not chase 0240 to 73; re-pin to its correct 72 once the roof lands. + +`pump_age` enum (verified): `0`=Unknown→115, `1`=Pre-2013→165, `2`=2013+→41 +(`_TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE` in `cert_to_inputs.py`). + +--- + +## THE METHODOLOGY LESSON FROM THIS SESSION (read this) + +The 0240 baseline (cont 72.39) was **two offsetting bugs**: a wall U **under**-count +(0.25 vs 0.35, less loss) masking an Ext1 roof U **over**-count (0.68 vs ~0.25, more +loss). Fixing one alone moves the residual the "wrong" way — fix both +([[feedback_software_no_special_handling]]). And: **Elmhurst is the arbiter, not the +spec text** — twice this session a confident spec/first-principles read was wrong and +a generated worksheet settled it (`NI`=not-indicated not "none"; pump `0`=Unknown). +**Generate a worksheet rather than guess a U-value or factor.** The user generates +Elmhurst worksheets readily (cases 7–10 done) — ask for one. + +--- + +## THREAD 1 — Roof fix — **CLOSED (S0380.211)** + +0240's Ext1 (BP2) lodges `roof_construction=5` (vaulted), `NI` thickness, "Pitched, +insulated (assumed)", band J → the cascade hit `u_roof`'s +`insulation_thickness_mm==0 and _described_as_insulated` override → **0.68** (the +§5.11.4 retrofit-50 mm joist row). A vaulted/sloping ceiling has no joist void, so per +RdSAP 10 §5.11 Table 18 (p.45) it takes the **column (1) age-band default (band J = +0.16)**, NOT 0.68. + +**The arbiter was the cohort, not case 11 — a methodology trap avoided.** The handover +above guessed the value was col-3 **0.25** (→ cont 72.31), citing case 9. That was +WRONG. The decisive evidence: **33 cohort-2 certs lodge `ND` (thickness None) vaulted +roofs** (`roof_construction=5`, band D) that already pin to their dr87 worksheets at +**0.40 = Table 18 col (1)**. 0240's only difference is the `NI` sentinel (insulation +present, unknown thickness), which uniquely hit the 0.68 override. So the spec-correct +value is **col (1) 0.16**, and 0240 lands at **cont 72.4617**, integer 72 — NOT 72.31. +A first broad attempt (route sloping → col-3 `_FLAT_ROOF_BY_AGE`) broke all 33 cohort +certs (band D col-3 = 2.30 vs worksheet 0.40) — that failure is what revealed the +col-1 answer. Lesson: when a U-value change moves worksheet-pinned cohort certs off +their pins, the change is wrong; the cohort worksheets are ground truth. + +**Implementation:** new `u_roof(is_sloping_ceiling=...)` flag, threaded from +`heat_transmission` for `roof_construction_type` containing "sloping ceiling" (code 8) +or "vaulted" (code 5). Fires only on the `NI` case (thickness 0 + "insulated +(assumed)") → col (1); the `ND`/None path is untouched (already col 1) and a normal +pitched-with-loft roof still takes the §5.11.4 50 mm row (flag defaults False). 0240 +PE +5.5044 → +1.5181, CO2 +0.2757 → +0.0728 (SAP 72 unchanged). Re-pinned in +`test_golden_fixtures.py`. + +**⚠ Suite-command gap discovered:** the AGENT_GUIDE §4 suite command does NOT run +`domain/sap10_ml/tests/`, where `u_roof`/`u_wall` unit tests live. S0380.210 shipped a +broken `test_u_wall_cavity_..._filled_cavity_row` there unnoticed; S0380.211 corrected +it (→ `..._as_built_row`). **When touching `rdsap_uvalues.py`, also run +`domain/sap10_ml/tests/`.** Two PRE-EXISTING failures live there (stone §5.6 thin-wall +formula 3.7408 vs Table-6 1.7 cap, granite + sandstone band A) — they fail at HEAD +`58ff7d88` too, unrelated to this branch. + +--- + +## THREAD 2 — Community fuel-code collision (cert 9390) — **CO2/PE FIXED (S0380.212); cost +4 open** + +Cert **9390-2722-3520** (community mains-gas boiler, `sap_main_heating_code=301`, +`main_fuel_type=20`). Authoritative: `datatypes/epc/domain/epc_codes.csv` +(RdSAP-Schema-17.0) `main_fuel,20,mains gas (community)`. + +**Root cause (the collision):** the EPC `main_fuel_type` enum and the SAP Table 12 / +Table 32 numbering overlap in **18–25** — EPC 20='mains gas (community)' but Table-12 +code 20 is solid biomass (CO2 0.028). `co2_factor_kg_per_kwh`/`primary_energy_factor`/ +`unit_price_p_per_kwh` check the Table-12 dict FIRST, so the EPC community fuel got the +biomass factor instead of translating 20→51 (community mains gas: CO2 0.210, PE 1.130). + +**S0380.212 fix:** new `_heat_network_factor_fuel_code(main)` translates the EPC community +fuel → Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for heat-network mains +(`_is_heat_network_main`) so a genuine biomass boiler keeps its raw factor. Applied at +**five** sites — space-heating CO2/PE/unit-price + water-heating (WHC 901) CO2/PE (9390's +HW is ALSO community gas, so both paths needed it). Worksheet-validated by **case 14** +(community boilers + mains gas, code 301): `(367)` CO2 0.2100, `(467)` PE 1.1300 = the +Table-12 code-51 values. 9390 CO2 **0.44 → 3.03 t** (lodged 2.8 — spec-correct factor over +the API-only register residual; 9390 is unpinned, retired P2.2 per ADR-0010 §10), PE +**204 → 220** (the prior 204≈205 was the collision coinciding with the register residual). +Summary path uses code 1 (no collision) → CH1-6 corpus untouched. Locked by 2 unit tests +in `test_cert_to_inputs.py`. + +**Cost +4 — FIXED (S0380.213), via the standing charge (NOT cost scaling).** The earlier +"missing 1/heat_source_eff cost scaling" hypothesis was WRONG: case 14's 10b block shows +the heat price (`(340)`/`(307)` = 4.24 p/kWh) is applied to delivered heat, NOT scaled — +and Table-32 code 51 already = 4.24 p/kWh (the price collision was harmless, 4.23≈4.24). +The real gap was the **£120 heat-network standing charge** (SAP 10.2 Table 12 note (l) + +§C3.2; case 14 `(351)` = £120): the API community fuel (20) isn't a Table-32 gas code so +`_is_gas_code` returned False → £0 standing (the Summary path masks it via code 1). New +`_heat_network_standing_charge_gbp` REPLACES the fuel standing for heat-network mains +(£120 full / £60 DHW-only) — not additive, so the CH corpus (already £120 via the gas +branch) isn't double-counted to £240. 9390 SAP +4 → **-2**. + +**STILL OPEN — 9390 ~7% demand over-count (SAP -2):** the standing fix EXPOSED it — PE 220 +vs lodged 205, CO2 3.03 vs 2.8 all run ~7% high. Likely the heat-source-efficiency default +(`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY[301]=0.80`) being too low for 9390's actual scheme, +or a fabric/demand difference. 9390 is API-only (no worksheet) + unpinned, so this is a +low-priority residual; needs a 9390-specific efficiency/fabric investigation. + +--- + +## THREAD 3 — Cert 0390 +7 — **CLOSED (S0380.210)** + +**0390-2954-3640** (detached, TFA 360, age F). The boiler was correctly resolved +(PCDB 9005 Firebird S, 86.4% winter); the gap was a single fabric mis-route. Walking +the §3 cascade localised it to the Main **cavity wall**: lodged `wall_construction=4`, +`wall_insulation_type=4` (as-built/assumed), description "Cavity wall, as built, +**partial insulation** (assumed)". The cascade routed it to the Table 6 **"Filled +cavity"** row (band F = 0.40) because `_described_as_insulated` matches the "partial +insulation" substring. Per **RdSAP 10 Table 6 (England)** an as-built partial-fill +cavity uses **"Cavity as built"** (band F = **1.0**), not filled — a genuine fill +lodges the distinct "Cavity wall, filled cavity" (`wall_insulation_type=2`). This +mirrors the worksheet-validated solid-brick rule from S0380.209 (cases 9/10). + +Fix: new `_cavity_described_as_filled` predicate, used **only** in u_wall's cavity +filled-row branch, excludes "partial insulation" while keeping "insulated (assumed)" +→ filled. Wall HLC +53.6 W/K lifted all four metrics together: **SAP +7 → +0**, +PE −27.9745 → +0.5281, CO2 −2.7134 → −0.1189. Bands I-M coincide († footnote) so +0535(M)/7536(L) are unaffected. Re-pinned in `test_golden_fixtures.py`. + +**Diagnosis lesson / latent follow-up:** the fix collided with an existing test, +`test_cavity_as_built_insulated_assumed_uses_filled_cavity_row` (heat_transmission +tests). That test (from early "slice S-B25") asserts a cavity **"insulated (assumed)"** +→ filled row, citing only an *assumption* ("the assessor has determined the cavity is +filled"), **never worksheet-validated** — and it is the OPPOSITE conclusion from the +worksheet-backed solid-brick sibling. The narrow S0380.210 fix leaves it untouched +(no current cert exercises it at a band where as-built ≠ filled). **Open question for a +future worksheet:** does a cavity lodged "as built, insulated (assumed)" (type 4) +belong on the filled row (0.7 at E) or the as-built row (1.5 at E)? If a worksheet +says as-built, fold "insulated (assumed)" into the as-built routing too and retire +that test. + +--- + +## Full audit — all golden certs with non-zero SAP residual + +| cert | SAP resid | diagnosis | +|---|---|---| +| 0390-2954-3640 | ~~+7~~ **+0** | **CLOSED S0380.210** — cavity partial-insulation → as-built row | +| 9390-2722-3520 | −2 (unpinned) | **CO2/PE collision FIXED S0380.212** + **standing charge S0380.213** (SAP +4→−2); remaining ~7% demand over-count (heat-source-eff default?) | +| 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72. Roof PE-pin tightened by **S0380.211** (PE +5.50 → +1.52) | +| 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value | +| 7536-3827-0600 | +1 | minor fabric precision (multi-bp D/L/F cavity); low value | + +All others pin at residual 0. + +--- + +## Worksheets + +- **Available** (user-generated, `sap worksheets/golden fixture debugging/simulated case N/`): + case 7 (combi), case 8 (unknown pump), case 9 (sandstone wall + sloping roof), + case 10 (solid-brick wall), **case 11** (001431 sloping-ceiling Unknown roof — used to + scope Thread 1; the cohort `ND` certs were the real arbiter), **case 12** (community + CHP **coal**, code 302), **case 13** (community CHP **mains gas**, code 302). Case 7's + Summary is the only one mirrored into tracked fixtures + (`backend/.../Summary_001431_case7.pdf`, used by the e2e pin). +- **Still needed (Thread 2):** a **code-301** (community boilers, NOT CHP) + **mains gas** + worksheet to pin 9390's exact PE/CO2/cost. case 13 is code-302 CHP-gas: it confirms the + community-gas direction (heat-network `(386)` CO2 0.2456) but the CHP heat-power split + differs from 9390's boiler scheme. **API `main_fuel_type=20` = community/district + heating from mains gas → SAP Table 12 code 51** (CO2 0.210, PEF 1.130). + +## Pointers +- Golden pins + slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py`. +- Wall fix: `domain/sap10_calculator/worksheet/heat_transmission.py` + (`_described_as_retrofit_insulated`, the `wall_ins_present` gate) + + `tests/.../worksheet/test_heat_transmission.py`. +- Roof code: `domain/sap10_ml/rdsap_uvalues.py` `u_roof` (L708 + Table 18 dicts L637-668). +- Community/heat-network CO2/PE/cost: `cert_to_inputs.py` ~L2749-2880, L1837 + (`_main_fuel_code`), `tables/table_12.py` + `table_32.py` (`co2_factor_kg_per_kwh`, + `API_FUEL_TO_TABLE_12/32`). +- Process: one slice = one commit, spec citation (page+line), AAA tests, `abs(x-y)<=tol` + not `pytest.approx`, `Co-Authored-By: Claude Opus 4.8 `. + SAP 10.2 only. No tolerance widening. mapper.py + cert_to_inputs.py each carry 32 + pre-existing pyright errors; heat_transmission.py + rdsap_uvalues.py carry their own — + baseline-compare with `git stash` for net-zero NEW. diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md new file mode 100644 index 00000000..a5f6945c --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md @@ -0,0 +1,147 @@ +# Handover — post S0380.195 (gas-combi site-notes + RR/floor bugs; 6035 OPEN) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +accuracy bar, and pipeline — this records *what this session did* and *what is open*. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `4a21717d` (S0380.195) +- **Baseline:** `2341 passed, 1 skipped, 0 failed`. Verify with the §4 suite command. + +--- + +## What this session shipped (S0380.190–195 + 1 extractor fix) + +| Slice | What | Spec | +|---|---|---| +| **.190** | Gas-combi site-notes `main_fuel_type` derivation. Newer Elmhurst export lodges §14.0 Fuel Type EMPTY + SAP code 104 → `MissingMainFuelType` blocked ALL gas-combi Summary certs. Derive carrier from §15.0 Water Heating Fuel Type for Table 4b gas-boiler codes 101–119 (NOT "104→mains gas" — Table 4b gas codes span mains gas/LPG/biogas; §15.0 disambiguates). `_elmhurst_gas_boiler_main_fuel`. | SAP 10.2 Table 4b p.168 | +| extractor fix | Windows-table header remnant ("value value Proofed Shutters") leaked into the FIRST window's `glazing_type` (layout `before_start=0` reaches the wrapped header). Trim prefix to the glazing-start word. | — | +| **.191** | Promoted sim case 1 (single-part gas combi) to e2e harness `001431`, 11 pins @1e-4. Resolved the handover's "+0.0007 SAP" as a display-rounded-ECF target artifact. | — | +| **.192** | **Simplified room-in-roof bug.** A Simplified RR (assessment "Simplified") lodges PLACEHOLDER slope/ceiling L×H (40 m ceiling height, 32 m slope). Spec derives one timber-framed remaining area `A_RR = 12.5√(A_floor/1.5) − Σgables`. The cascade already computes this (`has_roof_lodgement` gate in heat_transmission.py) but `_map_elmhurst_rir_surface` emitted the placeholder slope/ceiling → 1024+160 m² roof → **7.5× heat-loss explosion (SAP −14.6)**. Fix: drop roof-going surfaces for Simplified. API path (6035) already correct via scalar gable fields. | RdSAP 10 §3.9.1 p.21 | +| **.193** | **Suspended-floor (12) sealed rule.** Rule (a) "floor U<0.5 → sealed 0.1" applies only when a U-value is SUPPLIED; an as-built/default U falls to (b)→unsealed 0.2. Cascade fed the computed default U into (a) → wrongly sealed → ~450 kWh space-heat understatement. Fix: gate (a) on `floor_u_value_known`. | RdSAP 10 §5 p.29 | +| **.194** | Sim case 3 (8 windows, symmetric HLP) → e2e `001431_rr8`, 11 pins @1e-4. | — | +| **.195** | Sim case 4 (6035 floor geometry: Main ground HLP 15.99 + first 8.32) → e2e `001431_6035`, 11 pins @1e-4. | — | + +Net: 4 new Elmhurst-only e2e fixtures (cases 1–4 of cert 001431), all @1e-4. The +worksheet Summaries are mirrored into `backend/documents_parser/tests/fixtures/` +(`Summary_001431_gas_combi.pdf`, `_rr_ext`, `_rr8w`, `_6035`); source Summary + +P960 worksheet tracked under `sap worksheets/golden fixture debugging/simulated +case {1..4}/`. + +**The .195 commit message and the `_elmhurst_worksheet_001431_6035.py` docstring +claim 6035's +19 PE is "lodged divergence." THAT CLAIM IS RETRACTED — see below.** + +--- + +## OPEN (the priority) — golden cert 6035 residual is REAL, not divergence + +`tests/.../test_golden_fixtures.py` pins cert `6035-7729-2309-0879-2296`: +`actual_sap=70, expected_sap_resid=-2, expected_pe_resid=+19.16, +expected_co2_resid=+0.42 t`. **All three exceed the ±0.5 SAP / small-CO2 fallback +bar.** A −2 SAP is not rounding. 6035 was lodged **2025-11-11** under +RdSAP-Schema-21.0.1 / SAP 10.2 (software `5.02r0328`) — the SAME methodology we +target, so it is NOT a version artifact. + +### What we know +- **The cascade reproduces Elmhurst's WORKSHEET engine** for this archetype: sim + cases 1–4 (Main+Ext+RR+suspended-floor+gas-combi-104) all pin @1e-4 on all 11 + Block-1 line refs. +- **Case 4 ≈ 6035.** Identical: 2 BPs, age A, solid-brick walls (Main ins, Ext + as-built), RR floor 29.75, floor areas, **Main floors HLP 15.99/8.32**, doors, + heating (104/control 2106), 8 windows by **area + BP + 7/8 orientations**. + Remaining input diffs case4-vs-6035: + 1. the **3.82 m² window**: North in case 4, **South** in 6035 (only window diff); + 2. **lighting bulbs**: case 4 cascade lighting 262 vs 6035's **364** (6035 lodges + 9 low-energy + 2 incandescent; case 4's Summary lighting parsed as None); + 3. **meter type** "Dual" (case4) vs API **2** (6035); + 4. 6035 lodges `cylinder_size=1` (case 4 none) — appears immaterial (HW matches). +- **Controlled test:** flipping case 4's 3.82 window N→S raises SAP only **+0.25** + (68.19→68.44). Nowhere near +2. So orientation does NOT explain the gap. +- **The energy/demand model looks ~right per-end-use.** Cascade DEMAND + (postcode) costs ≈ 6035's lodged costs: heating £1278 vs lodged £1285, HW £225 + vs £217, lighting £103 vs £103. So the −2 SAP lives in the **RATING block** + (UK-avg): cascade rating cost 948.59 → ECF 2.31 → SAP 67.81; register implies + cost ~£886 / ECF 2.15 / SAP 70. **Plus the CO2 (+0.42 t) is unexplained.** +- Neither bug fixed this session touches 6035 (its RR uses the API scalar-field + path, already correct; its floor U=0.63 ≥ 0.5 was already "unsealed"). + +### The contradiction to resolve +Elmhurst-worksheet-for-case4-inputs = **68**, 6035-register = **70**, same +methodology, inputs nearly identical, and the known diffs explain only ~+0.25. +Either (a) 6035's register was produced from inputs materially different from the +golden JSON in a rating-relevant way we can't see, or (b) there's a real cascade +bug only 6035's exact combination triggers (the simulated cases didn't hit it). + +### ★ BREAKTHROUGH LEAD (end of session) — API-mapper roof/RR over-count +The user's hypothesis ("something missing from the API mapper") is CONFIRMED. +Diffing **6035 (API path) vs case 4 (site-notes path)** at the SECTION level +(`heat_transmission_section_from_cert`) — with near-identical fabric — exposes a +cross-mapper parity break that should not exist: + +| §3 line | case4 (site-notes) | 6035 (API) | Δ | +|---|---|---|---| +| **roof W/K** | **78.33** | **130.73** | **+52.39** | +| party W/K | 36.86 | 0.00 | −36.86 | +| (33) fabric heat loss | 290.72 | 304.66 | +13.94 | +| (31) total ext area | 231.02 | 242.74 | +11.72 | +| walls / floor / windows / doors | — | — | ≈0 | + +**The roof +52 W/K is the prime suspect for the whole 6035 residual** (52 W/K of +spurious heat loss ≈ the −2 SAP / +19 PE / +0.42 t CO2). Root cause is the RR/roof +representation feeding two DIFFERENT cascade paths: +- **case 4 (site-notes):** `sap_room_in_roof.detailed_surfaces=[gable_wall_external, + gable_wall]`, scalar gable lengths = None, `roof_construction=None` → cascade's + Detailed-loop residual path (`12.5√(A_floor/1.5) − Σwalls`) → roof 78.33. ✓ + (pins to case-4 worksheet @1e-4). +- **6035 (API):** `detailed_surfaces=None`, scalar `gable_1/2_length_m=4.65`, + **`roof_construction=4`** → cascade's SCALAR RR path (heat_transmission.py + ~363-460 + ~853-875) AND a separate `roof_construction=4` main-roof element → + roof 130.73. Likely DOUBLE-COUNTS the main roof over the full footprint with the + RR, or the scalar A_RR path over-states the area. + +Hand-check: for 6035 the correct roof ≈ RR remaining (12.5√(29.75/1.5) − 2×11.39 += 32.88 × 2.30 = 75.6) + main-loft residual (41.73−29.75=11.98 × 0.14 = 1.68) + +ext roof (7.21 × 0.14 = 1.01) ≈ **78.3** (matches case 4). The API path's 130.73 is +~52 too high. + +**START HERE:** instrument the API RR/roof path for 6035. Compare +`_api_build_room_in_roof` (mapper.py ~2713) output + `roof_construction=4` +handling vs the site-notes detailed_surfaces path. Find where the extra ~52 W/K +roof comes from (main-roof-area double count with RR, or scalar A_RR over-state). +Fix so the API path matches the site-notes path (cross-mapper parity), then re-pin +6035's golden residual (should collapse toward 0). The party=0 (party_wall_ +construction=3) is secondary — verify 3=solid U=0 is correct first. + +This is a CALCULATOR/MAPPER bug, not lodged divergence — the byte-exact-worksheet +plan below is now a fallback only. + +### Fallback — byte-exact 6035 worksheet ("simulated case 5") +Ask the user to generate case 5 = case 4 with EVERY remaining input matched to +6035: **3.82 m² window → South**, **lighting = 9 low-energy + 2 incandescent**, +**meter type matched**, **cylinder matched**. Then: +- If the worksheet SAP = **70** → real cascade bug. Diff cascade vs worksheet + line-by-line (start §6 solar gains (74)–(83) for the south window, §8 lighting + (232)/Appendix L, then §10a/§12 rating cost/ECF and §12/§13 CO2). +- If the worksheet SAP = **68** → the register's 70 is the anomaly (lodged from + different inputs); 6035 becomes a documented register-vs-worksheet divergence. + +Parallel angle worth a look NOW (no new worksheet needed): the **lighting energy** +(cascade 364 for 9 LE + 2 inc, TFA 128) — verify against SAP 10.2 Appendix L; and +the **CO2 (+0.42 t)** decomposition by carrier (the demand-cost match suggests the +energy is right, so a CO2-FACTOR or rating-block issue is implicated). + +--- + +## Carry-over (lower priority, from the prior handover) +- `transform.py:973` treats `wall_construction in (5,6)` as timber-frame for the + ventilation structural-ACH split, but 6 = system-built (masonry); only 5/7/8 are + timber/cob/park. Possible latent ventilation-ACH bug — verify before touching. +- Summary-path `main_fuel_type` for non-gas/non-104 boilers (only 101–119 + the + existing liquid/solid/electric/community branches are covered). + +## Process notes +- One slice = one commit, spec citation in the message, `Co-Authored-By: Claude + Opus 4.8` trailer. AAA tests, `abs(x-y) <= tol` (not `pytest.approx`). +- The 4 sim-case e2e fixtures pin Block 1 (UK-avg rating) via + `Sap10Calculator().calculate(epc)` — NOT the postcode demand block. +- Window ORIENTATION does NOT change the SAP rating much (+0.25 for 3.82 m²) — do + not over-attribute the 6035 gap to it. diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md new file mode 100644 index 00000000..2ab9570d --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md @@ -0,0 +1,186 @@ +# Handover — post S0380.200 (dual-main split done; boiler-interlock −5pp OPEN) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +accuracy bar, and pipeline — this records *what this session did* and *what is open*. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `8ae978a6` (S0380.200) +- **Baseline:** `2355 passed, 1 skipped, 0 failed`. Verify with the AGENT_GUIDE §4 suite command. + +--- + +## What this session shipped (S0380.196–200) + +The through-line: **golden certs 6035 and 0240 were both closed to SAP-exact** +by finding real API-mapper bugs (not "lodged divergence"), each confirmed +against a user-generated Elmhurst worksheet ("simulated case 5/6"). + +| Slice | What | Spec | +|---|---|---| +| **.196** | API Simplified Type 1 room-in-roof: `room_in_roof_type_1` gables (length-only, no height) weren't deducted from the A_RR shell → whole shell billed as roof at U_RR=2.30 (+52 W/K). Route them through `detailed_surfaces` (gable area = L × 2.45 default RR storey height). **6035 SAP −2→+0 exact**, PE +19.16→+1.84. | RdSAP 10 §3.9.1(e) p.21; Table 4 p.22 | +| **.197** | Promoted "simulated case 5" (detached sandstone RR) to e2e fixture (`001431_case5`, 11 pins @1e-4). Fixed sandstone wall label `"SS"`→2 (`_ELMHURST_WALL_CODE_TO_SAP10`) + `_parse_thickness_mm` for "400+ mm" roof insulation (trailing `+` was dropped → u_roof fell to age default). | — | +| **.198** | **API `window_wall_type=4` → roof window.** These are roof-of-room rooflights; the mapper flattened them into `sap_windows` (vertical, (27), U=2.0) instead of `sap_roof_windows` ((27a), inclined U=2.30 + 45° solar). The inclined solar dominates → **0240 SAP −1→+0 exact**, PE +3.91→+1.95; 6035 PE +1.84→+1.37. Discriminator is `wall_type==4` NOT `window_type==2` (0390/7536 lodge window_type=2 on main walls). | SAP 10.2 §3 (27a); Table 6e Note 2 | +| **.199** | Site-notes mirror of .198: extractor parses "Roof of Room" window rows (`_parse_window_from_anchors`); `_is_elmhurst_roof_window` location branch; `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double between 2002 and 2021"]=2.30`. Case 6 pinned on §3 windows (`test_section_3_roof_windows_case6_match_pdf`): (27)=22.7408, (27a)=13.0375 exact. | RdSAP 10 §3.7 | +| **.200** | **SAP 10.2 §9a two-main-heating split** (203)/(204)/(205)/(207)/(213). The cascade lumped a 2-main dwelling into one system. Now `space_heating_fuel_monthly_kwh` splits demand (204) to sys1 @ (206) + (205) to sys2 @ (207); `_solve_month` sums main_1+main_2; `_main_heating_detail_efficiency` (new, the per-detail core of `_main_heating_efficiency`) gives each system its own efficiency. Site-notes: `_map_elmhurst_main_heating_2` inherits Main 1's fuel when §14.1 omits Fuel Type. Cost/CO2/PE main_2 paths were already wired. 0240 unchanged (identical Eq-D1 systems). | SAP 10.2 §9a | + +Two new e2e fixtures: `001431_case5` (full SapResult, S0380.197) and +`001431_case6` (§3 windows only, S0380.199 — see why below). Source PDFs +tracked under `sap worksheets/golden fixture debugging/simulated case {5,6}/`; +Summaries mirrored to `backend/documents_parser/tests/fixtures/Summary_001431_case{5,6}.pdf`. + +--- + +## ⚠️ CORRECTION (post S0380.201) — the interlock priority was ALREADY DONE + +The "priority" below was **misdiagnosed**. At HEAD the cascade already +produces case 6 (206) sys-1 eff = **79.0** and (207) sys-2 eff = **84.0**, +matching the worksheet exactly. The cylinder-thermostat interlock path +(`no_stored_hw_interlock = has_cylinder and cylinder_thermostat != "Y"`) +has existed since **S0380.141**; the room-thermostat path since S0380.177. +`no_interlock = no_room_thermostat OR no_stored_hw_interlock` — it does NOT +only catch 2101/2102. Toggling case 6 `cylinder_thermostat` N→Y flips eff +0.79→0.84, confirming the −5pp fires. Golden **0240 is a combi** +(`has_hot_water_cylinder=False`) → correctly NOT penalised; its predicted +re-pin from the interlock is void. The misread came from +`energy_requirements_section_from_cert` (a §2.4 debug helper using raw +`_main_heating_efficiency`, which reports 84 — the real `cert_to_inputs` +cascade applies the −5pp at ~line 6071). See [[feedback-verify-handover-claims]]. + +**S0380.201 landed the SECONDARY item** (dual-system aux pumps): SAP 10.2 +Table 4f note c) second main-system circulation pump, gated on a lodged +`main_heating_fraction > 0`. Case 6 (231) 241 → **356** EXACT (= 41 Main-1 +pump + 115 Main-2 pump + 200 oil aux). 0240 re-pinned (pumps 315 → 430, +integer 73 → 72, resid +0 → -1, PE +2.8092, CO2 +0.1385) — anticipated +and authorised below. 000565 protected by the fraction>0 gate (its Main 2 +is a DHW-only combi, fraction 0). + +**Remaining case-6 gaps for full-SapResult promotion** (vs P960-0001-001431): +- (98c) space demand cascade **12145.31** vs ws **11991.96** (+1.28%) — + living-area MIT (87) ~0.3 °C low in winter; multi-causal (gains/heat-loss). +- (219) hot water cascade **4824.74** vs ws **4902.86** (−1.6%) — §4 walk needed. +Once both close, promote case 6 to a full SapResult e2e fixture (pin grid below). + +--- + +## OPEN (was the priority, now DONE) — boiler-interlock −5pp efficiency adjustment, per main system + +**Goal:** a RdSAP-10/SAP-10.2 **spec-accurate** implementation of the boiler +interlock efficiency adjustment, applied **per main heating system**, done in +the established pattern of this domain (per-line walk → cite spec → TDD → +re-pin). This is the last gap blocking full closure of simulated **case 6**, +and it will also re-pin golden **0240**. + +### The evidence (simulated case 6) + +`sap worksheets/golden fixture debugging/simulated case 6/` — detached, dual +**oil** boiler (both SAP code **127**, base seasonal eff **84%**), radiators 51% +(control **2106**) + underfloor 49% (control **2110**). Its P960 worksheet: + +| line | worksheet | meaning | +|---|---|---| +| (206) main sys-1 eff | **79.0** | 84 − **5pp** | +| (207) main sys-2 eff | **84.0** | base, no penalty | +| (216) water-heater eff | **72.0** | also penalised (DHW leg of the −5pp) | +| "Temperature adjustment" | 0.0000 | **flow temp has NO effect** — this is NOT a flow-temperature feature | + +Summary §14 lodges it explicitly: system 1 **"Boiler Interlock: No"**, system 2 +**"Boiler Interlock: Yes"**. The 84→79 is the SAP 10.2 **Table 4c(2)** "no boiler +interlock" −5pp **Space + DHW** adjustment (same mechanism as the AGENT_GUIDE +"oil 6" worked example, S0380.177 — but that one fired off control 2101). + +### Why control 2106 (which HAS a room thermostat) is "no interlock" + +Per RdSAP 10 boiler-interlock rules (find + cite the exact §; the existing +`_NO_INTERLOCK_CONTROLS = {2101, 2102}` block in `cert_to_inputs.py` ~line 1238 +quotes "RdSAP 10 §3 p.57: boiler interlock is assumed present if there is a room +thermostat and [time control], AND — when there is a hot-water cylinder — a +cylinder thermostat; otherwise not interlocked"): system 1 serves the **DHW +cylinder**, the cylinder is present (`Hot Water Cylinder Present: Yes`) but +**`Cylinder Thermostat: No`** → interlock **not** present → −5pp, *despite* the +room thermostat. System 2 (underfloor, separate part, no cylinder interaction) +keeps interlock via its zone control → no penalty. + +So the determination is **not** "control ∈ {2101,2102}". It is, per system: +`interlock present` ⇔ (room thermostat present, from the control code) AND +(time/programmer control) AND (cylinder absent OR cylinder thermostat present). +The current cascade only catches the "no room thermostat" path (2101/2102); it +misses the "room thermostat present but no cylinder thermostat" path that 2106 +hits here. + +### This single root cause explains BOTH remaining case-6 deltas + +- space heating: sys-1 eff 79 not 84 → main fuel cascade 14925 vs ws **14736.96** +- hot water: the −5pp DHW leg → cascade HW 4824 vs ws **4902.86** (lower cascade + fuel ⇒ cascade eff too high ⇒ missing the penalty) + +### 0240 will shift — and that is correct (apply the spec uniformly) + +Golden **0240** has the SAME controls (sys1 2106 / sys2 2110) AND the same +`cylinder_thermostat = "N"` with a cylinder present. So the spec-correct rule +applies the −5pp to 0240's system 1 too. 0240 is currently SAP-exact (continuous +72.55) **without** the penalty — that is an offsetting coincidence (it's API-only, +±0.5 bar, no worksheet). Per [[feedback-software-no-special-handling]] + +[[feedback-spec-floor-skepticism]]: implement the spec rule, let 0240 shift, and +**re-pin** it with a documented note. Expect 0240 continuous SAP to drop ~0.3–0.5 +(may take the integer 73→72; if so the golden `expected_sap_resid` moves −1 and +that is the new truth). Measure precisely and re-pin PE/CO2 too. + +### Where to implement (per-line walk first, then TDD) + +1. **Interlock determination.** Add a per-`MainHeatingDetail` helper, e.g. + `_boiler_interlock_present(main, epc) -> bool`, encoding the RdSAP 10 rule + above (room thermostat from control code + cylinder-thermostat gate when a + cylinder is present). `epc.sap_heating.cylinder_thermostat` ("Y"/"N") and + `cylinder_size`/`hot_water_cylinder_present` are the cylinder signals. The + site-notes path already lodges `cylinder_thermostat` (mapper.py ~5183, string + "Y"/"N"); the API path lodges it on `sap_heating.cylinder_thermostat` (0240 = + "N"). +2. **Apply Table 4c(2) −5pp per system.** The existing −5pp lives near the + `_NO_INTERLOCK_CONTROLS` block — find how it currently adjusts the seasonal + efficiency for 2101/2102 and generalise it to fire on + `not _boiler_interlock_present(main, epc)`, applied inside + `_main_heating_detail_efficiency` so **each** main system gets its own + adjustment (sys1 −5, sys2 0). Confirm the DHW leg (water-heater efficiency + (216)) is penalised too — the §4 water-heating cascade reads + `_main_heating_efficiency`; verify the −5pp flows there (case 6 (216)=72 + is the check). +3. **Verify combi vs regular rows of Table 4c(2).** The "no interlock" −5pp has a + combi row (Space −5 / DHW 0) and a regular-boiler row (Space −5 / DHW −5); + the DHW leg is gated on cylinder presence. Case 6 is a regular oil boiler with + a cylinder → DHW −5 applies (hence (216)=72). Read the table; don't assume. + +### Validation target + +After the fix, **promote case 6 to a full SapResult e2e fixture** (it's currently +§3-windows-only because the lumped efficiency made (211)/(219)/(231) non- +comparable). Case 6 worksheet Block-1 pin grid (P960-0001-001431): +- SAP 72 (258), continuous **71.6597**, ECF **2.0316** (257) +- total fuel cost **1162.5374** (255), CO2 **5953.6679** (272) +- (211) main sys-1 fuel **7741.6458**, (213) main sys-2 fuel **6995.3106** + (SapResult.main_heating_fuel_kwh_per_yr should be the sum **14736.9564**) +- hot water **4902.8601** (219), lighting **357.6571** (232) +- pumps/fans **356.0** (231) — **see the SECOND open item below** + +### SECONDARY open item — dual-system auxiliary pumps (Table 4f) + +Case 6 (231) = **356** = (230c) central-heating pump 156 + (230d) oil-boiler pump +200. Cascade gives **241**. Two boilers → two pump contributions per Table 4f +(note c: "where there are two main heating systems include two figures from this +table" — same note already used for the 0240 oil-pump in S0380.148). Needs the +per-system pump aggregation. Smaller than the interlock fix; do it after, then +case 6's (231) pin closes and the full e2e fixture lands. + +--- + +## Process notes +- One slice = one commit, spec citation (page + line) in the message, + `Co-Authored-By: Claude Opus 4.8 ` trailer. +- AAA tests (`# Arrange/# Act/# Assert`), `abs(x-y) <= tol` (not `pytest.approx`). +- New code passes `pyright` strict, 0 errors. (mapper.py + cert_to_inputs.py each + carry **32 pre-existing** errors — don't add to them; check with a `git stash` + baseline comparison.) +- The Elmhurst worksheet is ground truth at abs=1e-4. 0240 is API-only (±0.5 + fallback) — case 6 is its worksheet-backed proxy for the heating archetype, but + differs from 0240 on the boiler SAP code (127 vs 0240's 130 condensing combi), + so pin case 6 to ITS OWN worksheet, not 0240's register. +- Suite command + section/e2e harness layout: AGENT_GUIDE §2.6 + §4. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 48e22bd5..433f3270 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -659,6 +659,19 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909}) # zero-loss list, so primary loss is zero whenever this code is lodged. _WHC_ELECTRIC_IMMERSION: Final[int] = 903 +# Water-heating codes for a dedicated "boiler/circulator for water +# heating only" — SAP 10.2 Table 4a hot-water section (PDF p.166): +# 911 gas, 912 liquid fuel, 913 solid fuel boiler/circulator; 921-931 +# range cooker with boiler for water heating only. Each is a heat +# generator feeding the cylinder through a primary loop, so SAP 10.2 +# Table 3 (PDF p.160) row 1 primary circuit loss applies — independent +# of the space-heating system (which for these certs is a separate main, +# e.g. electric storage heaters). 941 (electric HP for water only) is +# excluded: HP DHW vessels follow the Table 3 integral-vessel rules. +_WATER_HEATING_BOILER_CIRCULATOR_CODES: Final[frozenset[int]] = frozenset( + {911, 912, 913} | set(range(921, 932)) +) + # SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed # per array. The module efficiency constant (0.8), orientation-dependent @@ -883,6 +896,12 @@ _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = { 304: 3.00, } +# SAP 10.2 Table 12 (PDF p.191) "Heat networks" standing charge row = +# £120/yr (note (k)). Note (l): "Include half this value if only DHW is +# provided by a heat network." §C3.2 (PDF p.58): the full charge applies +# when the space heating is also on the heat network. +_HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0 + def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: """True when the cert's main heating is a heat network — either by @@ -1479,6 +1498,35 @@ def _water_heating_main( return details[0] +def _water_heating_main_space_fraction( + epc: EpcPropertyData, secondary_fraction: float +) -> float: + """Fraction of TOTAL space heating provided by the DHW boiler — the + SAP 10.2 Appendix D §D2.1(2) Equation D1 Q_space weight. + + Eq D1's monthly water-heater efficiency blends η_winter / η_summer by + the ratio of the boiler's space-heating load to its water load. On a + single-main / WHC-901 cert that load is the whole main share, + (202) = 1 − (201). On a dual-main cert the DHW boiler does ONLY its + own share — (204) for Main 1, (205) for Main 2 — so feeding it the + dwelling total over-weights η_winter and under-states HW fuel + (simulated case 6: Main 1 serves DHW + 51% of space heat; using 100% + of demand gave HW −78 kWh vs the worksheet).""" + details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + main_fraction = 1.0 - secondary_fraction # (202) + if len(details) < 2: + return main_fraction + main_2 = details[1] + main_2_of_main = ( + main_2.main_heating_fraction / 100.0 + if main_2.main_heating_fraction is not None + else 0.0 + ) + if _water_heating_main(epc) is details[1]: + return main_fraction * main_2_of_main # (205) — DHW from Main 2 + return main_fraction * (1.0 - main_2_of_main) # (204) — DHW from Main 1 + + def _rdsap_tariff(epc: EpcPropertyData) -> Tariff: """Resolve the cert's Table 12a tariff column via RdSAP 10 §12 Rules 1-4 (page 62). Consults BOTH main heating systems — §12 @@ -1563,6 +1611,35 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: ) +def _heat_network_standing_charge_gbp( + epc: EpcPropertyData, main: Optional[MainHeatingDetail] +) -> Optional[float]: + """SAP 10.2 Table 12 note (l) + §C3.2 heat-network standing charge, or + None when the dwelling is not on a heat network (caller then falls back + to the fuel-based `additional_standing_charges_gbp`). + + A heat network carries the Table 12 £120/yr standing charge regardless + of the network fuel — full when the SPACE heating is on the network + (§C3.2 "the total standing charge is the normal heat network standing + charge"), halved to £60 when ONLY DHW is provided by the heat network + (note (l)). This REPLACES the fuel-based gas/off-peak standing for a + heat-network main, so it must not be added on top of + `additional_standing_charges_gbp` (which would double-count: a + Summary-path community-gas main lodges Table-32 code 1 and already + draws the £120 gas standing). Worksheet-validated: simulated case 14 + (community boilers + mains gas, space + water) → (351) = £120. + + The API path under-counted this: an EPC community fuel (e.g. 20 = mains + gas community) is not a Table-32 gas code, so `_is_gas_code` returned + False and the standing came out £0 — cert 9390 lost the whole £120. + """ + if _is_heat_network_main(main): + return _HEAT_NETWORK_STANDING_CHARGE_GBP + if _is_community_heating_hw_from_main(epc): + return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0 + return None + + def _main_heating_efficiency(epc: EpcPropertyData) -> float: """SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction. @@ -1570,7 +1647,16 @@ def _main_heating_efficiency(epc: EpcPropertyData) -> float: seasonal efficiency → heat-network 1/DLF override. Used by §4 (water heating cascade) and §9a (per-system fuel kWh) — both must see the same value, so this single helper is the single source of truth.""" - main = _first_main_heating(epc) + return _main_heating_detail_efficiency(_first_main_heating(epc), epc) + + +def _main_heating_detail_efficiency( + main: Optional[MainHeatingDetail], epc: EpcPropertyData +) -> float: + """SAP 10.2 (206)/(207) efficiency (0..1) for a SPECIFIC main heating + detail — the per-detail core of `_main_heating_efficiency`. Used for + both main system 1 (206) and main system 2 (207) on dual-main certs + (cert 0240 / simulated case 6).""" main_code = main.sap_main_heating_code if main is not None else None main_category = main.main_heating_category if main is not None else None main_fuel = _main_fuel_code(main) @@ -1817,6 +1903,37 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: raise MissingMainFuelType(fuel, main.sap_main_heating_code) +def _heat_network_factor_fuel_code( + main: Optional[MainHeatingDetail], +) -> Optional[int]: + """Fuel code to feed the Table 12 / Table 32 factor lookups, with the + EPC→Table-12 translation applied for heat-network (community) mains. + + The EPC `main_fuel_type` enum and the SAP Table 12 / Table 32 fuel-code + numbering COLLIDE in the 18-25 range: `epc_codes.csv` lists + 20='mains gas (community)', 21='LPG (community)', 22='oil (community)', + ..., whereas Table 12/32 code 20-25 are solid biomass fuels. The factor + lookups (`co2_factor_kg_per_kwh` / `primary_energy_factor` / + `unit_price_p_per_kwh`) check the Table-12/32 dict FIRST, so an EPC + community fuel 20 silently returns the biomass factor (CO2 0.028, PE + 1.046, wood-logs price) instead of community mains gas (CO2 0.210, PE + 1.130, mains-gas price + £120 standing charge). + + Resolution: for a heat-network main, translate the EPC community fuel to + its Table-12 code via `API_FUEL_TO_TABLE_12` (20->51) so the lookups hit + the heat-network row. NON-heat-network mains are returned unchanged so a + genuine biomass boiler (EPC 6 wood logs / 12 biomass, etc.) keeps its raw + Table-12 factor. The Summary path is unaffected — it maps + "Mains gas - community" to code 1 (no collision). Worksheet-validated: + simulated case 14 (community boilers + mains gas, SAP code 301) → + (367) CO2 factor 0.2100, (467) PE factor 1.1300. + """ + fuel = _main_fuel_code(main) + if fuel is None or not _is_heat_network_main(main): + return fuel + return API_FUEL_TO_TABLE_12.get(fuel, fuel) + + def _fuel_cost_gbp_per_kwh( main: Optional[MainHeatingDetail], prices: PriceTable ) -> float: @@ -1844,7 +1961,10 @@ def _fuel_cost_gbp_per_kwh( ) blended_p = chp_frac * chp_price + (1.0 - chp_frac) * boiler_price return blended_p * _PENCE_TO_GBP - return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP + return ( + prices.unit_price_p_per_kwh(_heat_network_factor_fuel_code(main)) + * _PENCE_TO_GBP + ) # RdSAP energy_tariff enum (per datatypes/epc/domain/epc_codes.csv): @@ -2155,6 +2275,35 @@ def _secondary_efficiency( return seasonal_efficiency(code, None, None) +def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float: + """SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric + secondary heater on an off-peak tariff. The secondary is a direct- + acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on + the "Other systems including direct-acting electric" row — high-rate + fraction 1.00 for 7-hour, 0.50 for 10-hour. NOT the 100%-low-rate of + storage-charging: a room heater runs on demand, mostly at the high + rate. Worksheet evidence — simulated case 19 (242): "Space heating - + secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate. + + Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to + a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls + through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_ + heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting + row) fall back to the tariff's Table 32 low rate.""" + tariff = tariff_from_meter_type(meter_type) + if tariff is Tariff.STANDARD: + tariff = Tariff.SEVEN_HOUR + try: + high_frac = space_heating_high_rate_fraction( + Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff, + ) + except NotImplementedError: + return _off_peak_low_rate_gbp_per_kwh(tariff) + high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) + blended = high_frac * high_rate + (1.0 - high_frac) * low_rate + return blended * _PENCE_TO_GBP + + def _secondary_fuel_cost_gbp_per_kwh( sap_heating, main: Optional[MainHeatingDetail], @@ -2170,13 +2319,13 @@ def _secondary_fuel_cost_gbp_per_kwh( # Default to electricity since the default secondary system is # portable electric heaters (code 693). if _is_off_peak_meter(meter_type, fuel_is_electric=True): - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) + return _secondary_off_peak_rate_gbp_per_kwh(meter_type) return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP # When secondary_fuel_type is electricity, apply off-peak if applicable. if _is_electric_water(sec_fuel) and _is_off_peak_meter( meter_type, fuel_is_electric=True ): - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) + return _secondary_off_peak_rate_gbp_per_kwh(meter_type) return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP @@ -2778,7 +2927,7 @@ def _main_heating_co2_factor_kg_per_kwh( ) if monthly is not None: return monthly * scaling - return _co2_factor_kg_per_kwh(main) * scaling + return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_co2_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -2857,7 +3006,7 @@ def _main_heating_primary_factor( ) if monthly is not None: return monthly * scaling - return primary_energy_factor(fuel) * scaling + return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_pe_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -3133,7 +3282,7 @@ def _hot_water_co2_factor_kg_per_kwh( ) if monthly is not None: return monthly * scaling - return _co2_factor_kg_per_kwh(main) * scaling + return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_CO2_KG_PER_KWH @@ -3197,7 +3346,7 @@ def _hot_water_primary_factor( ) if monthly is not None: return monthly * scaling - return primary_energy_factor(_main_fuel_code(main)) * scaling + return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF @@ -3956,11 +4105,25 @@ def energy_requirements_section_from_cert( if secondary_fraction_value > 0.0 else 0.0 ) eff = _main_heating_efficiency(epc) + # SAP 10.2 §9a two-main split (203)/(207): when a second main heating + # system is lodged, (203) = its `main_heating_fraction` (% of main + # heating it supplies) and (207) = its own seasonal efficiency. Cert + # 0240 (2× oil code 130, 51/49) + simulated case 6 (oil code 127, + # rads 51% + underfloor 49%) exercise this. + details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + main_2 = details[1] if len(details) >= 2 else None + main_2_of_main_fraction = 0.0 + main_2_efficiency_value = 0.0 + if main_2 is not None and main_2.main_heating_fraction is not None: + main_2_of_main_fraction = main_2.main_heating_fraction / 100.0 + main_2_efficiency_value = _main_heating_detail_efficiency(main_2, epc) return space_heating_fuel_monthly_kwh( space_heating_monthly_kwh=sh.total_space_heating_monthly_kwh, secondary_heating_fraction=secondary_fraction_value, main_heating_efficiency_pct=eff * 100.0, secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, + main_2_of_main_fraction=main_2_of_main_fraction, + main_2_efficiency_pct=main_2_efficiency_value * 100.0, ) @@ -4103,13 +4266,27 @@ def _has_suspended_timber_floor_per_spec( if age in _AGE_BANDS_F_TO_M: return True, True # sealed if age in _AGE_BANDS_A_TO_E: - # (a) U-value < 0.5 → sealed - main_floor_u = _main_floor_u_value(epc) - if main_floor_u is not None and main_floor_u < _FLOOR_U_SEALED_THRESHOLD: - return True, True - # (b) retro-fitted insulation + no U-value supplied → sealed - ins_type_str = (main.floor_insulation_type_str or "").strip().lower() u_value_known = bool(getattr(main, "floor_u_value_known", False)) + # (a) a SUPPLIED floor U-value < 0.5 → sealed. RdSAP 10 §5 (PDF + # p.29) splits (a)/(b) on whether a U-value is supplied: (a) is + # the "U-value supplied" branch, (b) the "no U-value is supplied" + # branch. A computed default U (an assumed / as-built uninsulated + # floor) is NOT a supplied value, so it must NOT trigger (a) — it + # falls through to (b). Without this gate the cascade marked an + # as-built suspended-timber floor with default U=0.43 "sealed" + # (0.1) where Elmhurst uses "unsealed" (0.2) — cert 001431 sim + # case 2 worksheet (12)=0.2, dropping (25) effective ACH and + # understating space heating ~450 kWh. + main_floor_u = _main_floor_u_value(epc) + if ( + u_value_known + and main_floor_u is not None + and main_floor_u < _FLOOR_U_SEALED_THRESHOLD + ): + return True, True + # (b) no U-value supplied: retro-fitted insulation → sealed; + # otherwise unsealed. + ins_type_str = (main.floor_insulation_type_str or "").strip().lower() if "retro" in ins_type_str and not u_value_known: return True, True # otherwise → unsealed @@ -4447,6 +4624,27 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { # from the ASHP cohort (all 7 certs lodge code 1, worksheet shows # "Foam" → factory-applied per SAP 10.2 Table 2 Note 2). _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 +# RdSAP 10 field 7-11 (cylinder insulation type) — code 2 = loose jacket, +# which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor +# L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4). +_CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2 + + +def _cylinder_storage_loss_insulation_label( + insulation_type: "int | str | None", +) -> Optional[Literal["factory_insulated", "loose_jacket"]]: + """Map the lodged cylinder_insulation_type code to the SAP 10.2 + Table 2 loss-factor branch. Code 1 → factory-insulated, code 2 → + loose jacket. Any other value (None / 0 / unknown) → None so the + caller keeps the conservative no-storage-loss default rather than + guessing a loss branch. Accepts the int / digit-string / None shapes + `cylinder_insulation_type` arrives in across the two front-ends.""" + code = _int_or_none(insulation_type) + if code == _CYLINDER_INSULATION_TYPE_FACTORY: + return "factory_insulated" + if code == _CYLINDER_INSULATION_TYPE_LOOSE_JACKET: + return "loose_jacket" + return None # RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating # code 999 (Elmhurst §15.0 "NON") signals that no DHW system was @@ -4460,11 +4658,25 @@ _WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999 # "Immersion Heater Type: Single" so the single-immersion path is used. _CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2 # RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not -# accessible" — the §10.7 default cylinder uses the age-band insulation, -# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket -# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam. -_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = { - "G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38, +# accessible" — the §10.7 default cylinder uses the age-band insulation: +# "Age band of main property A to F: 12 mm loose jacket", G/H → 25 mm +# foam, I-M → 38 mm foam. Each entry is (cylinder_insulation_type, +# thickness_mm); the loose-jacket branch is now plumbed (S0380.224) so +# A-F resolves instead of raising. +_TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE: Final[dict[str, tuple[int, int]]] = { + "A": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "B": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "C": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "D": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "E": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "F": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "G": (_CYLINDER_INSULATION_TYPE_FACTORY, 25), + "H": (_CYLINDER_INSULATION_TYPE_FACTORY, 25), + "I": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "J": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "K": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "L": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "M": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), } @@ -4487,9 +4699,8 @@ def _apply_rdsap_no_water_heating_system_default( Elmhurst engine's worksheet header for the corpus "no system" cert (WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G). - Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket) — - no corpus member exercises that combination and the SAP 10.2 Table 2 - loss-factor dispatch only has the factory-foam path plumbed. + Raises `UnmappedSapCode` only when the main dwelling's age band is + absent / outside A-M (no Table 29 row to apply). """ if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM: return epc @@ -4498,17 +4709,18 @@ def _apply_rdsap_no_water_heating_system_default( if epc.sap_building_parts else None ) band = (age_band or "")[:1].upper() - thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band) - if thickness_mm is None: + default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) + if default is None: raise UnmappedSapCode( "rdsap_10_7_default_cylinder_insulation_age_band", age_band ) + insulation_type_code, thickness_mm = default sap_heating = replace( epc.sap_heating, water_heating_code=_WHC_ELECTRIC_IMMERSION, water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE, cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L, - cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY, + cylinder_insulation_type=insulation_type_code, cylinder_insulation_thickness_mm=thickness_mm, cylinder_thermostat="Y", ) @@ -4594,6 +4806,20 @@ def _separately_timed_dhw( return False if main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES: return False + # SAP 10.2 Table 2b note b + RdSAP 10 §10.5.1 (PDF p.55): the ×0.9 + # reduction reflects DHW timed separately from space heating on a + # SHARED heat generator. When DHW is from a separate dedicated + # water-heating-only system (water-heating code not "from main / + # 2nd-main system" — e.g. 911 "Gas boiler/circulator for water + # heating only") there is no shared timer to apply the ×0.9 against, + # so the multiplier must not fire — the same principle as the WHC + # 903 electric-immersion carve-out above. Simulated case 19 (electric + # storage main + WHS 911 + 210 L loose-jacket cylinder) is the + # worksheet case: (53) Temperature factor 0.6000 (not 0.54) and + # (59)m primary loss h=5 (Jan 64.5792, not 43.31) both confirm the + # DHW is not separately timed. + if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: + return False return bool(epc.has_hot_water_cylinder) @@ -4897,6 +5123,18 @@ def _primary_loss_applies( # kWh/yr primary loss to a system with no primary circuit at all. if water_heating_code == _WHC_ELECTRIC_IMMERSION: return False + # SAP 10.2 Table 3 (PDF p.160) row 1 — a dedicated "boiler/circulator + # for water heating only" (WHC 911 gas / 912 liquid / 913 solid / + # 921-931 range cooker with boiler) is a heat generator feeding the + # cylinder through a primary loop, so the loss applies regardless of + # the space-heating main. Checked off `water_heating_code` (not + # `main`) because for these certs the resolved DHW `main` is the + # SPACE main (e.g. an electric storage heater, SAP code 402) — the + # gas/oil water boiler isn't a `main_heating_detail`. Simulated case + # 19 (storage main + WHS 911 + 210 L cylinder): worksheet (59) = 676.68 + # kWh/yr — zero before this branch. + if water_heating_code in _WATER_HEATING_BOILER_CIRCULATOR_CODES: + return True if main.main_heating_category == 4: if hp_record is None: # No PCDB record → assume separate-vessel (conservative; the @@ -5421,14 +5659,17 @@ def _cylinder_storage_loss_override( volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) if volume_l is None: return None - if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY: + insulation_label = _cylinder_storage_loss_insulation_label( + sh.cylinder_insulation_type + ) + if insulation_label is None: return None thickness_mm = sh.cylinder_insulation_thickness_mm if thickness_mm is None: return None storage_56m = cylinder_storage_loss_monthly_kwh( volume_l=volume_l, - insulation_type="factory_insulated", + insulation_type=insulation_label, thickness_mm=float(thickness_mm), has_cylinder_thermostat=sh.cylinder_thermostat == "Y", # SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the @@ -5785,10 +6026,15 @@ def _fuel_cost( table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP ) - standing = additional_standing_charges_gbp( - main_fuel_code=main_fuel_code, - water_heating_fuel_code=water_heating_fuel_code, - tariff=tariff, + heat_network_standing = _heat_network_standing_charge_gbp(epc, main) + standing = ( + heat_network_standing + if heat_network_standing is not None + else additional_standing_charges_gbp( + main_fuel_code=main_fuel_code, + water_heating_fuel_code=water_heating_fuel_code, + tariff=tariff, + ) ) # Worksheet display convention: when a row's kWh is zero (no main 2, no @@ -5887,6 +6133,27 @@ def cert_to_inputs( has_balanced_mv=_has_balanced_mechanical_ventilation(epc), ) ) + # SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main + # heating systems include two figures from this table." A genuine + # second SPACE-heating main therefore contributes its own circulation + # pump alongside Main 1's. The "second main heating system" test is the + # same one §9a uses to split space-heating demand: a lodged + # `main_heating_fraction > 0`. This excludes DHW-only second mains + # (e.g. cert 000565 Main 2 = gas combi via WHC 914, fraction 0 — water + # heating only, no space-heating circulation pump). Simulated case 6 + # (dual oil boiler, 51% rads + 49% underfloor) lodges Main 1 "2013 or + # later" (41 kWh) + Main 2 unknown-date (115 kWh) → worksheet (230c) + # central-heating pump = 41 + 115 = 156. The Main 2 oil-boiler aux + # (230d) is already summed in `_table_4f_additive_components`; this + # adds only the circulation pump. + _pumps_main_details = ( + epc.sap_heating.main_heating_details if epc.sap_heating else [] + ) + if len(_pumps_main_details) >= 2: + _pumps_main_2 = _pumps_main_details[1] + _pumps_main_2_fraction = _pumps_main_2.main_heating_fraction + if _pumps_main_2_fraction is not None and _pumps_main_2_fraction > 0: + pumps_fans_kwh += _table_4f_circulation_pump_kwh(_pumps_main_2) pumps_fans_kwh += _table_4f_additive_components(epc) # Track the MEV/MVHR-fan portion separately so the cost cascade can # apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on @@ -6192,12 +6459,33 @@ def cert_to_inputs( # = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0 # for the Elmhurst corpus (cert-side mapping is a future slice). control_type_value = _control_type(main) - responsiveness_value = _responsiveness( - main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type), - ) + _mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) + responsiveness_value = _responsiveness(main, tariff=_mit_tariff) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) + # SAP 10.2 Table 9b weighted R + p.186 two-systems-different-parts MIT. + # A genuine second main (main_heating_fraction > 0 = (203)) contributes + # its own responsiveness (Table 9b weighted average) and, when it + # carries a different control type, its own rest-of-dwelling control + # schedule. `_first_main_heating` is system 1 (living area); the second + # detail is system 2. Single-main / DHW-only second mains (frac 0) pass + # the None/0 defaults → unchanged single-system MIT. + _mit_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + _mit_main_2 = _mit_details[1] if len(_mit_details) >= 2 else None + main_2_control_type_value: Optional[int] = None + main_2_fraction_value = 0.0 + main_2_responsiveness_value = 1.0 + if ( + _mit_main_2 is not None + and _mit_main_2.main_heating_fraction is not None + and _mit_main_2.main_heating_fraction > 0 + ): + main_2_control_type_value = _control_type(_mit_main_2) + main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0 + main_2_responsiveness_value = _responsiveness( + _mit_main_2, tariff=_mit_tariff + ) monthly_total_gains_w = tuple( internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) ) @@ -6223,6 +6511,9 @@ def cert_to_inputs( responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=_control_temperature_adjustment_c(main), + main_2_control_type=main_2_control_type_value, + main_2_fraction=main_2_fraction_value, + main_2_responsiveness=main_2_responsiveness_value, extended_heating_days_per_month=extended_heating_days, ) @@ -6247,8 +6538,14 @@ def cert_to_inputs( # Q_space (kWh/month) per spec = (98c)m × (204) = (98c)m × (1 − # sec_frac) for single-main fixtures. if wh_result is not None: + # Eq D1 Q_space is the DHW boiler's OWN space-heating load — its + # (204)/(205) share of total — not the dwelling total (202). See + # `_water_heating_main_space_fraction`. + water_main_space_fraction = _water_heating_main_space_fraction( + epc, secondary_fraction_value + ) space_heating_monthly_useful_kwh = tuple( - q * (1.0 - secondary_fraction_value) + q * water_main_space_fraction for q in space_heating_result.total_space_heating_monthly_kwh ) hw_kwh = _apply_water_efficiency( @@ -6337,11 +6634,22 @@ def cert_to_inputs( secondary_efficiency_value = _secondary_efficiency( epc.sap_heating, main_code, main_fuel ) + # SAP 10.2 §9a two-main split (203)/(207) — see the section helper + # `energy_requirements_section_from_cert` for the rationale. + _main_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + _main_2 = _main_details[1] if len(_main_details) >= 2 else None + main_2_of_main_fraction = 0.0 + main_2_efficiency_value = 0.0 + if _main_2 is not None and _main_2.main_heating_fraction is not None: + main_2_of_main_fraction = _main_2.main_heating_fraction / 100.0 + main_2_efficiency_value = _main_heating_detail_efficiency(_main_2, epc) energy_requirements_result = space_heating_fuel_monthly_kwh( space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh, secondary_heating_fraction=secondary_fraction_value, main_heating_efficiency_pct=eff * 100.0, secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, + main_2_of_main_fraction=main_2_of_main_fraction, + main_2_efficiency_pct=main_2_efficiency_value * 100.0, ) # SAP 10.2 Appendix M1 §3-4 (p.93-94): split monthly PV generation @@ -6448,10 +6756,15 @@ def cert_to_inputs( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), ) _hw_extra_standing = 0.0 - standing_charges_total = additional_standing_charges_gbp( - main_fuel_code=_main_fuel_code(main), - water_heating_fuel_code=_water_heating_fuel_code(epc), - tariff=_rdsap_tariff(epc), + _heat_network_standing = _heat_network_standing_charge_gbp(epc, main) + standing_charges_total = ( + _heat_network_standing + if _heat_network_standing is not None + else additional_standing_charges_gbp( + main_fuel_code=_main_fuel_code(main), + water_heating_fuel_code=_water_heating_fuel_code(epc), + tariff=_rdsap_tariff(epc), + ) ) + _hw_extra_standing # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution diff --git a/domain/sap10_calculator/worksheet/energy_requirements.py b/domain/sap10_calculator/worksheet/energy_requirements.py index 44a15ed6..ccd8d738 100644 --- a/domain/sap10_calculator/worksheet/energy_requirements.py +++ b/domain/sap10_calculator/worksheet/energy_requirements.py @@ -11,8 +11,10 @@ where (204) = (202) × (1 − (203)) and (202) = 1 − (201). Single-main case ((203) = 0) collapses (204) to (202), so (211)m = (98c)m × (202) × 100 / (206). Same shape for secondary (215)m and main 2 (213)m. -Two-main split ((203) > 0) and cooling-fuel (209)/(221) are zero-branch -placeholders in scope A — populated once first cert exercises them. +Two-main split ((203) > 0) is implemented: (211)m = (98c)m × (204) × +100 / (206) for system 1 and (213)m = (98c)m × (205) × 100 / (207) for +system 2, where (204) = (202) × (1 − (203)) and (205) = (202) × (203). +Cooling-fuel (209)/(221) remains a zero-branch placeholder. Reference: SAP 10.2 specification (14-03-2025) §9a (lines 7909-7953). """ @@ -26,10 +28,9 @@ from dataclasses import dataclass class EnergyRequirementsResult: """SAP 10.2 §9a worksheet line refs (201)..(221). - Scope-A populated lines: (201), (202), (204), (206), (208), (211)m, - (211), (215)m, (215). Two-main and cooling-fuel line refs ((203), - (205), (207), (209), (213)m, (213), (221)) are zero-branch - placeholders until the first multi-main / fixed-AC cert lands. + Populated lines: (201)-(208), (211)m/(211), (213)m/(213) (two-main + split), (215)m/(215). Cooling-fuel line refs ((209), (221)) are + zero-branch placeholders until the first fixed-AC cert lands. """ # Fractions (Table 11) @@ -60,26 +61,37 @@ def space_heating_fuel_monthly_kwh( secondary_heating_fraction: float, main_heating_efficiency_pct: float, secondary_heating_efficiency_pct: float, + main_2_of_main_fraction: float = 0.0, + main_2_efficiency_pct: float = 0.0, ) -> EnergyRequirementsResult: """SAP 10.2 §9a orchestrator — produce (201)..(221) line refs. - Scope A: single-main + secondary only. Two-main ((203) > 0) and - cooling-fuel (Table 10c SEER) populate the zero-branch placeholder - fields with computed values when their respective slices land. + Single-main certs leave `main_2_of_main_fraction` = 0, collapsing + (204) to (202) and zeroing (213)m. Dual-main certs (cert 0240 / + simulated case 6) pass (203) = fraction of main heating from main + system 2 and (207) = main system 2 efficiency; the §8 space-heat + demand then splits (204)=(202)×(1−(203)) to system 1 and + (205)=(202)×(203) to system 2, each at its own efficiency. Cooling- + fuel (Table 10c SEER) remains a zero-branch placeholder. """ fraction_201 = secondary_heating_fraction fraction_202 = 1.0 - fraction_201 - fraction_203 = 0.0 # scope A: no main 2 + fraction_203 = main_2_of_main_fraction fraction_204 = fraction_202 * (1.0 - fraction_203) fraction_205 = fraction_202 * fraction_203 main_1_eff = main_heating_efficiency_pct + main_2_eff = main_2_efficiency_pct secondary_eff = secondary_heating_efficiency_pct main_1_fuel_monthly = tuple( q * fraction_204 * 100.0 / main_1_eff if main_1_eff > 0 else 0.0 for q in space_heating_monthly_kwh ) + main_2_fuel_monthly = tuple( + q * fraction_205 * 100.0 / main_2_eff if main_2_eff > 0 else 0.0 + for q in space_heating_monthly_kwh + ) secondary_fuel_monthly = tuple( q * fraction_201 * 100.0 / secondary_eff if secondary_eff > 0 else 0.0 for q in space_heating_monthly_kwh @@ -92,14 +104,14 @@ def space_heating_fuel_monthly_kwh( main_1_of_total_fraction=fraction_204, main_2_of_total_fraction=fraction_205, main_1_efficiency_pct=main_1_eff, - main_2_efficiency_pct=0.0, + main_2_efficiency_pct=main_2_eff, secondary_efficiency_pct=secondary_eff, cooling_seer=0.0, main_1_fuel_monthly_kwh=main_1_fuel_monthly, - main_2_fuel_monthly_kwh=(0.0,) * 12, + main_2_fuel_monthly_kwh=main_2_fuel_monthly, secondary_fuel_monthly_kwh=secondary_fuel_monthly, main_1_fuel_kwh_per_yr=sum(main_1_fuel_monthly), - main_2_fuel_kwh_per_yr=0.0, + main_2_fuel_kwh_per_yr=sum(main_2_fuel_monthly), secondary_fuel_kwh_per_yr=sum(secondary_fuel_monthly), cooling_fuel_kwh_per_yr=0.0, ) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 1c0a6f0c..dff61a08 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -300,6 +300,36 @@ def _parse_thickness_mm(value: Any) -> Optional[int]: return int(digits) if digits else None +def _described_as_retrofit_insulated(description: Optional[str]) -> bool: + """True only when the description asserts insulation KNOWN to have + been added subsequently — i.e. genuine retrofit, not the age-band + as-built assumption. + + RdSAP 10 Table 8/9 footnote routes a wall to the 50 mm "insulation + of unknown thickness" row ONLY when insulation is "known to have been + increased subsequently (otherwise 'as built' applies)". A description + rendered as "as built ... insulated (assumed)" is the EPC's age-band + assumption — it renders only on recent age bands where as-built + construction already includes insulation (an old band renders "no + insulation (assumed)"). For those the spec uses the as-built age-band + U-value, NOT the 50 mm retrofit row. + + Worksheet evidence: simulated case 9 (sandstone, band J, As Built → + U 0.35) and case 10 (solid brick, band J, As Built → U 0.35); both + Elmhurst worksheets return the as-built row, not the 50 mm bucket + (which gives ~0.25). Genuine retrofit is signalled by + `wall_insulation_type` (External/Internal/Filled), checked + independently by the `wall_ins_present` gate — so excluding the + "as built"/"(assumed)" description here loses no real retrofit signal. + """ + if description is None: + return False + if not _described_as_insulated(description): + return False + desc = description.lower() + return "as built" not in desc and "assumed" not in desc + + def _joined_descriptions(elements: list[Any]) -> Optional[str]: if not elements: return None @@ -311,6 +341,13 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]: 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 + # cantilever geometry, but the early return must still expose the + # SAME keys as the full return below: the §3.9 RR block reads + # geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] / + # ["cantilever_floor_area_m2"] for every part, so omitting them + # here raised KeyError on multi-part certs whose first bp lodges + # no sap_floor_dimensions (5 certs in a 2026 API sample). return { "ground_floor_area_m2": 0.0, "top_floor_area_m2": 0.0, @@ -318,6 +355,9 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: "party_wall_area_m2": 0.0, "rr_floor_area_m2": 0.0, "rr_simplified_a_rr_m2": 0.0, + "rr_common_wall_area_m2": 0.0, + "rr_gable_area_m2": 0.0, + "cantilever_floor_area_m2": 0.0, } fds = list(part.sap_floor_dimensions) ground = next((fd for fd in fds if fd.floor == 0), fds[0]) @@ -450,6 +490,45 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: } +_RR_ROOF_LODGEMENT_KINDS: Final[frozenset[str]] = frozenset( + {"slope", "flat_ceiling", "stud_wall"} +) + + +def _bp_rr_roof_absorbs_rooflight( + part: SapBuildingPart, geom: dict[str, Any] +) -> bool: + """Whether a rooflight on this building part pierces the room-in-roof + sloped ceiling (so it deducts from the RR roof contribution) rather + than a flat external roof. + + True ONLY for a Detailed RR (§3.10) lodging wall surfaces but no roof + surfaces (gable / common / connected, no slope / flat_ceiling / + stud_wall): the §3.10.1 residual roof fires and the rooflight deducts + from it (simulated case 6: the 6 "Roof of Room" rooflights deduct from + "Roof room Main remaining" net 55.54 = gross 61.73 − 6.19). + + False otherwise: + - Simplified Type 1/2 RR (geom A_RR > 0, certs 6035 / 0240): the + rooflight pierces the regular loft roof at U_roof, NOT the A_RR + shell — its area deducts from `roof_area` (the test + `test_6035_api_room_in_roof_gables_deduct_from_roof` pins this). + - Detailed RR lodging explicit roof surfaces (cert 000565 Ext2 stud + walls / 000516 slopes): the rooflight pierces the regular roof. + Both keep the pre-S0380.203 §3.7 "deduct from the host roof" behaviour. + """ + if geom["rr_simplified_a_rr_m2"] > 0: + return False + rir = part.sap_room_in_roof + if rir is None or not rir.detailed_surfaces: + return False + if float(rir.floor_area) <= 0.0: + return False + return not any( + s.kind in _RR_ROOF_LODGEMENT_KINDS for s in rir.detailed_surfaces + ) + + def heat_transmission_from_cert( epc: EpcPropertyData, *, @@ -626,14 +705,21 @@ def heat_transmission_from_cert( wall_construction = _int_or_none(part.wall_construction) wall_ins_type = _int_or_none(part.wall_insulation_type) wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness) - # Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)" - # or "partial insulation (assumed)" in its description has retrofit - # insulation the assessor hasn't measured the thickness of — even - # when wall_insulation_type=4 ("as-built / assumed"). Treat as - # present so the 50 mm bucket routes correctly. + # RdSAP 10 Table 8/9 footnote: the 50 mm "insulation of unknown + # thickness" row applies only when insulation is "known to have + # been increased subsequently (otherwise 'as built' applies)". + # Genuine retrofit is signalled by `wall_insulation_type` + # (External/Internal/Filled ≠ NONE). An "as built ... insulated + # (assumed)" description is the EPC age-band assumption (it only + # renders on recent bands where as-built already includes + # insulation) → use the as-built age-band row, NOT 50 mm. + # Worksheet-validated by simulated case 9 (sandstone J → 0.35) + # and case 10 (solid brick J → 0.35), both As Built. So the + # description signal is restricted to genuine (non-assumed) + # retrofit via `_described_as_retrofit_insulated`. wall_ins_present = ( (wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE) - or _described_as_insulated(wall_description) + or _described_as_retrofit_insulated(wall_description) ) party_construction = _int_or_none(part.party_wall_construction) raw_roof_thickness = getattr(part, "roof_insulation_thickness", None) @@ -674,6 +760,10 @@ def heat_transmission_from_cert( # insulation_type combination doesn't match the formula # path's preconditions. wall_thickness_mm=part.wall_thickness_mm, + # RdSAP 10 §5.8 — lodged insulation thermal-conductivity + # code feeds the documentary-evidence R-value calc when a + # measured wall thickness is also present (else ignored). + wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity, ) # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling @@ -707,7 +797,21 @@ def heat_transmission_from_cert( # spec value is 2.30 (A-D) / 1.50 (E) / 0.68 (F) / 0.40 (G). roof_type_lower = (part.roof_construction_type or "").lower() is_flat_roof = "flat" in roof_type_lower - ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof) + # RdSAP 10 §5.11 Table 18 — a pitched roof whose ceiling follows the + # slope ("Pitched, sloping ceiling" code 8 / "Pitched (vaulted + # ceiling)" code 5) has no loft void, so an unknown-thickness + # lodgement takes the column (3) "Flat roof / sloping ceiling" + # age-band default rather than the §5.11.4 retrofit-50 mm joist row. + is_sloping_ceiling = ( + "sloping ceiling" in roof_type_lower or "vaulted" in roof_type_lower + ) + # RdSAP 10 Table 18 col (3) routing for an AS-BUILT "Pitched, + # sloping ceiling" (code 8). Narrower than `is_sloping_ceiling` + # (which also covers code-5 vaulted): vaulted ceilings stay on + # col (1) per the cohort, so only the literal "sloping ceiling" + # string triggers the col (3) age-band default in `u_roof`. + is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower + ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling) # Floor U-value routing (in priority order): # 1. Basement floor — Table 23 F-column override (whole floor=0). # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no @@ -811,7 +915,23 @@ def heat_transmission_from_cert( if "sloping ceiling" in roof_type: top_floor_area = top_floor_area / _COS_30_DEG gross_roof_area = _round_half_up(top_floor_area, _AREA_ROUND_DP) - roof_area = max(0.0, gross_roof_area - rw_area_part) + # RdSAP 10 §3.7 — a rooflight deducts from the gross roof of the + # element it physically pierces. A "Roof of Room" rooflight sits on + # the room-in-roof sloped ceiling (the §3.9/§3.10 A_RR shell), not a + # flat external roof, so its area deducts from the RR roof + # contribution (simplified A_RR_final or the §3.10.1 detailed + # residual) rather than `roof_area` — but ONLY when the BP's RR + # actually contributes such a shell/residual. Where the BP lodges + # explicit roof surfaces (cert 000565 Ext2 stud walls / 000516 + # slopes), the rooflight pierces those (the regular roof) and + # deducts there per §3.7 (current behaviour). Simulated case 6 + # worksheet: "Roof room Main remaining area" net 55.54 = gross 61.73 + # − 6.19 rooflights, while "External roof Main" 14.52 carries no + # opening. + rw_area_on_rr = ( + rw_area_part if _bp_rr_roof_absorbs_rooflight(part, geom) else 0.0 + ) + roof_area = max(0.0, gross_roof_area - (rw_area_part - rw_area_on_rr)) floor_area_total = _round_half_up( geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0, _AREA_ROUND_DP, @@ -868,7 +988,9 @@ def heat_transmission_from_cert( rir = part.sap_room_in_roof assert rir is not None # rr_a_rr > 0 ⇒ rir present per _part_geometry walls += uw * (rr_common + rr_gable) - a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable) + # Deduct any "Roof of Room" rooflights piercing the RR shell + # (see `rw_area_on_rr` rationale at the gross-roof block). + a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable - rw_area_on_rr) u_rr = u_rr_default_all_elements( country=country, age_band=rir.construction_age_band, ) @@ -1003,7 +1125,13 @@ def heat_transmission_from_cert( a_rr_shell = _round_half_up( 12.5 * sqrt(rr_floor_for_a_rr / 1.5), _AREA_ROUND_DP, ) - residual_area = max(0.0, a_rr_shell - rr_walls_in_a_rr_area) + # Deduct any "Roof of Room" rooflights piercing the RR + # residual (see `rw_area_on_rr` rationale at the gross-roof + # block) — case 6: 93.09 shell − 31.36 gables − 6.19 + # rooflights = 55.54 net = worksheet "Roof room remaining". + residual_area = max( + 0.0, a_rr_shell - rr_walls_in_a_rr_area - rw_area_on_rr + ) if residual_area > 0.0: rr_detailed_area += residual_area roof += residual_area * u_rr_default_all_elements( @@ -1109,7 +1237,7 @@ def _alt_wall_w_per_k( alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness) alt_insulation_present = ( alt_wall.wall_insulation_type != _WALL_INSULATION_NONE - or _described_as_insulated(wall_description) + or _described_as_retrofit_insulated(wall_description) ) alt_u = u_wall( country=country, diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 8e6784e1..509ac363 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -27,9 +27,13 @@ from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from enum import Enum from math import cos, exp, pi -from typing import Final +from typing import Final, Optional -from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + MainHeatingDetail, + SapWindow, +) def _decimal_window_area_2dp(width: float, height: float) -> float: @@ -634,15 +638,15 @@ def _daylight_factor_from_cert( return 52.2 * g_l * g_l - 9.94 * g_l + 1.433 -def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: - """Map first main-heating detail's central_heating_pump_age_str to a +def _pump_date_category_for_detail( + detail: Optional[MainHeatingDetail], +) -> PumpDateCategory: + """Map a `MainHeatingDetail`'s central_heating_pump_age_str to a Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown" - / None on each `MainHeatingDetail` (nested under `epc.sap_heating`).""" - sap_heating = getattr(epc, "sap_heating", None) - details = getattr(sap_heating, "main_heating_details", None) or [] + / None on each detail.""" age_str = "" - if details: - age_str = (details[0].central_heating_pump_age_str or "").lower() + if detail is not None: + age_str = (detail.central_heating_pump_age_str or "").lower() if "post" in age_str or "2013 or later" in age_str: return PumpDateCategory.NEW_2013_OR_LATER if "pre" in age_str or "2012" in age_str: @@ -650,6 +654,14 @@ def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: return PumpDateCategory.UNKNOWN +def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: + """Table 5a date bucket for Main 1 (the dwelling's first circulation + pump). Delegates to `_pump_date_category_for_detail`.""" + sap_heating = getattr(epc, "sap_heating", None) + details = getattr(sap_heating, "main_heating_details", None) or [] + return _pump_date_category_for_detail(details[0] if details else None) + + # SAP 10.2 Table 5a Note a) (PDF p.177): "Not applicable for electric # heat pumps from database." The pump GAIN (worksheet line 70) is # omitted only for HP-category systems. Where the cert lodges a @@ -730,33 +742,69 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool: details = epc.sap_heating.main_heating_details if not details: return False - for d in details: - if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY: - # PCDB Table 362 record → pump electricity AND gain are - # embedded in COP (Appendix N1.2.1); no separate gain row. - if d.main_heating_index_number is not None: - continue - # Cat 5 warm-air HP (codes 521/523-527) → no water pump. - code = d.sap_main_heating_code - if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES: - continue - # Cat 4 HP, Table 4a default cascade → apply Table 5a - # pump gain per Appendix N3.1. - return True - code = d.sap_main_heating_code - if code is not None and any( - code in r for r in _WET_BOILER_SAP_CODE_RANGES - ): - return True + return any(_main_detail_has_central_heating_pump(d) for d in details) + + +def _main_detail_has_central_heating_pump(d: MainHeatingDetail) -> bool: + """Whether a single `MainHeatingDetail` carries a Table 5a central- + heating-pump gain — the per-detail core of + `_any_main_system_has_central_heating_pump` (see that docstring for + the wet-main identification + HP rules).""" + if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY: + # PCDB Table 362 record → pump electricity AND gain are + # embedded in COP (Appendix N1.2.1); no separate gain row. if d.main_heating_index_number is not None: - return True - if d.main_heating_category in {1, 2}: - return True - if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES: - return True + return False + # Cat 5 warm-air HP (codes 521/523-527) → no water pump. + code = d.sap_main_heating_code + if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES: + return False + # Cat 4 HP, Table 4a default cascade → apply Table 5a + # pump gain per Appendix N3.1. + return True + code = d.sap_main_heating_code + if code is not None and any(code in r for r in _WET_BOILER_SAP_CODE_RANGES): + return True + if d.main_heating_index_number is not None: + return True + if d.main_heating_category in {1, 2}: + return True + if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES: + return True return False +def _second_main_central_heating_pump_gain_w(epc: EpcPropertyData) -> float: + """SAP 10.2 Table 5a note a) (PDF p.177): "Where there are two main + heating systems serving different parts of the dwelling, assume each + has its own circulation pump and therefore include two figures from + this table. ... Where two main systems serve the same space a single + pump is assumed." + + Returns the SECOND main system's central-heating-pump gain (W, + heating-season) when a genuine second SPACE-heating main is lodged — + detected by `main_heating_fraction > 0`, the same gate + `cert_to_inputs` uses to split §9a space-heating demand and to add + the Table 4f note c) second circulation pump (S0380.201). Excludes + DHW-only second mains (fraction 0, e.g. cert 000565 Main 2 combi via + WHC 914). The gain uses the SECOND main's own pump-age bucket — for + simulated case 6 (dual oil, Main 2 unknown date) that is 7 W, giving + worksheet (70) = 3 (Main 1) + 7 (Main 2) = 10. + """ + details = epc.sap_heating.main_heating_details + if len(details) < 2: + return 0.0 + second = details[1] + fraction = second.main_heating_fraction + if fraction is None or fraction <= 0: + return 0.0 + if not _main_detail_has_central_heating_pump(second): + return 0.0 + return central_heating_pump_w( + date_category=_pump_date_category_for_detail(second) + ) + + # SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The # Table 5a "Warm air heating system fans" gain (and Table 4f # electricity row) fire for these mains: @@ -881,6 +929,11 @@ def internal_gains_from_cert( pump_w = central_heating_pump_w( date_category=_pump_date_category_from_cert(epc) ) + # SAP 10.2 Table 5a note a) — a second main heating system serving + # a different part of the dwelling has its own circulation pump + # (two figures from the table). Simulated case 6 (dual oil, rads + + # underfloor) → Main 1 3 W + Main 2 7 W = worksheet (70) 10 W. + pump_w += _second_main_central_heating_pump_gain_w(epc) else: pump_w = 0.0 # SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index 8e562ed4..7cfa637e 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -321,6 +321,9 @@ def mean_internal_temperature_monthly( control_temperature_adjustment_c: float = 0.0, secondary_fraction: float = 0.0, secondary_responsiveness: float = 1.0, + main_2_control_type: Optional[int] = None, + main_2_fraction: float = 0.0, + main_2_responsiveness: float = 1.0, extended_heating_days_per_month: Optional[tuple[tuple[int, int], ...]] = None, ) -> MeanInternalTemperatureResult: """SAP 10.2 §7 orchestrator — chain Table 9c steps 1–9 for all 12 months. @@ -354,13 +357,36 @@ def mean_internal_temperature_monthly( standard SAP heating schedule applies: T_zone = T_bimodal directly. """ + # SAP 10.2 Table 9b (PDF p.183) — "where there are two main systems R + # is a weighted average ... R = (203)·R_system2 + [1 − (203)]·R_system1". + # (203) = `main_2_fraction`. Applied before the secondary-heating blend. + main_responsiveness = responsiveness + if main_2_control_type is not None and main_2_fraction > 0.0: + main_responsiveness = ( + (1.0 - main_2_fraction) * responsiveness + + main_2_fraction * main_2_responsiveness + ) effective_responsiveness = ( - (1.0 - secondary_fraction) * responsiveness + (1.0 - secondary_fraction) * main_responsiveness + secondary_fraction * secondary_responsiveness ) elsewhere_off_hours = ( _ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12 ) + # SAP 10.2 p.186 "two systems heat different parts of the house": when + # the two mains carry different controls, the rest-of-dwelling (90)m is + # the weighted average of T2 computed under EACH system's control. The + # elsewhere off-hours for main system 2's control: + two_main_different_parts = ( + main_2_control_type is not None + and main_2_fraction > 0.0 + and main_2_control_type != control_type + ) + elsewhere_off_hours_main_2 = ( + _ELSEWHERE_OFF_HOURS_TYPE_3 + if main_2_control_type == 3 + else _ELSEWHERE_OFF_HOURS_TYPE_12 + ) eta_living: list[float] = [] t_1: list[float] = [] @@ -408,6 +434,34 @@ def mean_internal_temperature_monthly( ) eta_elsewhere.append(eta_e) + # SAP 10.2 p.186 part 2 — two systems heat different parts: blend + # the rest-of-dwelling temperature computed under each system's + # control. Th2 + η are identical for control types 2/3 (Table 9 + # uses the same Th2 formula); only the off-hours differ, so the + # second computation reuses t_h2_m and shares η. Weights: + # sys2 control: (203) / [1 − (91)] + # sys1 control: [1 − (203) − (91)] / [1 − (91)] + # If (203) ≥ rest-of-house area [1 − (91)], use sys2's control + # alone for elsewhere (per the spec's threshold clause). + if two_main_different_parts: + rest_of_house = 1.0 - living_area_fraction + _, t_e_main_2 = _zone_mean_temp_with_per_zone_eta( + heating_temperature_c=t_h2_m, + off_hours_first=elsewhere_off_hours_main_2[0], + off_hours_second=elsewhere_off_hours_main_2[1], + external_temp_c=ext, responsiveness=effective_responsiveness, + total_gains_w=gains, heat_transfer_coefficient_w_per_k=h, + time_constant_h=tau, + ) + if rest_of_house <= 0.0 or main_2_fraction >= rest_of_house: + t_e_bimodal = t_e_main_2 + else: + w_main_2 = main_2_fraction / rest_of_house + w_main_1 = ( + rest_of_house - main_2_fraction + ) / rest_of_house + t_e_bimodal = w_main_1 * t_e_bimodal + w_main_2 * t_e_main_2 + # SAP 10.2 Appendix N3.5 Equation N5 — when the caller provides # per-month (N24,9, N16,9) day allocations, blend Th / T_unimodal # / T_bimodal for each zone. T_unimodal applies one 8-hour off diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index edc6d9ce..8013e3d7 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -52,14 +52,11 @@ def _described_as_insulated(description: Optional[str]) -> bool: otherwise. Looks for "insulated" or "partial insulation" substrings, with "no insulation" taking precedence as a hard negation. - Two consumers: - - `u_wall` uses this to route cavity walls to the Filled-cavity row - of Table 6 (in lieu of the bucketed cascade). - - `heat_transmission_from_cert` uses this to set `wall_ins_present` - for non-cavity walls so the 50 mm bucket routing fires per the - RdSAP 10 Table 6 footnote ("If a wall is known to have additional - insulation but the insulation thickness is unknown, use the row - in the table for 50 mm insulation"). + Consumer: `u_wall` uses this to route cavity walls to the Filled- + cavity row of Table 6 (in lieu of the bucketed cascade). For the + non-cavity `wall_ins_present` gate, `heat_transmission_from_cert` + further restricts this to genuine (non-assumed) retrofit via its + local `_described_as_retrofit_insulated`. """ if description is None: return False @@ -69,6 +66,41 @@ def _described_as_insulated(description: Optional[str]) -> bool: return "insulated" in desc or "partial insulation" in desc +def _cavity_described_as_filled(description: Optional[str]) -> bool: + """True when an as-built cavity wall's description asserts the cavity is + insulated/filled, routing it to the Table 6 "Filled cavity" row. + + Distinguishes the three as-built cavity states the EPC renders by age + band when wall_insulation_type=4 ("as-built / assumed"): + + - "...insulated (assumed)" → Filled cavity (assessor judges + the cavity filled but lodges no + thickness) + - "...partial insulation (assumed)" → "Cavity as built" row (the + as-built partial fill of the age + band, NOT a retrofit cavity fill) + - "...no insulation (assumed)" → "Cavity as built" row + + Narrower than `_described_as_insulated`: it excludes the "partial + insulation" substring so a "partial insulation (assumed)" cavity stays on + the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F = + 1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the + filled row understates heat loss by 2.5x. A genuine retrofit fill is + lodged distinctly as "Cavity wall, filled cavity" + (wall_insulation_type=2), handled by the explicit-code branch. + + Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity + type 4, "partial insulation (assumed)") closes all four SAP metrics on + the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m². + """ + if description is None: + return False + desc = description.lower() + if "no insulation" in desc: + return False + return "insulated" in desc + + # --------------------------------------------------------------------------- # Country # --------------------------------------------------------------------------- @@ -145,6 +177,43 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7 # (cavity + external/internal insulation). _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04 +# RdSAP 10 §5.8 (page 41) — when documentary evidence lodges the insulation +# thermal conductivity, the R-value calc uses it instead of the 0.04 default. +# The spec offers three λ: 0.04 (mineral wool / EPS, the default), 0.03 (XPS), +# 0.025 (PUR / PIR / phenolic). The GOV.UK API surfaces a coded value +# (`wall_insulation_thermal_conductivity`); code 1 = the default 0.04 (the +# only code observed — cert 2130 Ext1, whose documentary-evidence path does +# not fire as no wall thickness is lodged, so the value is captured but +# unused there). Other codes raise until a worksheet-backed fixture confirms +# their λ — the same incremental-coverage discipline as the glazing-type map. +_WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA: Final[dict[int, float]] = { + 1: 0.04, +} + + +def _resolve_wall_insulation_lambda_w_per_mk( + conductivity: "str | int | None", +) -> float: + """Resolve the insulation λ (W/m·K) for the §5.8 documentary-evidence + R-value calc. Absent / "Unknown" → the 0.04 default; a mapped integer + code → its λ; an unmapped integer code raises so the enum is confirmed + against a worksheet rather than silently mis-factored.""" + if conductivity is None: + return _WALL_INSULATION_LAMBDA_W_PER_MK + if isinstance(conductivity, str): + text = conductivity.strip() + if not text or text.lower() == "unknown" or not text.isdigit(): + return _WALL_INSULATION_LAMBDA_W_PER_MK + conductivity = int(text) + lam = _WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA.get(conductivity) + if lam is None: + raise ValueError( + "unmapped wall_insulation_thermal_conductivity code " + f"{conductivity!r}; add its RdSAP 10 §5.8 λ " + "(0.04 / 0.03 / 0.025 W/m·K) once a worksheet confirms it" + ) + return lam + # RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including # laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to # the base U-value of an otherwise-uninsulated wall when the cert lodges @@ -457,6 +526,7 @@ def u_wall( dry_lined: bool = False, curtain_wall_age: Optional[str] = None, wall_thickness_mm: Optional[int] = None, + wall_insulation_thermal_conductivity: "str | int | None" = None, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. @@ -569,7 +639,10 @@ def u_wall( ): u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm) r_ins = _r_insulation_table_14( - insulation_thickness_mm, _WALL_INSULATION_LAMBDA_W_PER_MK, + 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( @@ -591,7 +664,9 @@ def u_wall( # for column alignment). Cascade-internal HLC then uses the # rounded U so net wall HLC matches `A × U_2dp` exactly. u_filled = _CAVITY_FILLED_ENG[age_idx] - r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK + r_ins = (insulation_thickness_mm / 1000.0) / _resolve_wall_insulation_lambda_w_per_mk( + wall_insulation_thermal_conductivity + ) u_unrounded = 1.0 / (1.0 / u_filled + r_ins) # Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87 # worksheet's column-display behaviour (used downstream in A×U). @@ -600,7 +675,7 @@ def u_wall( ) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY - or _described_as_insulated(description) + or _cavity_described_as_filled(description) ): return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) @@ -688,6 +763,8 @@ def u_roof( insulation_thickness_mm: Optional[int], description: Optional[str] = None, is_flat_roof: bool = False, + is_sloping_ceiling: bool = False, + is_pitched_sloping_ceiling: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. @@ -701,6 +778,29 @@ def u_roof( 3. Table 18 age-band default — column (1) "Pitched, insulation between joists" by default; column (3) "Flat roof" when `is_flat_roof=True`. Spec §5.11 Table 18 page 45. + + `is_sloping_ceiling` flags a pitched roof whose ceiling follows the + slope (a "Pitched, sloping ceiling" or "Pitched (vaulted ceiling)" + construction — RdSAP roof_construction codes 8 and 5). Such a roof has + no loft / ceiling-joist void, so an "NI" lodgement (parsed to 0) + + "insulated (assumed)" description means unknown-thickness-with-insulation, + NOT the §5.11.4 retrofit-50 mm joist row (0.68) a normal pitched-with- + loft roof would take. It instead takes the Table 18 column (1) age-band + default (band J = 0.16) — the same value a vaulted roof lodged "ND" + (thickness None) already reaches by falling through. The 33 cohort-2 + "ND" vaulted certs (code 5, band D → 0.40 = col 1) are the evidence. + + `is_pitched_sloping_ceiling` is the narrower code-8 ("Pitched, sloping + ceiling") signal for the AS-BUILT case (insulation lodged "As Built", + parsed to thickness None — distinct from the "NI"/"ND" unknown case + above). Per RdSAP 10 roof-input item 5-5 ("Sloping ceiling insulation + ... as built → Table 18") and Table 18 note (b) ("applies also to roof + with sloping ceiling"), an as-built sloping ceiling takes the column + (3) age-band default (band F = 0.68, band L = 0.18), NOT the column (1) + loft-joist default (band F = 0.40, band L = 0.16). Vaulted ceilings + (code 5) are deliberately excluded — they stay on column (1) per the + cohort evidence above. Worksheet-validated by simulated case 15 (the + 7536 replica): Ext1 band L → 0.18, Ext2 band F → 0.68. """ measured = _measured_u_from_description(description) if measured is not None: @@ -708,6 +808,20 @@ def u_roof( # ("Average thermal transmittance X W/m²K"); spec §5.11 opening # clause defers to the assessor's value when present. return measured + if ( + is_sloping_ceiling + and age_band is not None + and insulation_thickness_mm == 0 + and _described_as_insulated(description) + ): + # RdSAP 10 §5.11 Table 18 page 45 — a vaulted/sloping ceiling has no + # ceiling-joist void, so the "NI" sentinel (parsed to 0) + + # "insulated (assumed)" is unknown-thickness-with-insulation, not + # 0 mm uninsulated. It must NOT fall to the §5.11.4 retrofit-50 mm + # joist row (0.68) below; it takes the column (1) age-band default + # (band J = 0.16), matching the cohort's "ND" (thickness None) + # vaulted roofs which already reach col (1) by falling through. + return _ROOF_BY_AGE.get(age_band.upper(), 0.4) if insulation_thickness_mm == 0 and _described_as_insulated(description): # Spec §5.11.4 (page 44 footnote): "If retrofit insulation # present of unknown thickness use 50 mm". The cert encodes @@ -731,6 +845,15 @@ def u_roof( return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 + if is_pitched_sloping_ceiling: + # RdSAP 10 §5.11 Table 18 page 45 column (3) + roof-input item 5-5: + # an as-built "Pitched, sloping ceiling" (code 8) with no measured + # thickness takes the column (3) age-band default, not the column + # (1) loft-joist default. Note (b): column (3) "applies also to + # roof with sloping ceiling". (Pre-1950 bands reach the same value + # via the mapper's thickness=0 → Table 16 row-0 2.30 override, so + # this branch carries the post-1950 bands where col 1 ≠ col 3.) + return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) if is_flat_roof: return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) return _ROOF_BY_AGE.get(age_band.upper(), 0.4) diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 906ce3b8..00cf7164 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -165,18 +165,28 @@ def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_ro assert result == pytest.approx(1.5, abs=0.001) -def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None: - # Arrange — 147 corpus certs lodge "Cavity wall, as built, partial - # insulation (assumed)" with wall_insulation_type=4. The legacy - # production map (recommendations/rdsap_tables.py:753) routes these - # to "Filled cavity" — same destination as the "insulated (assumed)" - # case. We match that interpretation for parity with the cert - # assessor and the production recommendation engine. +def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> None: + # Arrange — a cavity lodged "Cavity wall, as built, partial insulation + # (assumed)" with wall_insulation_type=4 is in its AS-BUILT state (the + # partial fill of the age band), NOT a retrofit cavity fill. Per + # RdSAP 10 Table 6 (England) it uses the "Cavity as built" row, not + # "Filled cavity": band D = 1.5 (as built) vs 0.7 (filled). A genuine + # fill lodges the distinct "Cavity wall, filled cavity" + # (wall_insulation_type=2), caught by the explicit-code branch. + # + # Slice S0380.210 corrected this: the prior routing to "Filled cavity" + # mirrored a legacy production map, but golden cert 0390-2954-3640 + # (band F, cavity type 4, "partial insulation (assumed)") closes all + # four SAP metrics on the as-built row (band F = 1.0) and under-counts + # PE by ~28 kWh/m² on the filled row — the legacy parity was a latent + # bug at bands A-H (bands I-M coincide per the Table 6 † footnote). + # The "insulated (assumed)" variant still routes to filled (see the + # heat_transmission `_cavity_described_as_filled` sibling test). # Act result = u_wall( country=Country.ENG, - age_band="D", # 1950-1966 — typical partial-fill retrofit cohort + age_band="D", # 1950-1966 — as-built ≠ filled at this band construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, @@ -184,8 +194,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() description="Cavity wall, as built, partial insulation (assumed)", ) - # Assert — Filled-cavity row at band D = 0.7 W/m²K. - assert result == pytest.approx(0.7, abs=0.001) + # Assert — Cavity-as-built row at band D = 1.5 W/m²K (not filled 0.7). + assert abs(result - 1.5) <= 0.001 def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None: @@ -841,6 +851,54 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: assert result == pytest.approx(0.4, abs=0.001) +def test_u_roof_vaulted_ni_unknown_band_j_uses_col1_age_band_not_50mm() -> None: + # Arrange — a pitched roof with a vaulted/sloping ceiling (no joist + # void) lodged with insulation thickness "NI" (Not Indicated, parsed + # to 0) + an "insulated (assumed)" description. For a NORMAL pitched + # roof this hits the §5.11.4 "retrofit 50 mm" override (U=0.68, the + # Table 16 joist row) — but a vaulted/sloping ceiling has no joist + # void, so RdSAP 10 Table 18 routes it to the column (1) age-band + # default: band J = 0.16 W/m²K (NOT 0.68). This is the same value a + # vaulted roof lodged "ND" (thickness None) already reaches by falling + # through to the age-band default. + # + # Cohort-validated: 33 cohort-2 certs lodge "ND" vaulted roofs + # (roof_construction=5, band D) that pin to worksheet U=0.40 = col (1). + # Closes golden cert 0240's Ext1 vaulted roof (code 5, NI, band J) + # which the cascade returned at 0.68 (offsetting the wall under-count + # fixed in S0380.209). + + # Act + result = u_roof( + country=Country.ENG, + age_band="J", + insulation_thickness_mm=0, # parsed from "NI" + description="Pitched, insulated (assumed)", + is_sloping_ceiling=True, + ) + + # Assert + assert abs(result - 0.16) <= 1e-4 + + +def test_u_roof_normal_pitched_ni_insulated_still_returns_50mm_per_5_11_4() -> None: + # Arrange — regression guard: the is_sloping_ceiling flag defaults + # False, so a NORMAL pitched roof (with loft) lodged NI + "insulated + # (assumed)" must STILL hit the §5.11.4 retrofit-50 mm row (U=0.68). + # Same inputs as the sloping test above minus is_sloping_ceiling. + + # Act + result = u_roof( + country=Country.ENG, + age_band="J", + insulation_thickness_mm=0, + description="Pitched, insulated (assumed)", + ) + + # Assert + assert abs(result - 0.68) <= 1e-4 + + def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None: # Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof": # age band D, thickness unknown → U = 2.30 W/m²K. Column (1) @@ -887,6 +945,66 @@ def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None: assert abs(result - 0.18) <= 1e-4 +def test_u_roof_pitched_sloping_ceiling_as_built_band_f_uses_col3() -> None: + # Arrange — RdSAP 10 §5.11 Table 18 page 45 + roof-input item 5-5 + # ("Sloping ceiling insulation ... unknown / as built → Table 18"). + # A "Pitched, sloping ceiling" roof (roof_construction code 8) with an + # "As Built" insulation lodgement (no measured thickness → None) takes + # the Table 18 column (3) age-band default, NOT the column (1) + # "insulation between joists" default. Note (b) on column (3) states it + # "applies also to roof with sloping ceiling". For age band F the + # column (3) value is 0.68 W/m²K (vs column (1) 0.40 — the loft-joist + # assumption that is wrong for a sloping ceiling with no joist void). + # + # Worksheet-validated: simulated case 15 (7536 replica) lodges Ext2 as + # band F "PS Pitched, sloping ceiling, As Built"; its P960 worksheet + # pins `External roof Ext2 … 0.68`, and the full-cascade roof HLC and + # SAP match Elmhurst exactly only with column (3). + + # Act + result = u_roof( + country=Country.ENG, age_band="F", insulation_thickness_mm=None, + is_pitched_sloping_ceiling=True, + ) + + # Assert + assert abs(result - 0.68) <= 1e-4 + + +def test_u_roof_pitched_sloping_ceiling_as_built_band_l_uses_col3() -> None: + # Arrange — same rule at band L (2012-2022): Table 18 column (3) gives + # 0.18 W/m²K, where columns (2)/(3) coincide. Simulated case 15's Ext1 + # (band L PS sloping ceiling, As Built) pins worksheet U=0.18 (vs the + # column (1) value 0.16 the cascade returned pre-fix). + + # Act + result = u_roof( + country=Country.ENG, age_band="L", insulation_thickness_mm=None, + is_pitched_sloping_ceiling=True, + ) + + # Assert + assert abs(result - 0.18) <= 1e-4 + + +def test_u_roof_vaulted_nd_unknown_band_d_still_col1_not_col3() -> None: + # Arrange — regression guard for the discriminator: a code-5 "vaulted" + # roof lodged "ND" (thickness None) is the UNKNOWN-insulation case and + # must stay on Table 18 column (1) — band D = 0.40 — per the 33 + # cohort-2 vaulted certs (S0380.211). The col (3) routing fires only + # for code-8 "Pitched, sloping ceiling" (is_pitched_sloping_ceiling), + # NOT for vaulted ceilings, so this defaults False here and resolves + # to column (1) 0.40, NOT column (3) 2.30. + + # Act + result = u_roof( + country=Country.ENG, age_band="D", insulation_thickness_mm=None, + ) + + # Assert + assert abs(result - 0.40) <= 1e-4 + + def test_u_roof_description_no_insulation_overrides_age_band_default() -> None: # Arrange — surveyor description on a Victorian roof says uninsulated; # Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm @@ -1752,3 +1870,59 @@ def test_u_floor_matches_section_5_12_formula_for_cohort_geometry( # Assert assert abs(u - expected_u) < 1e-4 + + +def test_resolve_wall_insulation_lambda_absent_uses_default() -> None: + # Arrange — no lodged conductivity → RdSAP 10 §5.8 default 0.04 W/m·K. + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act + lam = _resolve_wall_insulation_lambda_w_per_mk(None) + + # Assert + assert abs(lam - 0.04) <= 1e-9 + + +def test_resolve_wall_insulation_lambda_unknown_string_uses_default() -> None: + # Arrange — a non-numeric "Unknown" lodgement defers to the default. + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act + lam = _resolve_wall_insulation_lambda_w_per_mk("Unknown") + + # Assert + assert abs(lam - 0.04) <= 1e-9 + + +def test_resolve_wall_insulation_lambda_code_1_is_default_mineral_wool() -> None: + # Arrange — code 1 = the §5.8 default λ=0.04 (mineral wool / EPS); + # cert 2130 Ext1 lodges this. Numeric-string form resolves identically. + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act + lam_int = _resolve_wall_insulation_lambda_w_per_mk(1) + lam_str = _resolve_wall_insulation_lambda_w_per_mk("1") + + # Assert + assert abs(lam_int - 0.04) <= 1e-9 + assert abs(lam_str - 0.04) <= 1e-9 + + +def test_resolve_wall_insulation_lambda_unmapped_code_raises() -> None: + # Arrange — an unmapped code must raise (incremental-coverage gate) + # rather than silently mis-factor the R-value. + import pytest as _pytest + + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act / Assert + with _pytest.raises(ValueError): + _resolve_wall_insulation_lambda_w_per_mk(2) diff --git a/sap worksheets/golden fixture debugging/simulated case 2/P960-0001-001431 - 2026-06-03T093549.591.pdf b/sap worksheets/golden fixture debugging/simulated case 2/P960-0001-001431 - 2026-06-03T093549.591.pdf new file mode 100644 index 00000000..8888b35a Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 2/P960-0001-001431 - 2026-06-03T093549.591.pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 2/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 2/Summary_001431 (1).pdf new file mode 100644 index 00000000..34934393 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 2/Summary_001431 (1).pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 3/P960-0001-001431 - 2026-06-03T104009.792.pdf b/sap worksheets/golden fixture debugging/simulated case 3/P960-0001-001431 - 2026-06-03T104009.792.pdf new file mode 100644 index 00000000..9e79a346 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 3/P960-0001-001431 - 2026-06-03T104009.792.pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 3/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 3/Summary_001431 (1).pdf new file mode 100644 index 00000000..d806f5b0 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 3/Summary_001431 (1).pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 4/P960-0001-001431 - 2026-06-03T105104.313.pdf b/sap worksheets/golden fixture debugging/simulated case 4/P960-0001-001431 - 2026-06-03T105104.313.pdf new file mode 100644 index 00000000..4fa42062 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 4/P960-0001-001431 - 2026-06-03T105104.313.pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 4/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 4/Summary_001431 (1).pdf new file mode 100644 index 00000000..6c31c0d8 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 4/Summary_001431 (1).pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 5/P960-0001-001431 - 2026-06-03T115608.865.pdf b/sap worksheets/golden fixture debugging/simulated case 5/P960-0001-001431 - 2026-06-03T115608.865.pdf new file mode 100644 index 00000000..55cb5940 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 5/P960-0001-001431 - 2026-06-03T115608.865.pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 5/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 5/Summary_001431 (1).pdf new file mode 100644 index 00000000..335e6242 Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 5/Summary_001431 (1).pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 6/P960-0001-001431 - 2026-06-03T130227.971.pdf b/sap worksheets/golden fixture debugging/simulated case 6/P960-0001-001431 - 2026-06-03T130227.971.pdf new file mode 100644 index 00000000..13e0183a Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 6/P960-0001-001431 - 2026-06-03T130227.971.pdf differ diff --git a/sap worksheets/golden fixture debugging/simulated case 6/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 6/Summary_001431 (1).pdf new file mode 100644 index 00000000..258f56ac Binary files /dev/null and b/sap worksheets/golden fixture debugging/simulated case 6/Summary_001431 (1).pdf differ diff --git a/scripts/analyse_api_sap_clusters.py b/scripts/analyse_api_sap_clusters.py new file mode 100644 index 00000000..ab8f0516 --- /dev/null +++ b/scripts/analyse_api_sap_clusters.py @@ -0,0 +1,102 @@ +"""Group API-path SAP error by property + heating type to find clusters. + +WHAT THIS IS FOR +---------------- +The headline number from `eval_api_sap_accuracy.py` tells you HOW accurate the +API path is; this tells you WHERE the error lives so you can prioritise. It +buckets the cached sample's per-cert SAP error (continuous vs lodged) by: + - property type (house / flat / bungalow / maisonette / park home), + - real PV presence, + - heating identity (main_heating_category + whether a PCDB index is lodged), +and prints n / mean|err| / %<0.5 per group, plus red flags (negative or +extreme-low SAP). The load-bearing cut is heating: e.g. electric storage +heaters (cat 7) and room heaters (cat 10) are the worst clusters, which points +the next worksheet-backed fix at those systems. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/analyse_api_sap_clusters.py + +Reads the cache written by `fetch_2026_epc_sample.py` (default +`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). +""" +import os +import json +import math +from collections import defaultdict +from pathlib import Path + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs + +CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) +PROP = {"0": "House", "1": "Bungalow", "2": "Flat", "3": "Maisonette", "4": "Park home"} + + +def real_pv(doc): + """True only for a genuine PV array — `none_or_no_details` / 0% is not PV.""" + es = doc.get("sap_energy_source", {}) or {} + pv = es.get("photovoltaic_supply") + if not isinstance(pv, dict): + return False + if set(pv.keys()) <= {"none_or_no_details"}: + nod = pv.get("none_or_no_details") or {} + return bool(nod.get("percent_roof_area")) + return True + + +def heat_identity(doc): + h = doc.get("sap_heating", {}) or {} + mh = (h.get("main_heating_details") or [{}]) + m0 = mh[0] if mh else {} + return m0.get("main_heating_index_number"), m0.get("main_heating_category") + + +def main(): + rows = [] + for f in sorted(CACHE.glob("????-????-????-????-????.json")): + doc = json.loads(f.read_text()) + lodged = doc.get("energy_rating_current") + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + cont = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ).sap_score_continuous + except Exception: + continue + if lodged is None or not math.isfinite(cont): + continue + idx, cat = heat_identity(doc) + rows.append(dict( + cert=f.stem, ae=abs(cont - lodged), cont=cont, lodged=lodged, + prop=PROP.get(str(doc.get("property_type")), str(doc.get("property_type"))), + pv=real_pv(doc), idx=idx, cat=cat, + neg=(cont < 0), low_lodged=(lodged <= 20), + )) + n = len(rows) + + def grp(keyfn, label): + g = defaultdict(list) + for r in rows: + g[keyfn(r)].append(r["ae"]) + print(f"\n-- mean|err| by {label} (n, mean|err|, %<0.5) --") + for k, v in sorted(g.items(), key=lambda kv: -sum(kv[1]) / len(kv[1])): + if len(v) < 5: + continue + p = 100 * sum(1 for x in v if x < 0.5) / len(v) + print(f" {str(k):28s} n={len(v):4d} mean={sum(v) / len(v):6.2f} <0.5={p:4.1f}%") + + print(f"computed n={n}") + grp(lambda r: r["prop"], "property type") + grp(lambda r: "PV" if r["pv"] else "no-PV", "real PV presence") + grp(lambda r: f"cat={r['cat']},idx={'Y' if r['idx'] else '-'}", "heating identity") + + neg = [r for r in rows if r["neg"]] + loww = [r for r in rows if r["low_lodged"]] + print(f"\nRED FLAGS: negative continuous SAP: {len(neg)} | lodged<=20 (extreme): {len(loww)}") + print(" negative-SAP certs:", [r["cert"] for r in neg][:15]) + + +if __name__ == "__main__": + main() diff --git a/scripts/eval_api_sap_accuracy.py b/scripts/eval_api_sap_accuracy.py new file mode 100644 index 00000000..7f1dd86d --- /dev/null +++ b/scripts/eval_api_sap_accuracy.py @@ -0,0 +1,169 @@ +"""Score the SAP10 calculator's API path against a cached EPC sample. + +WHAT THIS IS FOR +---------------- +Measures how well the API front-end (`from_api_response` → `cert_to_inputs` +→ continuous SAP) reproduces each cert's lodged rounded SAP +(`energy_rating_current`) across the sample built by +`fetch_2026_epc_sample.py`. This is the headline accuracy gauge for raw-API +behaviour on an unbiased population. + +Each cert lands in one bucket: + - computed — ran end-to-end; SAP error recorded. + - unsupported_schema — pre-21 schema the mapper doesn't support (skip). + - raise: — mapper raised (UnmappedApiCode etc.) — a gap to fix. + - calc_raise: — calculator raised (UnmappedSapCode etc.) — a gap. + +OUTPUT +------ + - Category counts + the raise breakdown with example certs (what to fix). + - For computed certs: % within 0.5 / 1 / 2 / 5 SAP, median/mean/p90/p99/max + |err|, the signed mean (over- vs under-rating), abs-err histogram. + - The 40 worst offenders with diagnostic columns (to prioritise). + - A full per-cert CSV at /_results.csv for ad-hoc slicing. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py + +Reads the cache written by `fetch_2026_epc_sample.py` (default +`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). +""" +import os +import json +import csv +import math +from collections import Counter, defaultdict +from pathlib import Path + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs + +CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) + + +def diag(doc): + """A few raw-JSON fields that help explain a cert's error at a glance.""" + es = doc.get("sap_energy_source", {}) or {} + h = doc.get("sap_heating", {}) or {} + mh = (h.get("main_heating_details") or [{}]) + mh0 = mh[0] if mh else {} + pv = es.get("photovoltaic_supply") + return { + "schema": doc.get("schema_type"), + "prop_type": doc.get("property_type"), + "built_form": doc.get("built_form"), + "age_band": doc.get("construction_age_band"), + "mains_gas": es.get("mains_gas"), + "main_heat_cat": mh0.get("main_heating_category"), + "main_heat_idx": mh0.get("main_heating_index_number"), + "n_bps": len(doc.get("sap_building_parts") or []), + "lodged_band": doc.get("current_energy_efficiency_band"), + } + + +def main(): + files = sorted(CACHE.glob("????-????-????-????-????.json")) + rows = [] + cat = Counter() + exc_examples = defaultdict(list) + for f in files: + cert = f.stem + try: + doc = json.loads(f.read_text()) + except Exception: + cat["bad_json"] += 1 + continue + lodged = doc.get("energy_rating_current") + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + except ValueError as e: + if "Unsupported EPC schema" in str(e): + cat["unsupported_schema"] += 1 + else: + cat["raise:ValueError"] += 1 + exc_examples["ValueError:" + str(e)[:60]].append(cert) + continue + except Exception as e: + ename = type(e).__name__ + cat[f"raise:{ename}"] += 1 + exc_examples[f"{ename}:{str(e)[:60]}"].append(cert) + continue + if lodged is None: + cat["no_lodged_sap"] += 1 + continue + try: + cont = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ).sap_score_continuous + except Exception as e: + ename = type(e).__name__ + cat[f"calc_raise:{ename}"] += 1 + exc_examples[f"calc:{ename}:{str(e)[:50]}"].append(cert) + continue + if not math.isfinite(cont): + cat["non_finite"] += 1 + continue + err = cont - lodged + cat["computed"] += 1 + rows.append({ + "cert": cert, "our_cont": round(cont, 4), "lodged": lodged, + "err": round(err, 4), "abs_err": round(abs(err), 4), **diag(doc), + }) + + if rows: + keys = list(rows[0].keys()) + with open(CACHE / "_results.csv", "w", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=keys) + w.writeheader() + w.writerows(rows) + + n = len(rows) + print("=" * 70) + print(f"SAMPLE: {len(files)} cached certs | categories:") + for k, v in cat.most_common(): + print(f" {k:28s} {v}") + if n == 0: + return + abs_errs = sorted(r["abs_err"] for r in rows) + + def pct(thr): + return 100.0 * sum(1 for r in rows if r["abs_err"] < thr) / n + + print("=" * 70) + print(f"COMPUTED: {n} certs (continuous SAP vs lodged rounded)") + print(f" % |err| < 0.5 : {pct(0.5):.1f}% <-- headline") + print(f" % |err| < 1.0 : {pct(1.0):.1f}%") + print(f" % |err| < 2.0 : {pct(2.0):.1f}%") + print(f" % |err| < 5.0 : {pct(5.0):.1f}%") + print(f" median |err| : {abs_errs[n // 2]:.3f}") + print(f" mean |err| : {sum(abs_errs) / n:.3f}") + print(f" p90 |err| : {abs_errs[int(n * 0.90)]:.3f}") + print(f" p99 |err| : {abs_errs[int(n * 0.99)]:.3f}") + print(f" max |err| : {abs_errs[-1]:.3f}") + signed = [r["err"] for r in rows] + print(f" mean signed err: {sum(signed) / n:+.3f} (we - lodged; +ve = we over-rate)") + print(" abs-err buckets:") + for lo, hi in [(0, 0.5), (0.5, 1), (1, 2), (2, 5), (5, 10), (10, 1e9)]: + c = sum(1 for r in rows if lo <= r["abs_err"] < hi) + print(f" [{lo:>4}, {hi:>4}) : {c:4d} ({100 * c / n:4.1f}%)") + print("=" * 70) + print("TOP 40 LARGEST |err| (prioritise these):") + worst = sorted(rows, key=lambda r: -r["abs_err"])[:40] + print(f" {'cert':22s} {'err':>7s} {'our':>6s} {'lodg':>4s} prop bf age gas cat/idx bps") + for r in worst: + print(f" {r['cert']:22s} {r['err']:+7.2f} {r['our_cont']:6.1f} {r['lodged']:4d} " + f"{str(r['prop_type']):>4s} {str(r['built_form']):>2s} {str(r['age_band'])[:3]:>3s} " + f"{str(r['mains_gas']):>3s} {str(r['main_heat_cat']):>3s}/{str(r['main_heat_idx']):>6s} " + f"{r['n_bps']}") + if exc_examples: + print("=" * 70) + print("RAISE/ERROR EXAMPLES (mapper/calculator gaps — also prioritise):") + for k, v in sorted(exc_examples.items(), key=lambda kv: -len(kv[1]))[:20]: + print(f" [{len(v):3d}] {k} e.g. {v[0]}") + print(f"\nFull per-cert CSV -> {CACHE / '_results.csv'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/fetch_2026_epc_sample.py b/scripts/fetch_2026_epc_sample.py new file mode 100644 index 00000000..7c16ae3e --- /dev/null +++ b/scripts/fetch_2026_epc_sample.py @@ -0,0 +1,145 @@ +"""Fetch a random sample of domestic EPC JSONs from the GOV.UK EPB register. + +WHAT THIS IS FOR +---------------- +Wide-scale accuracy testing of the SAP10 calculator's API front-end against +real-world certificates (not the curated golden cohort, which masks raw-API +behaviour). This script builds the *input corpus*: it samples certificate +numbers uniformly at random across a date window, then downloads each cert's +full schema-21 ``data`` payload (the exact shape +``EpcPropertyDataMapper.from_api_response`` consumes) into a local cache. + +Pair it with: + - ``eval_api_sap_accuracy.py`` — % within 0.5 SAP, worst offenders, raises. + - ``analyse_api_sap_clusters.py`` — error grouped by heating type / property. + +HOW THE SAMPLE IS DRAWN +----------------------- +The register's ``/api/domestic/search`` endpoint is date-windowed and paged +(``date_start``/``date_end``/``current_page``/``page_size``); results are +ordered by registration date, so picking random PAGES across the whole window +gives an unbiased spread over dates, regions and property types. Each chosen +cert number is then resolved to its full JSON via ``/api/certificate``. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/fetch_2026_epc_sample.py + +Resumable — re-running skips certs already cached, so it's safe to interrupt. +Token is read from ``backend/.env`` (``OPEN_EPC_API_TOKEN``). NB the register +rejects a ``date_end`` that includes today, so keep the window in the past. + +Tune the constants below (window, page count, target size, seed). The cache +dir defaults to ``/tmp/epc_2026_sample`` and can be overridden with the +``EPC_SAMPLE_CACHE`` env var. +""" +import os +import json +import time +import random +import threading +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed + +import httpx +from dotenv import load_dotenv + +load_dotenv("backend/.env") +TOKEN = os.environ["OPEN_EPC_API_TOKEN"] +BASE = "https://api.get-energy-performance-data.communities.gov.uk" +H = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"} +CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) +CACHE.mkdir(parents=True, exist_ok=True) + +# Sampling window + size. `date_end` must be strictly before today (the +# register rejects "the date cannot include today"). TOTAL_PAGES is the +# `totalPages` the search returns for this window at page_size=100 — re-probe +# it if you change the window (it only needs to be an upper bound for the +# random page draw; out-of-range pages just return fewer rows). +WINDOW = {"date_start": "2026-01-01", "date_end": "2026-05-31"} +TOTAL_PAGES = 7402 +N_PAGES = 14 # random pages to pull → N_PAGES * 100 candidate certs +TARGET = 1200 # cap on how many full JSONs to fetch +random.seed(2026) # reproducible page draw + + +def _get(url, params, timeout=20.0, tries=5): + """GET with retry/backoff on 429 + 5xx (honours Retry-After).""" + r = None + for i in range(tries): + try: + r = httpx.get(url, params=params, headers=H, timeout=timeout) + except httpx.HTTPError: + time.sleep(1.5 * (i + 1)) + continue + if r.status_code == 429 or r.status_code >= 500: + ra = r.headers.get("Retry-After") + time.sleep(float(ra) if ra else 1.5 * (i + 1)) + continue + return r + return r + + +def sample_cert_numbers(): + pages = sorted(random.sample(range(1, TOTAL_PAGES + 1), N_PAGES)) + certs = {} + for p in pages: + r = _get(f"{BASE}/api/domestic/search", {**WINDOW, "current_page": p, "page_size": 100}) + if r is None or not r.is_success: + print(f" search page {p} -> {getattr(r, 'status_code', 'ERR')}") + continue + for row in r.json().get("data", []): + certs[row["certificateNumber"]] = row.get("registrationDate") + print(f" page {p}: cumulative {len(certs)} certs") + return certs + + +_lock = threading.Lock() +_done = {"ok": 0, "404": 0, "err": 0} + + +def fetch_one(cert): + out = CACHE / f"{cert}.json" + if out.exists(): + with _lock: + _done["ok"] += 1 + return + r = _get(f"{BASE}/api/certificate", {"certificate_number": cert}) + if r is not None and r.status_code == 404: + with _lock: + _done["404"] += 1 + return + if r is None or not r.is_success: + with _lock: + _done["err"] += 1 + return + try: + payload = r.json()["data"] + except Exception: + with _lock: + _done["err"] += 1 + return + out.write_text(json.dumps(payload)) + with _lock: + _done["ok"] += 1 + if _done["ok"] % 100 == 0: + print(f" fetched {_done['ok']} (404={_done['404']} err={_done['err']})") + + +def main(): + print("sampling cert numbers...") + certs = sample_cert_numbers() + cert_list = list(certs)[:TARGET] + (CACHE / "_manifest.json").write_text( + json.dumps({"certs": cert_list, "window": WINDOW}, indent=2) + ) + print(f"fetching {len(cert_list)} cert JSONs into {CACHE} ...") + t0 = time.time() + with ThreadPoolExecutor(max_workers=8) as ex: + list(as_completed([ex.submit(fetch_one, c) for c in cert_list])) + print(f"DONE in {time.time() - t0:.0f}s: ok={_done['ok']} 404={_done['404']} err={_done['err']}") + print(f"cached JSON files: {len(list(CACHE.glob('????-????-????-????-????.json')))}") + + +if __name__ == "__main__": + main() diff --git a/tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json b/tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json new file mode 100644 index 00000000..f402740f --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json @@ -0,0 +1,516 @@ +{ + "uprn": 77048251, + "roofs": [ + { + "description": "Pitched, 300 mm loft insulation", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "addendum_numbers": [ + 15 + ] + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "M22 1UR", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "MANCHESTER", + "built_form": 2, + "created_at": "2026-06-03 14:45:36", + "door_count": 2, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "number_baths": 0, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 15709 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.48, + "window_height": 0.92, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.57, + "window_height": 1.12, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.44, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.54, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 8, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.48, + "window_height": 0.92, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.61, + "window_height": 1.05, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 1, + "window_height": 1.24, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.83, + "window_height": 1.07, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.56, + "window_height": 1.13, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.02, + "window_height": 1.17, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.93, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.81, + "window_height": 1.29, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 4, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.54, + "window_height": 1.32, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.93, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.93, + "window_height": 1.13, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.99, + "window_height": 1.05, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 2, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "49 Cotefield Road", + "assessment_type": "RdSAP", + "completion_date": "2026-06-03", + "inspection_date": "2026-06-03", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 107, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 6, + "registration_date": "2026-06-03", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 53.63, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.63, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 26.13, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "total_floor_area": { + "value": 53.63, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.63, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 21.78, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "D", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "300mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 6, + "heating_cost_current": { + "value": 1131, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.9, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 63, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 1031, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 218, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 100, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 223, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 76 + } + ], + "co2_emissions_potential": 2.5, + "energy_rating_potential": 76, + "lighting_cost_potential": { + "value": 63, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 218, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2512.47, + "space_heating_existing_dwelling": 9580.15 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 154, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 130, + "environmental_impact_current": 73, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 76, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 27, + "low_energy_fixed_lighting_bulbs_count": 14, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json b/tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json new file mode 100644 index 00000000..421d0d6f --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json @@ -0,0 +1,523 @@ +{ + "uprn": 77079925, + "roofs": [ + { + "description": "Pitched, 100 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": "Pitched, insulated (assumed)", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "M22 4FA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "MANCHESTER", + "built_form": 2, + "created_at": "2026-06-03 10:48:51", + "door_count": 2, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 2 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17741 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.98, + "window_height": 0.93, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.4, + "window_height": 0.74, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.49, + "window_height": 1.99, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.13, + "window_height": 1.06, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.17, + "window_height": 0.75, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.92, + "window_height": 0.94, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 8, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.01, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 8, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.41, + "window_height": 0.85, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.03, + "window_height": 1.26, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.12, + "window_height": 0.92, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.63, + "window_height": 0.98, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 2.64, + "window_height": 1.2, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.55, + "window_height": 1.22, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "75 Kenworthy Lane", + "assessment_type": "RdSAP", + "completion_date": "2026-06-03", + "inspection_date": "2026-06-03", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 83, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2026-06-03", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.43, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 35.07, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 4.29, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 11.12, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "total_floor_area": { + "value": 35.67, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 4.84, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 12.21, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.43, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 6.07, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 1.2, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 6.26, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "total_floor_area": { + "value": 6.07, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 1.2, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 6.26, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": { + "value": 1013, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": { + "value": 53, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 926, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 292, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 87, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 224, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 76 + } + ], + "co2_emissions_potential": 2.1, + "energy_rating_potential": 73, + "lighting_cost_potential": { + "value": 53, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 292, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2105.06, + "space_heating_existing_dwelling": 8424.88 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 174, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 146, + "environmental_impact_current": 73, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 76, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 29, + "low_energy_fixed_lighting_bulbs_count": 9, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 40ba7aff..fc95112c 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -14,10 +14,18 @@ area fraction); SAP 10.3 specification (13-01-2026) Tables 4a/4e/12. from __future__ import annotations -from typing import Final +from typing import Final, Optional import pytest +from datatypes.epc.domain.mapper import ( + _map_elmhurst_room_in_roof, # pyright: ignore[reportPrivateUsage] +) +from datatypes.epc.surveys.elmhurst_site_notes import ( + RoomInRoof as ElmhurstRoomInRoof, + RoomInRoofSurface as ElmhurstRoomInRoofSurface, +) + from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, @@ -44,16 +52,20 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] + _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] + _heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _primary_loss_applies, # pyright: ignore[reportPrivateUsage] _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] + _secondary_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] _section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage] _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] @@ -1854,6 +1866,137 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() assert sep_immersion is False +def test_separately_timed_dhw_excludes_dedicated_water_heater_per_table_2b_note_b() -> None: + # Arrange — SAP 10.2 Table 2b note b) (PDF p.159) applies the ×0.9 + # temperature-factor reduction only when DHW "is separately timed" + # relative to space heating on a SHARED heat generator ("boiler + # systems, warm air systems and heat pump systems"). Per RdSAP 10 + # §10.5.1 (PDF p.55) a separate boiler/circulator providing DHW only + # (water-heating code 911 = "Gas boiler/circulator for water heating + # only") is NOT the main space-heating system — here space is by + # electric storage heaters (SAP code 402). With no shared generator + # there is no separate DHW timer to apply the ×0.9 against, so the + # multiplier must not fire — the same principle as the WHC 903 + # electric-immersion carve-out above. Simulated case 19's worksheet + # confirms it: cylinder thermostat present + "Separate Time Control: + # No" → (53) Temperature factor 0.6000 (base, not 0.54 = 0.6 × 0.9) + # AND (59)m primary loss h=5 (winter Jan 64.5792), not h=3 (43.31). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, # storage-heater auto-charge control + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters + ) + dedicated_gas_water_heater_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[storage_heater_main], + water_heating_fuel=26, # mains gas (dedicated WHS boiler) + water_heating_code=911, # gas boiler/circulator, water only + cylinder_size=4, + cylinder_insulation_type=2, # loose jacket + cylinder_insulation_thickness_mm=50, + ), + ) + + # Act + separately_timed = _separately_timed_dhw( + dedicated_gas_water_heater_epc, storage_heater_main, + ) + + # Assert — dedicated water-heating-only system → not separately timed. + assert separately_timed is False + + +def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate() -> None: + # Arrange — SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating + # is a direct-acting electric room heater (RdSAP 10 §A.2.2 default), + # which sits on the "Other systems including direct-acting electric" + # row. For a 7-hour (Economy-7) tariff that row's high-rate fraction + # is 1.00 — ALL secondary consumption bills at the high rate, NOT the + # off-peak low rate that storage-heater charging earns. Simulated + # case 19's worksheet (242) is the evidence: "Space heating - + # secondary (1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. Pre- + # slice `_secondary_fuel_cost_gbp_per_kwh` returned the 7-hour low + # rate 5.50 p (£0.0550) for every off-peak electric secondary, + # under-charging by 9.79 p/kWh × the secondary kWh (~£340 on case 19). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters + ) + dual_meter_off_peak_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_heating=make_sap_heating( + main_heating_details=[storage_heater_main], + # secondary_fuel_type omitted → §A.2.2 portable electric default + ), + ) + + # Act + secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh( + dual_meter_off_peak_epc.sap_heating, + storage_heater_main, + 1, # Dual meter → 7-hour off-peak tariff + SAP_10_2_SPEC_PRICES, + ) + + # Assert — 1.00 × 15.29 p + 0.00 × 5.50 p = 15.29 p/kWh = £0.1529. + assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6 + + +def test_sap_table_3_primary_loss_applies_to_dedicated_water_heating_boiler_circulator() -> None: + # Arrange — SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss + # applies when "hot water is heated by a heat generator (e.g. boiler) + # connected to a hot water storage vessel via insulated or + # uninsulated pipes". The dedicated "boiler/circulator for water + # heating only" water-heating codes (Table 4a hot-water section, PDF + # p.166): 911 gas, 912 liquid fuel, 913 solid fuel, 921-931 range + # cooker with boiler — each is a boiler feeding the cylinder through a + # primary loop, so the loss applies regardless of what the SPACE + # heating system is. Simulated case 19 pairs electric storage heaters + # (SAP code 402) for space with a WHS 911 gas boiler/circulator for + # water: `_water_heating_main` resolves to the code-402 storage main + # (electric, no primary loop), so before this slice every dedicated- + # boiler branch missed the cylinder's primary circuit and (59)m went + # to zero — dropping the worksheet's 676.68 kWh/yr (59) and inflating + # HW fuel (219). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters (space) + ) + + # Act + applies_with_cylinder = _primary_loss_applies( + storage_heater_main, True, None, water_heating_code=911, + ) + applies_without_cylinder = _primary_loss_applies( + storage_heater_main, False, None, water_heating_code=911, + ) + + # Assert — WHS 911 + cylinder → primary loss applies; no cylinder → + # no primary circuit, no loss. + assert applies_with_cylinder is True + assert applies_without_cylinder is False + + def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two # efficiency columns: "space" and "water". For low-temperature @@ -2120,6 +2263,70 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 +def _placeholder_rir_surfaces() -> "list[ElmhurstRoomInRoofSurface]": + # A §3.9.1 Simplified Room-in-Roof lodges the roof-going Length/Height + # cells as placeholders (a 40 m flat-ceiling height, a 32 m slope on a + # 4.65 m gable) — Elmhurst ignores them and derives the area from the + # floor area. Gables ARE measured (4.65 × 2.45 = 11.39). + def surf( + name: str, length: float, height: float, + gable_type: Optional[str] = None, default_u: Optional[float] = None, + ) -> "ElmhurstRoomInRoofSurface": + return ElmhurstRoomInRoofSurface( + name=name, length_m=length, height_m=height, insulation="", + insulation_type=None, gable_type=gable_type, + default_u_value=default_u, u_value_known=False, u_value=0.0, + ) + + return [ + surf("Flat Ceiling 1", 4.00, 40.00), # placeholder + surf("Slope 1", 32.00, 32.00), # placeholder + surf("Gable Wall 1", 4.65, 2.45, gable_type="Exposed", default_u=0.29), + surf("Gable Wall 2", 4.65, 2.45, gable_type="Party", default_u=0.25), + ] + + +def test_elmhurst_simplified_rir_drops_placeholder_roof_surfaces() -> None: + # Arrange — RdSAP 10 §3.9.1 (PDF p.21): a Simplified RR's slope / + # flat ceiling / stud wall are not measured; emitting their + # placeholder L×H as `detailed_surfaces` makes the cascade bill them + # as explicit roof area (7.5× heat-loss explosion) instead of firing + # the spec's `A_RR = 12.5√(A_floor/1.5) − Σwalls` residual formula. + rir = ElmhurstRoomInRoof( + floor_area_m2=29.75, construction_age_band="A", + assessment="Simplified Type 1", surfaces=_placeholder_rir_surfaces(), + ) + + # Act + mapped = _map_elmhurst_room_in_roof(rir) + + # Assert — roof-going surfaces dropped, both gables retained. + assert mapped is not None + kinds = sorted(s.kind for s in (mapped.detailed_surfaces or [])) + assert "slope" not in kinds + assert "flat_ceiling" not in kinds + assert kinds == ["gable_wall", "gable_wall_external"] + + +def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None: + # Arrange — a Detailed (§3.10) assessment DOES measure slope / flat + # ceiling, so they must be retained (regression guard so the + # Simplified drop doesn't bleed into Detailed lodgements). + rir = ElmhurstRoomInRoof( + floor_area_m2=29.75, construction_age_band="A", + assessment="Detailed", surfaces=_placeholder_rir_surfaces(), + ) + + # Act + mapped = _map_elmhurst_room_in_roof(rir) + + # Assert — slope + flat ceiling retained under the Detailed path. + assert mapped is not None + kinds = sorted(s.kind for s in (mapped.detailed_surfaces or [])) + assert "slope" in kinds + assert "flat_ceiling" in kinds + + def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None: # Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas # boilers (including mains gas, LPG and biogas)". The code identifies @@ -3886,6 +4093,110 @@ def test_air_source_heat_pump_pcdb_104568_derives_apm_efficiencies_per_sap_app_n ) +def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_branch() -> None: + """SAP 10.2 Table 2 (PDF p.158) Note 1 gives a SEPARATE storage loss + factor for a loose-jacket cylinder: L = 0.005 + 1.76 / (t + 12.8), + ~2× the factory-insulated L = 0.005 + 0.55 / (t + 4.0) at the same + thickness. The EPB API lodges cylinder_insulation_type=2 = loose + jacket (1 = factory-applied). Before this fix + `_cylinder_storage_loss_override` returned None for every non-factory + type, so a loose-jacket cylinder fell to the zero-storage-loss combi + default — a systematic HW under-count (a 2026 register sample of 22 + such certs over-predicted SAP by +2.29 mean). The override must route + insulation_type=2 to the Table 2 loose-jacket branch. + """ + # Arrange — identical to the factory storage-loss test but + # cylinder_insulation_type=2 (loose jacket) instead of 1. + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=2, # loose jacket + cylinder_insulation_thickness_mm=50, + cylinder_thermostat="Y", + ), + ) + # Expected (56)m Jan from the Table 2 loose-jacket branch (same V / + # VF / TF as the factory test — only the loss factor L differs). + loss_factor = cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=50.0 + ) + vol_factor = cylinder_volume_factor_table_2a(160.0) + temp_factor = cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31 + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — non-None (was the zero-loss default) and equal to the + # loose-jacket branch, distinctly larger than the factory 36.9530. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + assert got_jan_kwh > 36.9530 # loose jacket loses more than factory + + +def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None: + """RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating + system is lodged, the default cylinder takes the age-band insulation, + and "Age band of main property A to F: 12 mm loose jacket". Bands + A-F previously raised UnmappedSapCode because the loose-jacket storage + loss branch wasn't plumbed (now it is, S0380.224). A band-B cert must + resolve to a 12 mm loose-jacket cylinder; band G stays 25 mm factory. + """ + from domain.sap10_calculator.rdsap.cert_to_inputs import _apply_rdsap_no_water_heating_system_default # pyright: ignore[reportPrivateUsage] + + def _no_dhw_epc(age_band: str) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band=age_band)], + sap_heating=make_sap_heating(water_heating_code=999), + ) + + # Act — band B (A-F band) + band G (factory band, regression guard). + band_b = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("B")) + band_g = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("G")) + + # Assert — band B → 12 mm loose jacket (type 2); band G → 25 mm + # factory (type 1). Both gain the immersion + 110 L cylinder default. + assert band_b.has_hot_water_cylinder is True + assert band_b.sap_heating.cylinder_insulation_type == 2 # loose jacket + assert band_b.sap_heating.cylinder_insulation_thickness_mm == 12 + assert band_g.sap_heating.cylinder_insulation_type == 1 # factory + assert band_g.sap_heating.cylinder_insulation_thickness_mm == 25 + + def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None: """SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary circuit loss for an indirect cylinder: @@ -5744,3 +6055,105 @@ def test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_table_4b_boi f"want {expected_hw_fuel!r} per SAP 10.2 Appendix D §D2.1 (2) " f"Equation D1 with Table 4b code 127 (winter 84%, summer 72%)" ) + + +def test_heat_network_community_gas_fuel_translates_epc_20_to_table12_51() -> None: + # Arrange — a community mains-gas BOILER main (SAP code 301) lodges + # main_fuel_type=20. Per epc_codes.csv (RdSAP-Schema-17.0) EPC fuel 20 + # is "mains gas (community)", but the SAP Table 12 / Table 32 numbering + # uses 20 for a solid biomass fuel — a collision. The factor lookups + # check the Table-12 dict first, so co2_factor_kg_per_kwh(20) returns + # the biomass 0.028 instead of community mains gas 0.210. The + # heat-network fuel-code translator must route EPC 20 → Table 12 51. + from domain.sap10_calculator.tables.table_12 import ( + co2_factor_kg_per_kwh, + primary_energy_factor, + ) + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # EPC "mains gas (community)" + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, # heat network + sap_main_heating_code=301, # community boilers + ) + + # Act + code = _heat_network_factor_fuel_code(main) + + # Assert — translates to Table 12 code 51 (community mains gas), and the + # factor lookups then return the worksheet-validated case-14 values + # ((367) CO2 0.2100, (467) PE 1.1300), NOT the collided biomass factors. + assert code == 51 + assert abs(co2_factor_kg_per_kwh(code) - 0.210) <= 1e-9 + assert abs(primary_energy_factor(code) - 1.130) <= 1e-9 + assert abs(co2_factor_kg_per_kwh(20) - 0.028) <= 1e-9 # the collided value + + +def test_non_heat_network_biomass_fuel_not_translated() -> None: + # Arrange — a NON-heat-network main lodging the same integer fuel code + # must NOT be translated: a genuine biomass boiler keeps its raw + # Table-12 factor. The translator only fires for heat-network mains. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, # ordinary boiler, NOT heat network + sap_main_heating_code=102, + ) + + # Act + code = _heat_network_factor_fuel_code(main) + + # Assert — unchanged (raw code, biomass factor preserved). + assert code == 20 + + +def test_heat_network_space_and_water_standing_charge_is_full_120() -> None: + # Arrange — a heat-network SPACE main (SAP code 301) carries the full + # Table 12 (PDF p.191) heat-network standing charge of £120/yr per + # §C3.2 ("the total standing charge is the normal heat network standing + # charge" when space heating is on the network). Worksheet-validated: + # case 14 (community boilers + mains gas, space + water) → (351) £120. + # The epc is not consulted on this branch (heat-network space main wins + # first), so a minimal epc suffices. + epc = _typical_semi_detached_epc() + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # EPC mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act + standing = _heat_network_standing_charge_gbp(epc, main) + + # Assert + assert standing is not None + assert abs(standing - 120.0) <= 1e-9 + + +def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> None: + # Arrange — a non-heat-network gas-boiler main must NOT draw the + # heat-network standing branch; the helper returns None so the caller + # falls back to the fuel-based `additional_standing_charges_gbp`. + epc = _typical_semi_detached_epc() + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, # mains gas (not community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, + ) + + # Act / Assert + assert _heat_network_standing_charge_gbp(epc, main) is None diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index dc000956..4bea5f15 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, cert_to_demand_inputs, cert_to_inputs, + heat_transmission_section_from_cert, ) _FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden" @@ -82,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+5.8007, - expected_co2_resid_tonnes_per_yr=+0.3173, + expected_pe_resid_kwh_per_m2=+1.5181, + expected_co2_resid_tonnes_per_yr=+0.0728, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -120,7 +121,103 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "extract-fans default (age J, 4 hab rooms → 2 fans). " "Cascade ventilation HLC rises ~0.07 ACH × volume → SH " "demand rises proportionally; PE +2.5225 → +5.8007, CO2 " - "+0.1395 → +0.3173. SAP integer unchanged at 72." + "+0.1395 → +0.3173. SAP integer unchanged at 72. " + "Slice S0380.196 (the 6035 RR fix) also applies here: this " + "cert's `room_in_roof_type_1` lodges two gables (L=6.4, both " + "Party) with no height, previously dropped → roof over-count. " + "Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) " + "from the A_RR shell → roof drops, tightening PE +5.8007 → " + "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72. " + "Slice S0380.198 CLOSED the SAP: this cert lodges 6 windows " + "with `window_wall_type=4` = roof windows ('Roof of Room' " + "rooflights). The API mapper had flattened them into " + "`sap_windows` (vertical glazing, (27), U=2.0); they belong on " + "(27a) at the Table 6e Note 2 inclination-adjusted U=2.30 with " + "45°-inclined solar gains. Validated against the simulated-" + "case-6 worksheet ((27a) U_eff 2.1062). The inclined solar " + "gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 " + "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226. " + "Slice S0380.201 added the SECOND main heating system's " + "circulation pump per SAP 10.2 Table 4f note c) (PDF p.175) " + "\"Where there are two main heating systems include two " + "figures from this table\" — gated on a lodged " + "main_heating_fraction > 0 (a genuine second SPACE-heating " + "main, excluding DHW-only second mains). This cert is dual-" + "main oil combi 51%/49%; Main 2 pump_age unknown (115 kWh) " + "joins Main 1's 115 → cascade pumps_fans 315 → 430 (+115 " + "kWh/yr). The fix was validated against the simulated-case-6 " + "worksheet, whose (231) = 356 decomposes as (230c) central-" + "heating pump 156 (= Main 1 41 + Main 2 115) + (230d) oil " + "boiler pump 200 — proving the two-pump treatment is spec-" + "correct. Cascade SAP cont 72.55 → 72.18 (integer 73 → 72, " + "resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → " + "+0.1385. The lodged 73 carries Elmhurst's own rounding/" + "residual (this cert is API-only with no worksheet); the " + "worksheet-backed case 6 is the spec authority for the " + "archetype per [[feedback-worksheet-not-api-reference]]. " + "Slice S0380.202 added the SECOND main's central-heating-pump " + "GAIN per SAP 10.2 Table 5a note a) (PDF p.177) \"two main " + "heating systems serving different parts ... include two " + "figures\" — the §5 (70) mirror of S0380.201's Table 4f (230c). " + "Both Main 1 + Main 2 unknown-date → (70) 7 → 14 W. The extra " + "internal gain lowers space-heating demand → SAP cont 72.18 → " + "72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 " + "+0.1385 → +0.1269 (both closer to zero). Validated against " + "case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2). " + "Slice S0380.203 routed this cert's 6 'Roof of Room' rooflights " + "(window_wall_type=4) to deduct from the §3.10.1 RR residual " + "instead of the regular roof (the case-6 worksheet rule). 0240 " + "is detailed-RR gables-only like case 6 → roof drops → space-" + "heating demand falls → PE +2.5812 → +2.1519, CO2 +0.1269 → " + "+0.1051 (both closer to zero; SAP integer 72 unchanged). " + "Slice S0380.205 applied the SAP 10.2 p.186 two-systems-" + "different-parts MIT (Main 1 2106 type 2 / Main 2 2110 type 3, " + "emitter 2 R=0.75): weighted responsiveness 0.8775 + elsewhere " + "two-control blend. Lowers MIT ~0.037 °C → space-heating demand " + "falls → PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both " + "closer to zero; SAP integer 72 unchanged). Verified 1e-4 " + "against the case-6 worksheet (87)/(90)/(98c). " + "Slice S0380.206 fed Eq D1 the DHW boiler's OWN (204) space " + "share (Main 1 = 51%) instead of the dwelling total (202) — " + "the worksheet-validated case-6 fix that lands its (219) HW " + "exact. For 0240 this raises HW fuel slightly → PE +1.6893 → " + "+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged " + "73 carries Elmhurst's own residual; case 6 is the spec " + "authority per [[feedback-worksheet-not-api-reference]]. " + "Slice S0380.209 fixed the API-path wall U: the EPC renders " + "this cert's sandstone (band J, As Built) wall as 'insulated " + "(assumed)', which the cascade wrongly routed to the 50 mm " + "retrofit row (U 0.25). Per RdSAP 10 Table 8/9 footnote the " + "50 mm row is only for insulation 'known to have been " + "increased subsequently'; an 'as built ... (assumed)' " + "description is the age-band assumption (renders only on " + "recent bands) → as-built row U 0.35. Worksheet-validated by " + "simulated case 9 (sandstone J → 0.35) + case 10 (solid brick " + "J → 0.35). walls 24.45 → 34.23 W/K → PE +1.8687 → +5.5044, " + "CO2 +0.0907 → +0.2757 (SAP 72 unchanged). This spec-correct " + "fix REMOVED the wall under-count that was masking the Ext1 " + "vaulted-roof over-count (cascade U 0.68 via the same " + "'insulated (assumed)' description vs case-9 sloping-ceiling " + "0.25) — that roof over-count is the next slice; fixing both " + "lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 " + "requires a 2013+ pump (case 7); 0240's API lodges the pump " + "as Unknown (code 0 → 115, proven 0=Unknown across 9 API+" + "Summary pairs), so 73 is unreachable from the lodged inputs. " + "Slice S0380.211 fixed the Ext1 vaulted-roof over-count S0380.209 " + "exposed: BP2 lodges roof_construction=5 (vaulted), NI thickness, " + "'Pitched, insulated (assumed)', band J → the cascade returned " + "U 0.68 (the §5.11.4 retrofit-50 mm joist row). A vaulted ceiling " + "has no joist void, so per RdSAP 10 Table 18 it takes the column " + "(1) age-band default (band J = 0.16) — the SAME value the 33 " + "cohort-2 'ND' vaulted roofs (code 5, band D → 0.40 = col 1) " + "reach by falling through. New u_roof `is_sloping_ceiling` flag " + "(threaded from heat_transmission for codes 5/8) routes the 'NI' " + "variant to col (1) too. roof 76.93 → ~68 W/K → PE +5.5044 → " + "+1.5181, CO2 +0.2757 → +0.0728 (SAP integer 72 unchanged — the " + "true value; the lodged 73 needs the unpreserved 2013+ pump). " + "NB the S0380.209 note's predicted 'cont ≈ 72.31 (case 9, U 0.25 " + "col 3)' was an unconfirmed guess; the cohort's 'ND' vaulted " + "roofs are the arbiter and use col (1) 0.16 → cont 72.4617." ), ), _GoldenExpectation( @@ -154,9 +251,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0390-2954-3640-2196-4175", actual_sap=60, - expected_sap_resid=+7, - expected_pe_resid_kwh_per_m2=-27.9745, - expected_co2_resid_tonnes_per_yr=-2.7134, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+0.5281, + expected_co2_resid_tonnes_per_yr=-0.1189, notes=( "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " @@ -187,15 +284,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "Slice S0380.151 wired RdSAP 10 §4.1 Table 5 (PDF p.28) " "extract-fans default (age F → 1 fan). Cascade ventilation " "HLC rises ~0.03 ACH × volume; PE -28.0830 → -27.9745 " - "(closer to zero), CO2 -2.7342 → -2.7134." + "(closer to zero), CO2 -2.7342 → -2.7134. " + "Slice S0380.210 CLOSED the residual: the Main cavity wall lodges " + "wall_insulation_type=4 (as-built/assumed) + description " + "'Cavity wall, as built, partial insulation (assumed)'. The " + "cascade mis-routed it to the Table 6 'Filled cavity' row " + "(band F = 0.40) via the 'partial insulation' substring; " + "RdSAP 10 Table 6 (England) routes an as-built partial-fill " + "cavity to the 'Cavity as built' row (band F = 1.0). New " + "`_cavity_described_as_filled` excludes 'partial insulation' " + "(keeping 'insulated (assumed)' → filled). Wall HLC +53.6 W/K " + "(0.40 → 1.0 over 268 m²) lifted all four metrics together: " + "SAP +7 → +0, PE -27.9745 → +0.5281, CO2 -2.7134 → -0.1189." ), ), _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-2, - expected_pe_resid_kwh_per_m2=+19.1566, - expected_co2_resid_tonnes_per_yr=+0.4211, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-0.1357, + expected_co2_resid_tonnes_per_yr=-0.0362, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "S0380.189 fixed the dominant driver: walls are solid brick " @@ -223,15 +331,47 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) " "produces ~150 kWh/yr more HW fuel than the pre-slice flat-" "winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 " - "→ +1.0779." + "→ +1.0779. " + "Slice S0380.196 CLOSED the residual (the prior 'lodged " + "divergence' claim is RETRACTED — it was a real API-mapper " + "bug). The API `room_in_roof_type_1` block lodges two gable " + "walls (L=4.65 each) but no heights; the mapper carried only " + "the scalar lengths, and the cascade's `_part_geometry` gable " + "formula silently drops height-less gables → the whole " + "55.67 m² A_RR shell billed as roof at U_RR=2.30 instead of " + "the §3.9.1(e) residual `12.5√(29.75/1.5) − 2×11.39 = 32.89`. " + "That over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof " + "130.73 → 78.33, matching the site-notes case-4 replica at " + "1e-4). Fix: route the Type 1 gables through `detailed_" + "surfaces` (gable area = L × the §3.9.1 default RR storey " + "height 2.45 m; Exposed → gable_wall_external, Party → " + "gable_wall) so the cascade's Detailed-RR residual fires. " + "SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 " + "+0.42 → +0.01. " + "Slice S0380.198 (the 0240 roof-window fix) also applies: " + "6035 lodges 2 windows with `window_wall_type=4` (room-in-roof " + "rooflights) which were billed as vertical glazing; routing " + "them to roof windows (27a) at inclined U=2.30 + 45° solar " + "tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still " + "exact). " + "Slice S0380.203 CLOSED the remaining +1.37 PE (it was NOT " + "'unrelated gains/HW'): the 2 'Roof of Room' rooflights pierce " + "the room-in-roof sloped ceiling, so their 1.92 m² deducts from " + "the §3.10.1 RR residual (uninsulated U_RR=2.30) — not the " + "insulated loft (U=0.14) the S0380.198 assumption used. Roof " + "78.0648 → 73.9176 (−4.42 W/K); space-heating demand drops → " + "PE +1.37 → -0.14, CO2 -0.0004 → -0.0362 (SAP still exact 70). " + "Validated against the simulated-case-6 worksheet, the only " + "worksheet evidence for 'Roof of Room' rooflight deduction " + "(6035's site-notes case-4 replica lodges no rooflights)." ), ), _GoldenExpectation( cert_number="7536-3827-0600-0600-0276", actual_sap=68, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-7.0776, - expected_co2_resid_tonnes_per_yr=-0.1875, + expected_pe_resid_kwh_per_m2=-6.1952, + expected_co2_resid_tonnes_per_yr=-0.1639, notes=( "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " @@ -239,10 +379,31 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "age band, not per-bp) jointly tightened: SAP +4 → +3, PE " "-27.17 → -22.53, CO2 -0.72 → -0.60. Slice 97 added " "glazing_type=2 (Table 24 spec U=2.0): SAP 0 → +1, PE/CO2 " - "widened. The cert's actual lodged U for glazing_type=2 " - "appears higher than the spec's table default — multi-age " - "geometry probably surfaces a per-bp U-value the spec table " - "doesn't capture exactly." + "widened. Slice S0380.214 fixed the as-built sloping-ceiling " + "roof U: Ext1 (band L) and Ext2 (band F) lodge " + "roof_construction=8 'Pitched, sloping ceiling' + 'As Built', " + "which take RdSAP 10 Table 18 col (3) (L=0.18, F=0.68) not " + "col (1) (0.16/0.40) — per item 5-5 + note (b). Roof HLC " + "26.77 → 29.17 W/K; cont SAP 69.071 → 68.924, PE -7.0776 → " + "-6.1952, CO2 -0.1875 → -0.1639 (SAP integer still 68 vs " + "lodged → resid +1). Worksheet-validated by simulated case 15 " + "(the 7536 replica): our cascade on its Summary matches the " + "P960 worksheet exactly (roof 29.17, SAP 65.04 vs 65). The " + "glazing hypothesis from the prior handover was wrong — maxing " + "the glazing U past spec can't flip 69→68, and every per-bp " + "fabric U is spec-plausible. CONCLUSION (cases 15/16/17, the " + "last faithful on windows 16.98/13.59/1.89 + ground floors): " + "every per-element value matches Elmhurst — walls 0.70/0.28/" + "0.40, roofs 0.40/0.18/0.68, window U-eff 2.4368/1.8519, " + "ground floors Main 0.97 / Ext1 0.26 / Ext2 1.12. The only " + "worksheet divergences were manual-entry artifacts (case 16 " + "floor-order inversion; case 17 spurious 'to external air' " + "exposed floors auto-derived from the small-ground/big-upper " + "geometry — real 7536 lodges floor_heat_loss 2/7/3 = unheated-" + "space/ground, NOT code 1 exposed). The residual +0.92 cont " + "SAP is therefore 0240-like: an Elmhurst register-rounding " + "residual not reproducible from the API-only JSON. DO NOT " + "chase further — leave at resid +1." ), ), _GoldenExpectation( @@ -273,9 +434,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="2130-1033-4050-5007-8395", actual_sap=82, - expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-7.5579, - expected_co2_resid_tonnes_per_yr=-0.0454, + expected_sap_resid=+2, + expected_pe_resid_kwh_per_m2=-11.7236, + expected_co2_resid_tonnes_per_yr=-0.0947, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " @@ -284,9 +445,28 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "from -38.63 to -9.70. Slice S0380.49 wired effective monthly " "Table 12e PE factor (vs annual 1.501/0.501) into the PV " "split: residual closed -9.70 → -8.22. SAP integer shifted " - "+1 (82 → 83) via the cohort cascade interaction. Remaining " - "-8.22 residual sits in gas combi PE under-count + secondary " - "heating credit (deferred)." + "+1 (82 → 83) via the cohort cascade interaction. " + "Slice S0380.215 fixed the dropped measured wall insulation: " + "Ext1 lodges solid-brick band B + INTERNAL insulation " + "`wall_insulation_thickness='measured'` with the actual 100 mm " + "in the separate `wall_insulation_thickness_measured` field " + "that the schema didn't declare, so `from_dict` discarded it " + "and the cascade fell back to the 50 mm unknown-thickness " + "default (U=0.55). Wiring it through → RdSAP 10 Table 8 U=0.32 " + "(less wall loss). This SPEC-CORRECT fix EXPOSED the offsetting " + "PV-β / gas-combi-PE under-count it had been masking: cont SAP " + "83.35 → 83.78 (resid +1 → +2), PE -7.56 → -11.72, CO2 -0.045 " + "→ -0.095. INVESTIGATED the exposed -11.72 PE (~-746 kWh/yr) " + "against simulated case 18 (a TFA-64 base + 2130's exact PV: 2× " + "2.04 kWp SE/NW, overshading 1/2): our cascade reproduces the " + "P960 worksheet's PV split EXACTLY — gen 2684.17, (233a) onsite " + "970.77, (233b) export 1713.40 to the decimal. So the Appendix " + "M1 β-split is NOT the bug; the gas PE factor is also exact " + "(Table 12 mains gas 1.13). 2130's residual is therefore the " + "irreducible API-only lodged gap (Elmhurst's own residual), " + "0240-like — NOT a closable calculator bug. The +2/-11.72 is " + "the spec-correct state once the masking wall bug is removed. " + "Leave it; do not chase." ), ), _GoldenExpectation( @@ -486,6 +666,58 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3541, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3533, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1132, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), + + # ------------------------------------------------------------------ + # "with api 3" — 2 fresh API+Summary+worksheet triples cross-validated + # by S0380.218. Both fetched fresh from the GOV.UK EPB register, run + # through BOTH front-ends (`from_api_response` + `from_elmhurst_site_ + # notes`), and pinned against the dr87 worksheet. The two paths agree + # to <1e-4 on all four metrics (cross-mapper parity per + # [[feedback-cross-mapper-parity-via-cascade]]) AND reproduce the + # worksheet's (255) cost / (272) CO2 / (286) PE exactly — see the + # +0.0000 worksheet pins in `_WORKSHEET_PE_CO2`. The dropped-field + # audit on both fresh JSONs surfaced no new silently-dropped schema + # fields (only `created_at` metadata + the shower keys handled by + # `_normalize_shower_outlets`). The PE/CO2 residuals below are vs the + # integer-rounded lodged register (`energy_consumption_current` / + # `co2_emissions_current`); the worksheet pins are the load-bearing + # 1e-4 check. + # ------------------------------------------------------------------ + _GoldenExpectation( + cert_number="0340-2467-9260-2006-6521", + actual_sap=70, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-0.4054, + expected_co2_resid_tonnes_per_yr=-0.0250, + notes=( + "Semi-detached house, TFA 107.26, mains-gas PCDB-listed boiler " + "(index 15709, no Table 4b code), no PV, 1 building part, 17 " + "windows. S0380.218 cross-validation: API path ≡ Summary path " + "to <1e-4 on SAP/cost/CO2/PE; cascade reproduces the dr87 " + "worksheet (255) cost 776.4295 / (272) CO2 2875.0498 / (286) PE " + "16474.5616 exactly (see `_WORKSHEET_PE_CO2`). Cascade SAP " + "integer 70 = lodged (resid +0); cont 70.1228. PE/CO2 resids " + "below are vs the integer-rounded lodged register." + ), + ), + _GoldenExpectation( + cert_number="5500-5070-0822-0201-3663", + actual_sap=66, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-0.2909, + expected_co2_resid_tonnes_per_yr=+0.0235, + notes=( + "Semi-detached house + 1 extension, TFA 82.88, mains-gas " + "PCDB-listed boiler (index 17741, no Table 4b code), no PV, 2 " + "building parts, 13 windows. S0380.218 cross-validation: API " + "path ≡ Summary path to <1e-4 on SAP/cost/CO2/PE; cascade " + "reproduces the dr87 worksheet (255) cost 751.8295 / (272) CO2 " + "2423.4547 / (286) PE 14397.0118 exactly (see " + "`_WORKSHEET_PE_CO2`). Cascade SAP integer 66 = lodged (resid " + "+0); cont 65.5539. PE/CO2 resids below are vs the integer-" + "rounded lodged register." + ), + ), ) @@ -517,7 +749,8 @@ class _WorksheetPin: expected_co2_resid_kg: float -# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc ≡ +# The 49 worksheet-validated certs (9 ASHP + 38 cohort-2 + 2 "with api +# 3", the last pair added by S0380.218). calc ≡ # worksheet on BOTH PE and CO2 at <1e-4 across the ENTIRE cohort # (every expected_*_resid below is 0.0000) — the SAP 10.2 1e-4 # convergence target, met. Closed over two slices: @@ -542,6 +775,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0340-2467-9260-2006-6521", ws_pe_kwh_per_m2=153.5946, ws_co2_kg_per_yr=2875.0498, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), @@ -567,6 +801,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="5500-5070-0822-0201-3663", ws_pe_kwh_per_m2=173.7091, ws_co2_kg_per_yr=2423.4547, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), @@ -731,3 +966,75 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert main.main_heating_index_number == expected_pcdb_id if expected_winter_eff is not None: assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 + + +def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None: + """Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP + API code for a roof window ("Roof of Room" rooflight / inclined + glazing), distinct from main-wall (1) and alternative-wall (2/3) + windows. They belong on worksheet line (27a) Roof Windows at the + Table 6e Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 + = 2.30 W/m²K), with 45°-inclined solar gains — NOT on (27) as vertical + wall windows at U=2.0. + + Before the fix the API mapper flattened all windows into + `sap_windows`, so these 6 billed as vertical glazing (wrong U *and* + wrong solar). Validated against the simulated-case-6 worksheet, which + bills the identical 6 windows on (27a) at U_eff 2.1062 (= 2.30 with + the §3.2 R=0.04 curtain transform). + """ + # Arrange + doc = _load_cert("0240-0200-5706-2365-8010") + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — the 6 wall_type=4 windows route to roof windows; the other + # 5 (wall_type=1, main wall) stay vertical. + assert epc.sap_roof_windows is not None + assert len(epc.sap_roof_windows) == 6 + assert len(epc.sap_windows) == 5 + assert all(abs(rw.u_value_raw - 2.30) <= 1e-9 for rw in epc.sap_roof_windows) + + +def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: + """Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_ + type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e) + (PDF p.21) the gable areas deduct from the A_RR shell — the residual + roof area is `12.5√(A_RR_floor/1.5) − Σ gables`, NOT the full shell. + + The API mapper must route these scalar gables through + `detailed_surfaces` (gable area = L × the §3.9.1 default RR storey + height 2.45 m) so the cascade's Detailed-RR residual fires, exactly + as the site-notes path does. Before the fix the gables (no lodged + height) were silently dropped → the whole 55.67 m² shell billed at + U_RR=2.30, a +52 W/K roof over-count and the entire 6035 residual. + + Cross-mapper parity: this is the value the site-notes case-4 replica + (`worksheet/_elmhurst_worksheet_001431_6035.py`) pins to its + worksheet at 1e-4: + loft (41.73−29.75=11.98) × U_roof(300 mm) = 1.6772 + ext (7.21) × U_roof(300 mm) = 1.0094 + RR residual (55.67 − 2×11.39 = 32.89) × U_RR(age A=2.30) = 75.647 + → 78.3336 W/K + """ + # Arrange + doc = _load_cert("6035-7729-2309-0879-2296") + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k + + # Assert — 78.3336 (gable-deducted residual + loft + ext roof). The 2 + # room-in-roof rooflights (window_wall_type=4 = "Roof of Room", 1.92 m²) + # pierce the RR sloped ceiling, so per S0380.203 their area deducts from + # the §3.10.1 residual (at the uninsulated U_RR=2.30) — NOT the insulated + # loft at U_roof=0.14 as the unvalidated S0380.198 assumption had it. + # 78.3336 − 1.92 × 2.30 = 78.3336 − 4.416 = 73.9176. The rooflights' own + # A×U stays on roof_windows_w_per_k. This matches the simulated-case-6 + # worksheet, where the only worksheet evidence for "Roof of Room" + # rooflight deduction shows them billed against "Roof room remaining" + # (the RR residual), not the flat/loft roof. Cert 6035 is API-only and + # its site-notes case-4 worksheet replica lodges no rooflights, so the + # case-6 worksheet is the spec authority for this archetype. + assert abs(roof_w_per_k - 73.9176) <= 1e-4 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_6035.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_6035.py new file mode 100644 index 00000000..a2c4d63b --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_6035.py @@ -0,0 +1,114 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 4" worksheet — a near-exact replica of golden cert +6035 (Main + Extension + Simplified room-in-roof, 8 windows). + +Like 000565 / sim case 1 / sim case 2, this fixture does NOT hand-build +the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +Purpose: prove the calculator is spec-correct for the 6035 archetype +(after S0380.192 Simplified-RR + S0380.193 suspended-floor fixes). This +cert reproduces 6035's full floor geometry — Main ground-floor HLP +15.99 m AND first-floor HLP 8.32 m (the asymmetric upper-storey +perimeter) — plus 6035's 8 windows (≈14.15 m²). Two minor inputs still +differ from 6035 (the largest window's orientation is N here vs S in +6035; meter type "Dual" vs API code 2), accounting for a residual ~50 +kWh / £11 cascade delta — both are lodged inputs, not calculator +behaviour. All 11 Block-1 line refs pin at abs=1e-4 against this cert's +OWN worksheet, confirming the cascade reproduces the spec engine exactly +for 6035's geometry — so 6035's residual +19 PE vs the lodged register +is lodged-register divergence, not a cascade gap. + +Cert shape: Main + Extension 1, both solid brick WITH internal +insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof +on the Main (floor 29.75 m², exposed + party gables), suspended +uninsulated ground floors (Main ground HLP 15.99 / first 8.32), +gas-combi SAP code 104, 8 windows, no PV. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 4/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf` +(distinct name — the corpus reuses cert 001431). + +Worksheet pin targets (P960-0001-001431, Block 1 — energy rating): +- SAP rating 68 (line 258), ECF 2.2802 (line 257) +- Total fuel cost £937.2341 (line 255) +- CO2 4682.3494 kg/year (line 272) +- Space heating 15745.3260 kWh/year (Σ monthly (98)) +- Main 1 fuel 18744.4357 kWh/year (line 211) +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3307.8383 kWh/year (line 219) +- Lighting 262.0885 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet 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_6035.pdf" +) + + +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\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + 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-2 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.192 Simplified-RR fix and the + S0380.193 suspended-floor sealed-rule fix. + """ + 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/_elmhurst_worksheet_001431_case5.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case5.py new file mode 100644 index 00000000..c1f6e8f7 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case5.py @@ -0,0 +1,122 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 5" worksheet — a DETACHED, SANDSTONE-walled cousin of +golden cert 0240 (Main + Extension + room-in-roof, age band J). + +Like the other 001431 cases, this fixture does NOT hand-build the +EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +Purpose: prove the calculator is spec-correct for a DETACHED room-in-roof +with one Exposed + one Party gable, validating S0380.196 (API Simplified +Type 1 RR gables deduct from the A_RR shell) against a real worksheet. +The worksheet prints the exact routing the cascade implements: + + Roof room Main Gable Wall 1 15.68 U=0.35 (29a) ← Exposed → walls @ main-wall U + Roof room Main remaining area 61.73 U=0.30 (30) ← A_RR shell − Σ gables (residual) + External roof Main 14.52 U=0.11 (30) ← loft residual + Roof room Main Gable Wall 2 15.68 U=0.25 (32) ← Party → party @ 0.25 + +gable area = 6.40 × 2.45 = 15.68 m² (the §3.9.1 default RR storey height). +A_RR remaining = 12.5√(83.2/1.5) − 2×15.68 = 93.09 − 31.36 = 61.73. + +This case surfaced two extractor/mapper gaps fixed in the same slice +(S0380.197): +- the sandstone wall label "SS Stone: sandstone or limestone" had no + `_ELMHURST_WALL_CODE_TO_SAP10` entry (→ WALL_STONE_SANDSTONE=2, matching + 0240's API `wall_construction=2`); +- the roof "Insulation Thickness 400+ mm" was silently dropped by the + extractor's `.split()[0].isdigit()` thickness parse (the trailing "+"), + so u_roof fell back to the age-J default 0.16 instead of 0.11 + (`_parse_thickness_mm` now strips to leading digits). + +Cert shape: Detached house, Main + Extension 1, sandstone insulated walls, +2 storeys + room-in-roof on the Main (floor 83.2 m², one Exposed + one +Party gable, L=6.40 each), oil community/boiler (SAP code 901 combi route, +control 2106), no PV, 20 low-energy lighting bulbs. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 5/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf`. + +Worksheet pin targets (P960-0001-001431, Block 1 — energy rating): +- SAP rating 61 (line 258), ECF 2.7724 (line 257) +- Total fuel cost £1586.4549 (line 255) +- CO2 8387.6229 kg/year (line 272) +- Space heating 12838.6489 kWh/year (Σ monthly (98)) +- Main 1 fuel 21397.7480 kWh/year (line 211) +- Secondary fuel 0.0 (line 215) +- Hot water fuel 6498.2518 kWh/year (line 219) +- Lighting 381.4601 kWh/year (line 232) +- Pumps/fans 141.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet 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_case5.pdf" +) + + +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\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + 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-5 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.196 RR-gable deduction, the + S0380.197 sandstone-wall-label + "400+ mm" roof-thickness fixes. + """ + 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/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py new file mode 100644 index 00000000..71f19cbf --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -0,0 +1,156 @@ +"""Mapper-driven cascade fixture for the Elmhurst P960-0001-001431 +"simulated case 6" worksheet — a DETACHED, dual-oil cousin of golden +cert 0240 carrying ROOM-IN-ROOF WINDOWS (rooflights). + +Routes the Summary PDF through ElmhurstSiteNotesExtractor + +from_elmhurst_site_notes (no hand-built EpcPropertyData) so the pin +exercises the whole extractor + mapper + calculator pipeline. + +Purpose: validate S0380.198/199 ROOF-WINDOW handling against a real +worksheet. Case 6 lodges 6 windows on the room-in-roof ("Roof of Room" +location); the worksheet bills them on line (27a) Roof Windows at +U_eff 2.1062 (= inclined 2.30 with the §3.2 R=0.04 curtain transform), +NOT on (27) as vertical glazing. This is the site-notes mirror of +0240's API `window_wall_type=4` roof windows (S0380.198). + +This cert surfaced two site-notes gaps fixed in S0380.199: +- the extractor mangled the "Roof of Room in Roof" window-location cell + into the glazing-type phrase ("Double between 2002 Roof of Room and + 2021 in Roof" → UnmappedElmhurstLabel); `_parse_window_from_anchors` + now detects + strips those tokens and marks the window roof-of-room; +- `_is_elmhurst_roof_window` gained a "Roof of Room" location branch, + and `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` an entry for the + already-inclined "Double between 2002 and 2021" → 2.30 (so the + inclination adjustment isn't double-applied). + +SCOPE: promoted to a FULL SapResult e2e fixture (S0380.207) once the dual +main heating system was fully modelled. Case 6 has Main 1 radiators (51%, +control 2106) + Main 2 underfloor (49%, control 2110) heating different +parts. Closing every line ref took: Table 4f note c) two circulation +pumps (231) S0380.201; Table 5a note a) two pump gains (70) S0380.202; +RdSAP §3.7 "Roof of Room" rooflights → §3.10.1 RR residual (30) S0380.203; +SAP 10.2 p.186 two-systems-different-parts MIT — weighted responsiveness +0.8775 + elsewhere two-control blend — (87)/(90)/(98c) S0380.205 (with +the Main 2 emitter/control extractor fix S0380.204); and Eq D1 per-boiler +(204) space share (219) S0380.206. SapResult pins (Block 1 energy rating) +live in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`; +`main_heating_fuel_kwh_per_yr` is the (211)+(213) two-system sum. Heating +is SAP code 127 (vs 0240's 130 condensing combi) — case 6 pins to its OWN +worksheet, the spec authority for this dual-oil archetype. + +The §3 window line refs (27)/(27a)/(31), the roof (30), the pumps (231), +the pump gains (70), the per-system fuel (211)/(213), and HW (219) also +have dedicated section pins in `test_section_cascade_pins.py`. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 6/`. Summary mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf`. + +Worksheet §3 window pin targets (P960-0001-001431, Block 1): +- (27) Windows = 19.3704 (Main) + 3.3704 (Ext1) = 22.7408 W/K +- (27a) Roof Windows = 6.19 m² × 2.1062 = 13.0375 W/K (the 6 rooflights) +- (31) Total external element area = 336.13 m² + +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_case6.pdf" +) + +# Worksheet §3 window line refs (Block 1 — energy rating). +LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408 +LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375 +LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13 + +# Worksheet (30) Roof total W/K = RR remaining (net of the 6.19 m² roof +# windows) 55.54 × 0.30 = 16.6620 + External roof Main 14.52 × 0.11 = +# 1.5972 + External roof Ext1 7.21 × 0.11 = 0.7931 → 19.0523. The 6 "Roof +# of Room" rooflights pierce the room-in-roof sloped ceiling, so their +# area deducts from the RR residual area, NOT the external flat roof. +LINE_30_ROOF_W_PER_K: Final[float] = 19.0523 + +# Worksheet (231) "Total electricity for the above, kWh/year" (Block 1). +# Decomposes as (230c) central heating pump 156 + (230d) oil boiler pump +# 200. (230c) = 41 (Main 1 circ pump, "2013 or later") + 115 (Main 2 circ +# pump, unknown date) — the two-main-system circulation-pump pair per +# SAP 10.2 Table 4f note c. (230d) = 2 × 100 oil-boiler aux (already +# wired in `_table_4f_additive_components`). +LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0 + +# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). The dual +# oil boiler heats different parts (Main 1 radiators/2106 living 51%, Main +# 2 underfloor/2110 elsewhere 49%) — the SAP 10.2 p.186 two-systems- +# different-parts MIT (weighted R 0.8775 + elsewhere two-control blend) +# lands (98c) demand 11991.96 exact, so the per-system fuels pin. +LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7741.6458 +LINE_213_MAIN_2_FUEL_KWH: Final[float] = 6995.3106 + +# Worksheet (219) water-heating fuel (kWh/yr). The DHW boiler is Main 1 +# (WHC 901), which provides only 51% of space heating, so SAP 10.2 +# Appendix D Eq D1 weights η_winter by Main 1's (204) share — not the +# dwelling total — when blending the monthly water-heater efficiency. +LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 4902.8601 + +# Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only +# (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair +# per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2 +# (unknown date → 7 W). The pre-S0380.202 cascade billed a single Main 1 +# pump (3 W). +LINE_70_PUMPS_FANS_GAINS_W: Final[tuple[float, ...]] = ( + 10.0, 10.0, 10.0, 10.0, 10.0, 0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0, +) + + +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 (mirror of the case-5 helper).""" + 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-6 Summary through extractor + mapper.""" + 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/_elmhurst_worksheet_001431_case7.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case7.py new file mode 100644 index 00000000..b538b320 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case7.py @@ -0,0 +1,134 @@ +"""Mapper-driven cascade fixture for the Elmhurst P960-0001-001431 +"simulated case 7" worksheet — the CONDENSING-OIL-COMBI variant of +[[case 6]], generated to validate the combi HW + space efficiency path +that golden cert 0240-0200-5706-2365-8010 exercises. + +Routes the Summary PDF through ElmhurstSiteNotesExtractor + +from_elmhurst_site_notes (no hand-built EpcPropertyData) so the pin +exercises the whole extractor + mapper + calculator pipeline. + +WHY THIS FIXTURE EXISTS +----------------------- +Case 6 is SAP code 127 ("Condensing oil *boiler*", regular) + a 110 L +cylinder — so it never exercised the COMBI instantaneous-DHW efficiency +path. 0240 is SAP code 130 ("Condensing combi oil boiler") with NO +cylinder. Case 7 is case 6 with that single difference swapped in: + + - both mains → SAP code 130 (Table 4b winter 82 / summer 73); + - NO hot-water cylinder → combi instantaneous DHW (WHC 901), Table 3a + keep-hot combi loss (61), no primary/storage loss; + - boiler interlock PRESENT (combi + room thermostat 2106, no cylinder) + → NO −5pp penalty, base eff 82/73 — the OPPOSITE of case 6. + +The dual-main rads(2106, 51%) + UFH(2110, 49%) different-parts structure, +the 6 "Roof of Room" rooflights, and the fabric are unchanged from case 6. + +WHAT IT PROVED +-------------- +The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every +top-level output with ZERO calculator changes — the condensing-combi +(130) + no-cylinder + dual-main + Appendix D Eq D1 path is already +correct. This fixture is a regression lock on that path; it did NOT +require a fix. (It also exonerates the combi mechanism as the source of +0240's API-path residual — see docs/HANDOVER_0240_CLOSURE.md.) + +Combi-path worksheet line refs (P960-0001-001431, Block 1): +- (206)/(207) main space-heating efficiency = 82.0000 / 82.0000 (base, + interlock present, no −5pp). +- (216) water-heater efficiency (summer base) = 73.0000. +- (217)m water-heater monthly efficiency = combi blend 73.00 → 80.18. +- (61)m combi loss = 50.9589 (Jan) … = 600 kWh/yr flat (Table 3a + keep-hot, daily HW volume > 100 L every month so the "no keep-hot" + fu-scaling collapses to 1.0). +- (59)m primary loss = 0 and storage loss = 0 (combi, no cylinder). +- (211) space-heating fuel main 1 = 7865.4304. +- (213) space-heating fuel main 2 = 7556.9821. +- (219) water-heating fuel = 3496.8121. +- (64) HW demand total = 2712.0619 (smaller dwelling than 0240's + 2842.82 — case 7 validates the combi *mechanism*, not 0240's absolute + demand). + +Per [[feedback-zero-error-strict]]: e2e pins are abs=1e-4 against the PDF +(see test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case7"]). + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 7/`. Summary mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case7.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_case7.pdf" +) + +# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). Both mains +# are condensing oil combis (SAP code 130, Table 4b 82/73) at base +# efficiency — interlock present (combi + room thermostat, no cylinder), +# so NO −5pp penalty (the case-6 boiler+cylinder had no cylinder stat → a +# −5pp penalty; the combi removes it). +LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7865.4304 +LINE_213_MAIN_2_FUEL_KWH: Final[float] = 7556.9821 + +# Worksheet (219) water-heating fuel (kWh/yr). Combi instantaneous DHW +# (WHC 901) — SAP 10.2 Appendix D Eq D1 blends the monthly water-heater +# efficiency (217)m by the DHW boiler's (204) space share; Table 3a +# keep-hot combi loss (61) = 600 kWh/yr; no primary/storage loss. +LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 3496.8121 + +# Worksheet (206)/(207) main space-heating efficiency — base 82, no +# −5pp (interlock present). Watch these if the pin ever regresses: a +# silent interlock flip drops them to 77/68. +LINE_206_MAIN_1_EFFICIENCY_PCT: Final[float] = 82.0 +LINE_207_MAIN_2_EFFICIENCY_PCT: Final[float] = 82.0 + + +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 (mirror of the case-6 helper).""" + 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-7 Summary through extractor + mapper.""" + 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/_elmhurst_worksheet_001431_rr.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr.py new file mode 100644 index 00000000..8fc8ecc7 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr.py @@ -0,0 +1,124 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 2" worksheet — a Main + Extension dwelling with a +Simplified room-in-roof (the 6035 archetype, more complete than sim +case 1). + +Like 000565 / sim case 1, this fixture does NOT hand-build the +EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +This cert surfaced two real cascade bugs (both fixed; this fixture pins +them end-to-end at 1e-4): + + S0380.192 — Simplified room-in-roof. The Summary lodges placeholder + slope/ceiling Length/Height cells (a 40 m ceiling height, a 32 m + slope on a 4.65 m gable). RdSAP 10 §3.9.1 derives one timber-framed + "remaining area" from the floor area instead + (A_RR = 12.5√(A_floor/1.5) − Σgables = 32.89 m²). Emitting the + placeholders as detailed_surfaces billed 1024 + 160 m² of explicit + roof area → a 7.5× fabric-heat-loss explosion (SAP −14.6). Fixed by + dropping roof-going surfaces for Simplified assessments so the + cascade's residual formula fires. + + S0380.193 — Suspended-timber-floor "sealed/unsealed" infiltration. + RdSAP 10 §5 (PDF p.29) line (12): rule (a) ("U-value < 0.5 → sealed + 0.1") applies only when a floor U-value is SUPPLIED. This cert's + floor is as-built/uninsulated (default U=0.43, not supplied), so it + falls to rule (b) → unsealed 0.2. The cascade was feeding the + computed default U into rule (a) → sealed 0.1 → (25) effective ACH + dropped → space heating understated ~450 kWh. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 2/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf` +(distinct name — the corpus reuses cert 001431; sim case 1 is the +single-part gas-combi variant) so the test runs without depending on +the unstaged workspace. + +Cert shape: Main + Extension 1, both solid brick WITH internal +insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof +on the Main (floor 29.75 m², exposed + party gables), suspended +uninsulated ground floors, gas-combi SAP code 104, no PV. + +Worksheet pin targets (P960-0001-001431, Block 1 — energy rating): +- SAP rating 69 (line 258), ECF 2.2395 (line 257) +- Total fuel cost £920.5046 (line 255) +- CO2 4566.7090 kg/year (line 272) +- Space heating 15269.8593 kWh/year (Σ monthly (98)) +- Main 1 fuel 18178.4039 kWh/year (line 211) +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3308.6172 kWh/year (line 219) +- Lighting 282.6414 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet 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_rr_ext.pdf" +) + + +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\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + 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-2 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.192 Simplified-RR fix and the + S0380.193 suspended-floor sealed-rule fix. + """ + 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/_elmhurst_worksheet_001431_rr8.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr8.py new file mode 100644 index 00000000..8011704e --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr8.py @@ -0,0 +1,112 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 3" worksheet — a near-exact replica of golden cert +6035 (Main + Extension + Simplified room-in-roof, 8 windows). + +Like 000565 / sim case 1 / sim case 2, this fixture does NOT hand-build +the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +Purpose: prove the calculator is spec-correct for the 6035 archetype +(after S0380.192 Simplified-RR + S0380.193 suspended-floor fixes). This +cert reproduces 6035's 8 windows (≈14.15 m²) and Main ground-floor +heat-loss perimeter (15.99 m). It still differs from 6035 in ONE input: +the Main FIRST-floor HLP is 15.99 here vs 6035's 8.32 (6035's upper +storey has less exposed perimeter), so it is not yet byte-identical to +6035. All 11 Block-1 line refs nonetheless pin at abs=1e-4 against this +cert's OWN worksheet, confirming the cascade reproduces the spec engine +exactly for this Main+Ext+RR+suspended-floor+gas-combi shape — so 6035's +residual +19 PE vs the lodged register is lodged-register divergence, +not a cascade gap. + +Cert shape: Main + Extension 1, both solid brick WITH internal +insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof +on the Main (floor 29.75 m², exposed + party gables), suspended +uninsulated ground floors, gas-combi SAP code 104, 8 windows, no PV. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 3/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf` +(distinct name — the corpus reuses cert 001431). + +Worksheet pin targets (P960-0001-001431, Block 1 — energy rating): +- SAP rating 68 (line 258), ECF 2.3146 (line 257) +- Total fuel cost £951.3425 (line 255) +- CO2 4767.4862 kg/year (line 272) +- Space heating 16086.3557 kWh/year (Σ monthly (98)) +- Main 1 fuel 19150.4235 kWh/year (line 211) +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3307.2639 kWh/year (line 219) +- Lighting 262.0885 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet 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_rr8w.pdf" +) + + +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\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + 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-2 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.192 Simplified-RR fix and the + S0380.193 suspended-floor sealed-rule fix. + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index fa3fe8ab..dfeb9b6e 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -38,6 +38,12 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000516 as _w000516, _elmhurst_worksheet_000565 as _w000565, _elmhurst_worksheet_001431 as _w001431, + _elmhurst_worksheet_001431_rr as _w001431_rr, + _elmhurst_worksheet_001431_rr8 as _w001431_rr8, + _elmhurst_worksheet_001431_6035 as _w001431_6035, + _elmhurst_worksheet_001431_case5 as _w001431_case5, + _elmhurst_worksheet_001431_case6 as _w001431_case6, + _elmhurst_worksheet_001431_case7 as _w001431_case7, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -167,6 +173,111 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=283.2229, pumps_fans_kwh_per_yr=86.0, ), + # Mapper-driven cohort entry — Summary_001431_rr_ext.pdf → extractor + # → mapper → calculator. Main + Extension, Simplified room-in-roof, + # suspended uninsulated floors (the 6035 archetype). Surfaced + pins + # S0380.192 (Simplified-RR remaining area) and S0380.193 (suspended- + # floor sealed/unsealed rule). Pins are worksheet Block 1 line refs. + "001431_rr": FixtureCascadePins( + sap_score=69, sap_score_continuous=68.7584, ecf=2.2395, + total_fuel_cost_gbp=920.5046, co2_kg_per_yr=4566.7090, + space_heating_kwh_per_yr=15269.8593, + main_heating_fuel_kwh_per_yr=18178.4039, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3308.6172, + lighting_kwh_per_yr=282.6414, + pumps_fans_kwh_per_yr=86.0, + ), + # Mapper-driven cohort entry — Summary_001431_rr8w.pdf → extractor → + # mapper → calculator. Near-exact 6035 replica: Main + Extension + + # Simplified room-in-roof, 8 windows (≈14.15 m², matching 6035), + # suspended uninsulated floors. Differs from 6035 only in the Main + # first-floor HLP (15.99 here vs 6035's 8.32). Pins at 1e-4 confirm + # the cascade is spec-correct for the archetype → 6035's +19 PE vs + # the lodged register is lodged-register divergence, not a calc gap. + "001431_rr8": FixtureCascadePins( + sap_score=68, sap_score_continuous=67.7118, ecf=2.3146, + total_fuel_cost_gbp=951.3425, co2_kg_per_yr=4767.4862, + space_heating_kwh_per_yr=16086.3557, + main_heating_fuel_kwh_per_yr=19150.4235, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3307.2639, + lighting_kwh_per_yr=262.0885, + pumps_fans_kwh_per_yr=86.0, + ), + # Mapper-driven cohort entry — Summary_001431_6035.pdf → extractor → + # mapper → calculator. Reproduces 6035's full floor geometry (Main + # ground HLP 15.99 + first 8.32, asymmetric) and 8 windows. Residual + # vs 6035 is two lodged inputs only (largest window orientation, + # meter type). Pins at 1e-4 → 6035's +19 PE is lodged divergence. + "001431_6035": FixtureCascadePins( + sap_score=68, sap_score_continuous=68.1906, ecf=2.2802, + total_fuel_cost_gbp=937.2341, co2_kg_per_yr=4682.3494, + space_heating_kwh_per_yr=15745.3260, + main_heating_fuel_kwh_per_yr=18744.4357, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3307.8383, + lighting_kwh_per_yr=262.0885, + pumps_fans_kwh_per_yr=86.0, + ), + # Mapper-driven cohort entry — Summary_001431_case5.pdf → extractor → + # mapper → calculator. DETACHED, SANDSTONE-walled cousin of cert 0240: + # Main + Extension + room-in-roof (floor 83.2 m², one Exposed + one + # Party gable L=6.40), age J, oil combi (SAP 901), no PV. Validates + # S0380.196 (RR gable deduction) against a real worksheet — the + # worksheet prints Gable 1 (Exposed) at (29a) U=0.35, Gable 2 (Party) + # at (32) U=0.25, remaining area = shell − Σ gables at (30). Also pins + # the S0380.197 sandstone "SS" wall label + "400+ mm" roof-thickness + # extractor fixes (without the latter, roof U fell to 0.16 not 0.11). + "001431_case5": FixtureCascadePins( + sap_score=61, sap_score_continuous=61.3255, ecf=2.7724, + total_fuel_cost_gbp=1586.4549, co2_kg_per_yr=8387.6229, + space_heating_kwh_per_yr=12838.6489, + main_heating_fuel_kwh_per_yr=21397.7480, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=6498.2518, + lighting_kwh_per_yr=381.4601, + pumps_fans_kwh_per_yr=141.0, + ), + # Mapper-driven cohort entry — Summary_001431_case6.pdf → extractor → + # mapper → calculator. DETACHED dual-oil cousin of case 5: Main 1 + # radiators (control 2106) + Main 2 underfloor (control 2110) heating + # DIFFERENT parts (51% / 49%), 6 "Roof of Room" rooflights, no boiler + # interlock (cyl stat No → −5pp on Main 1). Promoted to a full + # SapResult fixture once S0380.201-206 closed every line ref: Table 4f + # note c) two-pump electricity (231), Table 5a note a) two-pump gain + # (70), §3.7 rooflight→RR-residual (30), SAP 10.2 p.186 two-systems- + # different-parts MIT (87)/(90)/(98c), and Eq D1 per-boiler (204) + # space share (219). Pins are worksheet Block 1 (energy rating) line + # refs; main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum. + "001431_case6": FixtureCascadePins( + sap_score=72, sap_score_continuous=71.6597, ecf=2.0316, + total_fuel_cost_gbp=1162.5374, co2_kg_per_yr=5953.6679, + space_heating_kwh_per_yr=11991.9611, + main_heating_fuel_kwh_per_yr=14736.9564, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=4902.8601, + lighting_kwh_per_yr=357.6571, + pumps_fans_kwh_per_yr=356.0, + ), + # Mapper-driven cohort entry — Summary_001431_case7.pdf → extractor → + # mapper → calculator. Case 6 with the heating swapped to a CONDENSING + # OIL COMBI (SAP code 130, Table 4b 82/73) with NO cylinder — combi + # instantaneous DHW (WHC 901), Table 3a keep-hot combi loss (61), no + # primary/storage loss, boiler interlock PRESENT (no −5pp). Validates + # the combi HW + space efficiency path that golden cert 0240 uses; + # reproduces every line ref EXACTLY with no calculator change. + # main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum. + "001431_case7": FixtureCascadePins( + sap_score=73, sap_score_continuous=72.6153, ecf=1.9631, + total_fuel_cost_gbp=1123.3372, co2_kg_per_yr=5738.9315, + space_heating_kwh_per_yr=12646.3783, + main_heating_fuel_kwh_per_yr=15422.4125, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3496.8121, + lighting_kwh_per_yr=357.6571, + pumps_fans_kwh_per_yr=356.0, + ), } @@ -179,6 +290,12 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "000516": _w000516, "000565": _w000565, "001431": _w001431, + "001431_rr": _w001431_rr, + "001431_rr8": _w001431_rr8, + "001431_6035": _w001431_6035, + "001431_case5": _w001431_case5, + "001431_case6": _w001431_case6, + "001431_case7": _w001431_case7, } diff --git a/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py b/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py index 0f63ec26..7bcd9400 100644 --- a/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py +++ b/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py @@ -60,6 +60,40 @@ def test_table_11_secondary_fraction_splits_q_heat_between_main_and_secondary() assert result.secondary_fuel_kwh_per_yr == 550.0 +def test_two_main_systems_split_q_heat_by_fraction_203_at_own_efficiencies() -> None: + """Spec §9a (203)/(204)/(205) two-main split: when a second main + system supplies (203) of the main heating, (204)=(202)×(1−(203)) goes + to system 1 at (206) and (205)=(202)×(203) to system 2 at (207). With + no secondary ((202)=1), (203)=0.49, eff1=79%, eff2=84%, Σ(98c)=4400: + Σ(211) = 4400 × 0.51 × 100/79 = 2840.5063 kWh + Σ(213) = 4400 × 0.49 × 100/84 = 2566.6667 kWh + Mirrors simulated case 6 (oil boiler, radiators 51% + underfloor 49%) + and cert 0240 (identical-efficiency systems collapse to the single- + main total).""" + # Arrange + monthly_space_heating = ( + 1000.0, 800.0, 600.0, 400.0, 200.0, 0.0, + 0.0, 0.0, 0.0, 200.0, 400.0, 800.0, + ) + + # Act + result = space_heating_fuel_monthly_kwh( + space_heating_monthly_kwh=monthly_space_heating, + secondary_heating_fraction=0.0, + main_heating_efficiency_pct=79.0, + secondary_heating_efficiency_pct=0.0, + main_2_of_main_fraction=0.49, + main_2_efficiency_pct=84.0, + ) + + # Assert + assert abs(result.main_2_of_main_fraction - 0.49) <= 1e-12 + assert abs(result.main_1_of_total_fraction - 0.51) <= 1e-12 + assert abs(result.main_2_of_total_fraction - 0.49) <= 1e-12 + assert abs(result.main_1_fuel_kwh_per_yr - 4400.0 * 0.51 * 100.0 / 79.0) <= 1e-9 + assert abs(result.main_2_fuel_kwh_per_yr - 4400.0 * 0.49 * 100.0 / 84.0) <= 1e-9 + + def test_per_month_fuel_preserves_summer_clamp_zeros_from_98c() -> None: """The §8 Table 9c summer clamp zeros (98c)m for Jun..Sep. §9a's per- month (211)m / (215)m tuples are linear in (98c)m so they inherit the diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index b9f54aae..1757bdd6 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -36,11 +36,36 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( heat_transmission_from_cert, ) from domain.sap10_calculator.worksheet.heat_transmission import ( + _part_geometry, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage] _window_bp_index, # pyright: ignore[reportPrivateUsage] ) +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 + # 2026 API sample). `_part_geometry`'s early return must expose the + # same dict keys as its full return: the §3.9 RR contribution block + # reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for + # EVERY part, so a missing key raises KeyError and blocks the cert. + floorless = make_building_part(floor_dimensions=[]) + with_floors = make_building_part( + floor_dimensions=[make_floor_dimension(total_floor_area_m2=50.0)] + ) + + # Act + early = _part_geometry(floorless) + full = _part_geometry(with_floors) + + # Assert — identical key contract; the RR/cantilever geometry is 0.0 + # for a floorless part (no floor area ⇒ no RR shell or cantilever). + assert set(early.keys()) == set(full.keys()) + assert early["rr_common_wall_area_m2"] == 0.0 + assert early["rr_gable_area_m2"] == 0.0 + assert early["cantilever_floor_area_m2"] == 0.0 + + def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None: # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" # with descriptions like "Pitched, insulated (assumed)". The @@ -168,21 +193,26 @@ def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnot assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0) -def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None: - # Arrange — 128 corpus certs lodge solid-brick walls with - # wall_insulation_type=4 ("as-built / assumed") AND description - # "Solid brick, as built, insulated (assumed)". The description - # signals retrofit insulation that the assessor hasn't measured the - # thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm - # row. Without the description signal, type=4 alone would set - # wall_ins_present=False and the cascade would return the as-built - # U=1.7. With it, U = 0.55 at band B. +def test_solid_brick_as_built_insulated_assumed_uses_as_built_row_per_table9_footnote() -> None: + # Arrange — an "as built, insulated (assumed)" description only renders + # on RECENT age bands (where as-built construction already includes + # insulation per Building Regs); an old band renders "no insulation + # (assumed)". RdSAP 10 Table 8/9 footnote routes to the 50 mm row only + # when insulation is "known to have been increased subsequently + # (otherwise 'as built' applies)" — an age-band assumption is NOT + # known retrofit, so the as-built row applies. + # + # Worksheet-validated: simulated case 9 (sandstone, band J, As Built + # → U 0.35) and case 10 (solid brick, band J, As Built → U 0.35) both + # return the as-built row, NOT the 50 mm bucket (which would give + # U=0.25). This was previously asserted at 55 W/K via an IMPOSSIBLE + # band-B + "insulated (assumed)" combination. # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single - # storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100 - # = 55 W/K. + # storey → gross_wall = 100 m². walls_w_per_k expected = 0.35 × 100 + # = 35 W/K. main = make_building_part( identifier="Main Dwelling", - construction_age_band="B", + construction_age_band="J", wall_construction=3, wall_insulation_type=4, party_wall_construction=1, @@ -211,7 +241,7 @@ def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnot result = heat_transmission_from_cert(epc) # Assert - assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0) + assert result.walls_w_per_k == pytest.approx(35.0, abs=1.0) def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None: @@ -302,6 +332,58 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) +def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: + # Arrange — the EPC renders a cavity wall lodged wall_insulation_type=4 + # (as-built / assumed) with description "Cavity wall, as built, partial + # insulation (assumed)" for age bands where the as-built construction + # carries only partial cavity fill. "Partial insulation" is the as-built + # thermal state of the age band, NOT a retrofit cavity fill — the spec + # routes it to the "Cavity as built" row, not "Filled cavity": + # RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs + # "Filled cavity" band F = 0.40. A genuine fill renders the distinct + # "Cavity wall, filled cavity" description (wall_insulation_type=2), + # caught separately. Contrast the "insulated (assumed)" variant above, + # which the assessor judges as filled. + # + # Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, + # cavity type 4, "partial insulation (assumed)") closes all four SAP + # metrics (PE/SAP/CO2/cost) on the as-built 1.0 row — at the filled + # 0.40 row its PE under-counts by ~28 kWh/m². + # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey + # → gross_wall = 100 m². walls_w_per_k expected = 1.0 × 100 = 100 W/K. + main = make_building_part( + construction_age_band="F", + wall_construction=4, + wall_insulation_type=4, + party_wall_construction=1, + 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, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.walls = [ + EnergyElement( + description="Cavity wall, as built, partial insulation (assumed)", + energy_efficiency_rating=3, + environmental_efficiency_rating=3, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — U=1.0 × 100 m² gross wall = 100 W/K (as-built, not filled 70). + assert abs(result.walls_w_per_k - 100.0) <= 1.0 + + def test_walls_description_measured_transmittance_overrides_construction_cascade() -> None: # Arrange — a full-SAP (not RdSAP) cert lodges the wall U-value # directly in walls[i].description ("Average thermal transmittance @@ -1140,6 +1222,58 @@ def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() - assert alt_is_basement.main_wall_is_basement is False # main is still wc=4 +def test_explicit_wall_is_basement_flag_disambiguates_system_built_from_basement() -> None: + """RdSAP10 `wall_construction == 6` is canonically SYSTEM-BUILT + (`WALL_SYSTEM_BUILT`), but the gov-EPC basement heuristic hijacked it + (Elmhurst lodges both "SY System build" and "B Basement wall" as + code 6). The explicit `wall_is_basement` flag — set by the Elmhurst + mapper from the distinct "SY"/"B" codes — disambiguates: + - flag True → basement (drives §5.17 u_basement_wall) + - flag False → system-built (drives the u_wall code-6 table) + - flag None → fall back to the gov-EPC API code-6 heuristic + so the API path (which lodges basement as integer 6 with no flag) is + unchanged.""" + from dataclasses import replace + # Arrange — three parts, all wall_construction=6, differing only in flag. + plain = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built = replace(plain, wall_is_basement=False) + basement = replace(plain, wall_is_basement=True) + api_code_6 = replace(plain, wall_is_basement=None) + + # Act / Assert + assert system_built.main_wall_is_basement is False + assert basement.main_wall_is_basement is True + assert api_code_6.main_wall_is_basement is True # API heuristic preserved + + # Alt-wall mirror — same Optional disambiguation on SapAlternativeWall. + alt_system_built = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=False, + ) + alt_basement = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=True, + ) + alt_api_code_6 = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", + ) + assert alt_system_built.is_basement_wall is False + assert alt_basement.is_basement_wall is True + assert alt_api_code_6.is_basement_wall is True + + def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None: """RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. 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 b3480d2a..2835eb16 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -42,6 +42,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000487 as _w000487, _elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000516 as _w000516, + _elmhurst_worksheet_001431_case6 as _w001431_case6, ) @@ -248,6 +249,152 @@ def test_section_3_line_refs_match_pdf( _pin(actual, expected, f"§3 {fixture_attr} {fixture_name}") +def test_section_3_roof_windows_case6_match_pdf() -> None: + """§3 (27a) roof-window pin for simulated case 6 — the 6 room-in-roof + rooflights (window_wall_type=4 on the API side / "Roof of Room" + location on the site-notes side) must bill on (27a) at U_eff 2.1062, + not on (27) as vertical glazing. Validates the S0380.198/199 roof- + window routing against a real worksheet. Case 6 is pinned only on the + §3 window line refs (not added to `_FIXTURES`) because its dual main + heating system makes the §10/§12 per-system lines non-comparable — + see the fixture module docstring.""" + # Arrange + epc = _w001431_case6.build_epc() + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + _pin(ht.windows_w_per_k, _w001431_case6.LINE_27_WINDOWS_W_PER_K, "§3 (27) case6") + _pin( + ht.roof_windows_w_per_k, + _w001431_case6.LINE_27A_ROOF_WINDOWS_W_PER_K, + "§3 (27a) case6", + ) + _pin( + ht.total_external_element_area_m2, + _w001431_case6.LINE_31_TOTAL_EXTERNAL_AREA_M2, + "§3 (31) case6", + ) + _pin( + ht.roof_w_per_k, + _w001431_case6.LINE_30_ROOF_W_PER_K, + "§3 (30) case6", + ) + + +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 + main systems heat different parts (Main 1 radiators/2106 living, Main + 2 underfloor/2110 elsewhere). Pre-S0380.204 the extractor + mapper + dropped both (emitter='' / control=''), so the SAP 10.2 p.186 two- + systems-different-parts MIT could not read system 2's responsiveness + (underfloor → emitter 2 → R=0.75) or control type (2110 → type 3).""" + # Arrange / Act + epc = _w001431_case6.build_epc() + main_2 = epc.sap_heating.main_heating_details[1] + + # Assert — emitter 2 (underfloor in screed → Table 4d R=0.75) + + # control 2110 (Table 4e type 3 zone control). + assert main_2.heat_emitter_type == 2 + assert main_2.main_heating_control == 2110 + + +def test_section_4_hot_water_fuel_case6_match_pdf() -> None: + """(219) water-heating fuel for simulated case 6. The DHW boiler (Main + 1, WHC 901) provides only 51% of space heating, so SAP 10.2 Appendix D + §D2.1(2) Equation D1 must weight η_winter by Main 1's (204) share, not + the dwelling total (202). Pre-S0380.206 the cascade fed Eq D1 the full + dwelling space load → over-weighted η_winter → HW −78 kWh.""" + # Arrange / Act — real cascade (the §2.4 helper skips the cylinder gate). + ci = cert_to_inputs(_w001431_case6.build_epc()) + + # Assert + _pin( + ci.hot_water_kwh_per_yr, + _w001431_case6.LINE_219_HOT_WATER_FUEL_KWH, + "§4 (219) case6", + ) + + +def test_section_9a_per_system_fuel_case6_match_pdf() -> None: + """(211)/(213) per-system space-heating fuel for simulated case 6. The + dual oil boiler heats different parts (Main 1 radiators/2106 living, + Main 2 underfloor/2110 elsewhere), so SAP 10.2 p.186 applies the + two-systems-different-parts MIT: weighted responsiveness R = 0.51·1.0 + + 0.49·0.75 = 0.8775 (Table 9b) and a rest-of-dwelling temperature + blended from each system's control schedule. That lands (98c) demand + 11991.96 exact, so the per-system fuels pin. Pre-S0380.205 the cascade + used Main 1's control + R=1.0 for the whole dwelling → MIT +0.037 °C → + demand +61 kWh → both legs ~+1.3 % high.""" + # Arrange / Act — pin the REAL cascade (the §2.4 section helper skips + # the interlock penalty + two-system MIT params, so use cert_to_inputs). + er = cert_to_inputs(_w001431_case6.build_epc()).energy_requirements + + # Assert + _pin( + er.main_1_fuel_kwh_per_yr, + _w001431_case6.LINE_211_MAIN_1_FUEL_KWH, + "§9a (211) case6", + ) + _pin( + er.main_2_fuel_kwh_per_yr, + _w001431_case6.LINE_213_MAIN_2_FUEL_KWH, + "§9a (213) case6", + ) + + +def test_section_4f_pumps_fans_case6_match_pdf() -> None: + """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler + detached dwelling. Worksheet (231) = 356 = (230c) central heating + pump 156 + (230d) oil boiler pump 200. (230c) is itself the two- + main-system circulation-pump pair per SAP 10.2 Table 4f note c + ("Where there are two main heating systems include two figures from + this table"): Main 1 41 kWh (pump age "2013 or later") + Main 2 115 + kWh (pump age unknown). The pre-S0380.201 cascade summed only Main 1's + circulation pump (41) and gave (231) = 241.""" + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + + # Arrange + epc = _w001431_case6.build_epc() + + # Act + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert + _pin( + result.pumps_fans_kwh_per_yr, + _w001431_case6.LINE_231_PUMPS_FANS_KWH, + "§4f (231) case6", + ) + + +def test_section_5_pumps_fans_gains_case6_match_pdf() -> None: + """(70) pumps/fans internal-gain pin for simulated case 6. The dual oil + boiler serves different parts (51% radiators + 49% underfloor), so SAP + 10.2 Table 5a note a) ("Where there are two main heating systems serving + different parts of the dwelling, assume each has its own circulation + pump and therefore include two figures from this table") bills TWO + central-heating-pump gains: Main 1 "2013 or later" (3 W) + Main 2 + unknown date (7 W) = 10 W in the 8 heating months. The pre-S0380.202 + cascade billed a single Main 1 pump (3 W).""" + # Arrange + epc = _w001431_case6.build_epc() + + # Act + ig = internal_gains_section_from_cert(epc) + + # Assert + assert ig is not None + for m in range(12): + _pin( + ig.pumps_fans_monthly_w[m], + _w001431_case6.LINE_70_PUMPS_FANS_GAINS_W[m], + f"§5 (70) case6 month {m + 1}", + ) + + # ============================================================================ # §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples # ============================================================================