diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index ed35bef1..6fa7a745 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -268,6 +268,14 @@ class ElmhurstSiteNotesExtractor: thickness_mm=thickness_mm, insulation_thickness_mm=insulation_thickness_mm, alternative_walls=self._alternative_walls_from_lines(lines), + # Summary §7 lodges the per-BP "Curtain Wall Age" line only + # when `Type: CW Curtain Wall`. Per RdSAP 10 §5.18 (PDF + # p.48) this drives the curtain-wall U-value (Post 2023 → + # 1.4; Pre 2023 → 2.0) independent of the dwelling-wide + # age band. Use `_local_val` (Optional[str]) so absent + # lines surface as None, not the empty-string sentinel + # `_local_str` returns. + curtain_wall_age=self._local_val(lines, "Curtain Wall Age"), ) def _alternative_walls_from_lines(self, lines: List[str]) -> List[AlternativeWall]: 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 e85aaca5..650022f8 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1340,6 +1340,126 @@ def test_summary_000565_ext3_ext4_wall_constructions_route_to_basement_code_6() assert epc.sap_building_parts[4].main_wall_is_basement is True +def test_summary_000565_extractor_finds_curtain_wall_age_post_2023_on_bp_2_ext2() -> None: + """Summary §7 per-BP Wall block carries a `Curtain Wall Age` line + when `Type: CW Curtain Wall` is lodged. Cert 000565 Ext2 (BP[2]) + is the cohort fixture: it lodges + + Type CW Curtain Wall + Curtain Wall Age Post 2023 + U-value Known No + + Per RdSAP 10 §5.18 (PDF p.48), the U-value of a curtain wall is + keyed on the per-BP `Curtain Wall Age` (Post 2023 → Table 24 + window row; Pre 2023 → 2.0 W/m²K), NOT on the dwelling-wide + `construction_age_band`. The extractor must surface this field + so the mapper + cascade can dispatch correctly. Pre-S0380.85 the + line was silently dropped and `wall_construction=9` fell through + to the cavity-default Table 6 row. + + Pure extractor data-completion step — downstream cascade impact + lands when the mapper threads the new field through and `u_wall` + grows a Curtain Wall branch (follow-up sub-step in the same slice). + """ + # Arrange + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + + # Act + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Assert — BP[2] is Ext2 (index 1 in `extensions`). + ext2_walls = site_notes.extensions[1].walls + assert ext2_walls.wall_type == "CW Curtain Wall", ( + f"Ext2 wall_type = {ext2_walls.wall_type!r}; expected 'CW Curtain Wall'" + ) + assert ext2_walls.curtain_wall_age == "Post 2023", ( + f"Ext2 curtain_wall_age = {ext2_walls.curtain_wall_age!r}; " + f"expected 'Post 2023'" + ) + # Negative case — BPs without Curtain Wall don't have a Curtain + # Wall Age line; the field must be None (not the empty-string + # sentinel `_local_str` returns). + main_walls = site_notes.walls + assert main_walls.curtain_wall_age is None, ( + f"Main wall (non-CW) curtain_wall_age = " + f"{main_walls.curtain_wall_age!r}; expected None" + ) + + +def test_summary_000565_mapper_threads_curtain_wall_age_post_2023_to_bp_2_sap_building_part() -> None: + """The Elmhurst mapper builds a `SapBuildingPart` per BP from the + extracted `WallDetails`. `curtain_wall_age` must be threaded + through so the heat-transmission cascade can dispatch on it (per + [[reference-unmapped-api-code]] strict-plumbing pattern). Cert + 000565 BP[2] Ext2 is the fixture: `wall_construction=9` + (WALL_CURTAIN) + `curtain_wall_age="Post 2023"`. + + Per RdSAP 10 §5.18 + §1.5: a curtain wall can be a main wall, an + alt wall, or absorbed into the prevailing wall when <10% area. + This slice scopes to the main-wall path (cert 000565 lodges CW + only as the BP[2] main wall, never as an alt sub-area). + """ + # Arrange + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + bp_2 = epc.sap_building_parts[2] + assert bp_2.wall_construction == 9, ( + f"BP[2] wall_construction = {bp_2.wall_construction!r}; " + f"expected 9 (WALL_CURTAIN)" + ) + assert bp_2.curtain_wall_age == "Post 2023", ( + f"BP[2] curtain_wall_age = {bp_2.curtain_wall_age!r}; " + f"expected 'Post 2023'" + ) + # Non-CW BPs preserve curtain_wall_age=None (no per-BP signal). + assert epc.sap_building_parts[0].curtain_wall_age is None + assert epc.sap_building_parts[1].curtain_wall_age is None + + +def test_summary_000565_ext2_curtain_wall_routes_to_u_value_1p4_per_rdsap_10_section_5_18() -> None: + """End-to-end cascade pin: with `curtain_wall_age="Post 2023"` plumbed + through extractor + mapper + `u_wall` `WALL_CURTAIN` branch, the + `heat_transmission_from_cert` walls subtotal on cert 000565 must + reflect the §5.18 Curtain Wall U=1.4 W/m²K on BP[2] Ext2. + + Pre-S0380.85: BP[2] cascade U=0.60 (Cavity default, age H), Δ −0.80 + W/m²K vs worksheet U=1.40. The BP[2] Ext2 gross wall area on cert + 000565 multiplied by this U-delta accounts for the documented + −112.2 W/K contribution to the walls subtotal residual. + + Asserts the cascade walls subtotal moves materially toward the + worksheet target 604.07 W/K (from pre-S0380.85's 443 W/K). The + remaining ~50 W/K gap is the BP[0] Main alt1 thin-wall stone + granite cascade gap — out of scope for this slice; closes in + follow-up S0380.86. + """ + # Arrange + from domain.sap10_calculator.worksheet.heat_transmission import ( + heat_transmission_from_cert, + ) + + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + ht = heat_transmission_from_cert(epc) + + # Assert — pre-S0380.85 cascade had walls 443 W/K. Curtain Wall + # closure adds ~112 W/K (worksheet target 604 W/K). Lower-bound + # 540 W/K is a robust gate that still leaves headroom for the + # remaining BP[0] alt1 thin-wall gap; the cascade reaches ~555. + assert ht.walls_w_per_k >= 540.0, ( + f"walls_w_per_k = {ht.walls_w_per_k:.2f}; expected ≥540 after " + f"Curtain Wall §5.18 dispatch (pre-S0380.85 baseline was 443)" + ) + + def test_summary_000565_ext1_party_wall_routes_to_cavity_filled_code_4() -> None: # Arrange — RdSAP 10 Table 15 row 3 "Cavity masonry filled": # cert 000565 Ext1 lodges "CF Cavity masonry filled". Routes diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index c04af7ea..e6474755 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -435,6 +435,13 @@ class SapBuildingPart: None # TODO: make enum/mapping? ) sap_room_in_roof: Optional[SapRoomInRoof] = None + # Per RdSAP 10 §5.18 (PDF p.48), a curtain wall (wall_construction + # =WALL_CURTAIN=9) takes its U-value from the per-BP installation + # age — "Post 2023" routes to the Table 24 window row (1.4 W/m²K + # PVC/wood), anything else (incl. None) defaults to U=2.0 W/m²K. + # The dwelling-wide `construction_age_band` does NOT govern curtain + # walls; this field decouples them per spec. + curtain_wall_age: Optional[str] = None @property def main_wall_is_basement(self) -> bool: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index eadb3cdf..35d17d92 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3121,6 +3121,12 @@ def _map_elmhurst_building_part( sap_room_in_roof=room_in_roof, sap_alternative_wall_1=alt_walls[0], sap_alternative_wall_2=alt_walls[1], + # RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-value is keyed on + # the per-BP `Curtain Wall Age` lodging, not on the dwelling- + # wide age band. Thread the extractor's optional field through + # so heat_transmission's `u_wall(curtain_wall_age=...)` can + # dispatch. None for non-curtain-wall BPs. + curtain_wall_age=walls.curtain_wall_age, ) diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 9a9a02ae..863846b7 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -89,6 +89,13 @@ class WallDetails: # "Insulation Thickness" / "100 mm" line pair when a composite or # retrofit insulation is recorded. None when the PDF omits the line. insulation_thickness_mm: Optional[int] = None + # Per-BP curtain-wall installation age, lodged in Summary §7 as + # "Curtain Wall Age" when `wall_type` is "CW Curtain Wall". Per + # RdSAP 10 §5.18 (PDF p.48) the curtain-wall U-value keys on this + # field (Post 2023 → Table 24 window row; Pre 2023 → 2.0 W/m²K), + # NOT on the dwelling-wide `construction_age_band`. None when the + # BP is not a curtain wall. + curtain_wall_age: Optional[str] = None @dataclass diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index c0ccd84a..afb60b20 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -647,6 +647,11 @@ def heat_transmission_from_cert( insulation_present=wall_ins_present, description=wall_description, wall_insulation_type=wall_ins_type, + # RdSAP 10 §5.18 (PDF p.48) — curtain walls dispatch + # on the per-BP installation age, not the dwelling age + # band. None for non-curtain-wall parts (ignored by + # `u_wall` unless wall_construction == WALL_CURTAIN). + curtain_wall_age=part.curtain_wall_age, ) # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 8e6609cf..90423d57 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -144,6 +144,39 @@ _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04 _DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17 +# RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-values. +# +# "If documentary evidence is available, use calculated U-value of the +# whole curtain wall. Otherwise for the purpose of RdSAP, U= 2.0 W/m²K +# for pre-2023 curtain walls, And for post-2023 (2024 in Scotland) +# U-values as for windows given in Notes below Table 24." +# +# Table 24 row "Double or triple glazed, England/Wales: 2022 or later" +# is the matching post-2023 row: U = 1.4 (PVC/wood) / 1.6 (metal). The +# Frame Factor for a whole-wall curtain wall is 1 per the §5.18 closer. +# +# Empirical pin: cert 000565 BP[2] Ext2 lodges `Curtain Wall Age: Post +# 2023` and the U985 worksheet uses U=1.40 for this BP — matching the +# PVC/wood row (the §5.18 default since curtain-wall frame material is +# not separately surfaced on the Elmhurst Summary). +_CURTAIN_WALL_U_PRE_2023: Final[float] = 2.0 +_CURTAIN_WALL_U_POST_2023: Final[float] = 1.4 +_CURTAIN_WALL_POST_2023_LODGEMENTS: Final[frozenset[str]] = frozenset({ + "Post 2023", + "Post-2023", +}) + + +def _u_curtain_wall(curtain_wall_age: Optional[str]) -> float: + """RdSAP 10 §5.18 curtain-wall U-value. Keyed on the per-BP + `Curtain Wall Age` lodgement (Summary §7), NOT on the dwelling-wide + `construction_age_band`. Unknown / absent → pre-2023 default per + the spec's "U= 2.0 W/m²K for pre-2023 curtain walls" sentence.""" + if curtain_wall_age is not None and curtain_wall_age.strip() in _CURTAIN_WALL_POST_2023_LODGEMENTS: + return _CURTAIN_WALL_U_POST_2023 + return _CURTAIN_WALL_U_PRE_2023 + + _AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM") @@ -336,6 +369,7 @@ def u_wall( description: Optional[str] = None, wall_insulation_type: Optional[int] = None, dry_lined: bool = False, + curtain_wall_age: Optional[str] = None, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. @@ -361,12 +395,23 @@ def u_wall( insulation stack. Cohort fixture: cert 7700 Alt 1 cavity-as-built age C with Dry-lining: Yes — base U=1.5 → adjusted U=1.20 (2 d.p., matching worksheet `CavityWallPlasterOnDabsDenseBlock`). + + `curtain_wall_age` keys the RdSAP 10 §5.18 (PDF p.48) curtain-wall + dispatch. Applies only when `construction == WALL_CURTAIN`; ignored + for all other constructions. The dwelling-wide `age_band` does NOT + govern curtain walls per §5.18 — the installation age does. """ measured = _measured_u_from_description(description) if measured is not None: return measured if country is None and age_band is None and construction is None and insulation_thickness_mm is None and not insulation_present: return 1.5 + # RdSAP 10 §5.18 (PDF p.48) — curtain walls bypass the Table 6/7/8/9 + # cascade entirely; their U-value keys solely on the per-BP + # `Curtain Wall Age` lodging. Place the dispatch before `known_types` + # so an explicit `construction=WALL_CURTAIN` always routes here. + if construction == WALL_CURTAIN: + return _u_curtain_wall(curtain_wall_age) ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 5c5f5942..174ea842 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -23,6 +23,7 @@ import pytest from domain.sap10_ml.rdsap_uvalues import ( Country, WALL_CAVITY, + WALL_CURTAIN, WALL_INSULATION_FILLED_CAVITY, WALL_SOLID_BRICK, WALL_STONE_GRANITE, @@ -537,6 +538,75 @@ def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_ assert result == pytest.approx(0.35, abs=0.001) +def test_u_wall_curtain_wall_post_2023_routes_to_window_table_24_u_1p4_per_rdsap_5_18() -> None: + # Arrange — RdSAP 10 §5.18 (PDF p.48): "Otherwise for the purpose of + # RdSAP, U= 2.0 W/m²K for pre-2023 curtain walls, And for post-2023 + # (2024 in Scotland) U-values as for windows given in Notes below + # Table 24." Table 24 row "Double or triple glazed England/Wales: + # 2022 or later" PVC/wood column = 1.4 W/m²K. Cert 000565 BP[2] + # Ext2 lodges `Type: CW Curtain Wall` + `Curtain Wall Age: Post 2023` + # — worksheet pins U=1.40 for this BP. + # + # Pre-S0380.85: `WALL_CURTAIN=9` was defined but not in `known_types` + # at u_wall:373-376, so the dispatch fell through to + # `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age + # H = 0.60. Cascade walls subtotal under-counted by ~112 W/K on + # this BP. + + # Act + result = u_wall( + country=Country.ENG, + age_band="H", + construction=WALL_CURTAIN, + insulation_thickness_mm=None, + curtain_wall_age="Post 2023", + ) + + # Assert + assert abs(result - 1.4) <= 1e-9 + + +def test_u_wall_curtain_wall_pre_2023_uses_rdsap_5_18_default_u_2p0() -> None: + # Arrange — RdSAP 10 §5.18 (PDF p.48) fallback for curtain walls + # built before 2023 (or installed-age unknown): U = 2.0 W/m²K. + # Independent of construction age band — §5.18 keys solely on the + # curtain-wall-age lodging (Post 2023 vs everything else), not on + # the dwelling-wide `construction_age_band`. + + # Act + result = u_wall( + country=Country.ENG, + age_band="H", + construction=WALL_CURTAIN, + insulation_thickness_mm=None, + curtain_wall_age="Pre 2023", + ) + + # Assert + assert abs(result - 2.0) <= 1e-9 + + +def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None: + # Arrange — when the cert lodges `Type: CW Curtain Wall` but no + # `Curtain Wall Age` line (older Elmhurst Summary PDFs, or API EPCs + # without the per-BP curtain_wall_age field), apply the §5.18 + # default. The §5.18 sentence "U= 2.0 W/m²K for pre-2023 curtain + # walls" applies as the unknown-age fallback — matches the spec's + # "assume as-built" convention elsewhere in the cascade. + + # Act + result = u_wall( + country=Country.ENG, + age_band="H", + construction=WALL_CURTAIN, + insulation_thickness_mm=None, + curtain_wall_age=None, + ) + + # Assert + assert abs(result - 2.0) <= 1e-9 + + # ----- Roofs -----