mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice 99c: Elmhurst mapper — RR gables external for flats + SO wall code
Cert 9501 worksheet line (29a) lodges both RR gable walls (13.50 + 15.95 m²) as EXTERNAL walls at U=1.7 (the main-wall U for age B Solid Brick), contributing +50.07 W/K on top of the 168.74 W/K main- wall HLC for a (29a) total of 218.81 W/K. Two mapper gaps blocked this: 1. The Summary mapper defaulted un-typed RR gable walls (`surface.gable_type=None`) to `gable_wall` (party, U=0.25 per RdSAP Table 4 row 2). For flats with RR — top-floor dwellings that sit at the end of a building block with no neighbour above — the gable walls are exposed external, not party. Threading `is_flat=property_type.lower()=='flat'` through `_map_elmhurst_building_parts` → `_map_elmhurst_room_in_roof` → `_map_elmhurst_rir_surface` switches the default for un-typed gables on flats to `gable_wall_external` (cascade falls through to main-wall U `uw`). 2. The Elmhurst wall-construction code map was missing "SO Solid Brick" (newer Elmhurst PDF variant; the cohort certs lodge "SB Solid Brick"). Cert 9501's main wall fell through to wall_construction=None → cascade uw=1.5 (Table-18 unknown-cons age-B default) instead of 1.7 (Table-18 solid-brick age-B). Added "SO": 3 alongside "SB": 3 — same SAP10 mapping. Joint effect on cert 9501 Summary path: - walls HLC 148.89 → 218.81 (exact worksheet match) - party_walls HLC 7.36 → 0.00 (gables no longer route to party) - (37) total HLC 229.71 → 296.68 (exact worksheet match) Cohort regression check: 259/0 mapper-chain + extractor + golden tests pass. Houses keep the historical un-typed-gable → party default. Houses lodging "SO" instead of "SB" now also pick up the correct solid-brick U-value. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2cdaefcd2e
commit
e9575b529f
2 changed files with 75 additions and 8 deletions
|
|
@ -348,6 +348,39 @@ def test_summary_9501_dwelling_type_is_top_floor_flat() -> None:
|
|||
assert epc.dwelling_type.lower().startswith("top-floor")
|
||||
|
||||
|
||||
def test_summary_9501_rr_gable_walls_route_to_external_walls_hlc() -> None:
|
||||
# Arrange — cert 9501's worksheet §3 lodges "Roof room Main Gable
|
||||
# Wall 1" + "Gable Wall 2" as line (29a) entries (external walls)
|
||||
# at the main-wall U (= 1.70 for age B Solid Brick): 13.50×1.70 +
|
||||
# 15.95×1.70 = 50.07 W/K added on top of the regular external-walls
|
||||
# 168.74 → 218.81 W/K total.
|
||||
#
|
||||
# The Summary mapper currently lodges these as
|
||||
# `SapRoomInRoofSurface(kind='gable_wall', ...)` — the cascade's
|
||||
# cohort-house default which routes to party walls at U=0.25
|
||||
# (Table 4 row 2). For a top-floor flat in a mid-terrace block,
|
||||
# the gables sit at the ends of the building (no neighbour above)
|
||||
# — they're EXTERNAL not party. Surface them as
|
||||
# `gable_wall_external` so the cascade's (29a) sum picks them up.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000784_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Act
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
heat_transmission_section_from_cert,
|
||||
)
|
||||
ht = heat_transmission_section_from_cert(epc)
|
||||
|
||||
# Assert — worksheet (29a) total walls = 168.7420 (main) +
|
||||
# 22.95 (Gable 1) + 27.115 (Gable 2) = 218.807 W/K. Tolerance
|
||||
# 1e-2 absorbs the 2-d.p. rounding of the underlying U/area
|
||||
# products; the 1e-4 chain test downstream will tighten this
|
||||
# to the cascade-internal rounding floor.
|
||||
worksheet_walls_w_per_k = 218.807
|
||||
assert abs(ht.walls_w_per_k - worksheet_walls_w_per_k) <= 1e-2
|
||||
|
||||
|
||||
def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 001479 (Summary_001479.pdf / P960-0001-001479.pdf)
|
||||
# is the first cohort cert with a real GOV.UK EPB API counterpart
|
||||
|
|
|
|||
|
|
@ -322,7 +322,9 @@ class EpcPropertyDataMapper:
|
|||
wind_turbines_terrain_type=survey.renewables.wind_turbines_terrain_type,
|
||||
electricity_smart_meter_present=survey.meters.electricity_smart_meter,
|
||||
),
|
||||
sap_building_parts=_map_elmhurst_building_parts(survey),
|
||||
sap_building_parts=_map_elmhurst_building_parts(
|
||||
survey, is_flat=property_type.lower() == "flat",
|
||||
),
|
||||
solar_water_heating=survey.renewables.solar_water_heating,
|
||||
has_hot_water_cylinder=survey.water_heating.hot_water_cylinder_present,
|
||||
has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling,
|
||||
|
|
@ -2065,7 +2067,10 @@ def _leading_code(value: str) -> str:
|
|||
_ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
|
||||
"ST": 1, # Stone (granite/sandstone) — placeholder; sandstone vs granite
|
||||
# ambiguity resolved downstream via walls[].description.
|
||||
"SB": 3, # Solid brick
|
||||
"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
|
||||
# cohort lodges "SB Solid Brick")
|
||||
"CA": 4, # Cavity
|
||||
"TF": 5, # Timber frame
|
||||
"TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping)
|
||||
|
|
@ -2732,11 +2737,16 @@ _EXTENSION_IDENTIFIERS: tuple[BuildingPartIdentifier, ...] = (
|
|||
)
|
||||
|
||||
|
||||
def _map_elmhurst_building_parts(survey: ElmhurstSiteNotes) -> List[SapBuildingPart]:
|
||||
def _map_elmhurst_building_parts(
|
||||
survey: ElmhurstSiteNotes, *, is_flat: bool = False,
|
||||
) -> List[SapBuildingPart]:
|
||||
"""Produce a list of `SapBuildingPart` covering the main dwelling plus
|
||||
each lodged extension. Empty `survey.extensions` collapses to a
|
||||
single-element list (the Main bp) — backward-compatible with single-
|
||||
bp certs."""
|
||||
bp certs.
|
||||
|
||||
`is_flat` propagates to the RR mapper so un-typed gables on a flat's
|
||||
RR route to external walls (not party walls)."""
|
||||
parts: List[SapBuildingPart] = [
|
||||
_map_elmhurst_building_part(
|
||||
identifier=BuildingPartIdentifier.MAIN,
|
||||
|
|
@ -2745,7 +2755,7 @@ def _map_elmhurst_building_parts(survey: ElmhurstSiteNotes) -> List[SapBuildingP
|
|||
walls=survey.walls,
|
||||
roof=survey.roof,
|
||||
floor=survey.floor,
|
||||
room_in_roof=_map_elmhurst_room_in_roof(survey.room_in_roof),
|
||||
room_in_roof=_map_elmhurst_room_in_roof(survey.room_in_roof, is_flat=is_flat),
|
||||
)
|
||||
]
|
||||
for ext, identifier in zip(survey.extensions, _EXTENSION_IDENTIFIERS):
|
||||
|
|
@ -2808,12 +2818,22 @@ def _elmhurst_rir_insulation_thickness_mm(insulation_text: str) -> int:
|
|||
|
||||
def _map_elmhurst_rir_surface(
|
||||
surface: ElmhurstRoomInRoofSurface,
|
||||
*,
|
||||
is_flat: bool = False,
|
||||
) -> Optional[SapRoomInRoofSurface]:
|
||||
"""Translate one Elmhurst surface row into a `SapRoomInRoofSurface`.
|
||||
Returns None when the surface is absent (0×0 — the cohort lodges a
|
||||
full 5-pair table even when only some surfaces exist) or is a
|
||||
Common Wall (those are handled by the cascade's Simplified Type 2
|
||||
geometry, not by Detailed enumeration)."""
|
||||
geometry, not by Detailed enumeration).
|
||||
|
||||
`is_flat=True` flips the default routing of un-typed gable walls
|
||||
(gable_type=None) from `gable_wall` (party, U=0.25) to
|
||||
`gable_wall_external` (external, cascade uses main-wall U). Flats
|
||||
with RR sit at the ends of their building block — the gables are
|
||||
exposed external walls, not party walls. Cert 9501's worksheet
|
||||
treats both RR gables as line (29a) external entries at U=1.7.
|
||||
"""
|
||||
if surface.length_m <= 0 or surface.height_m <= 0:
|
||||
return None
|
||||
if surface.name.startswith("Common Wall"):
|
||||
|
|
@ -2833,6 +2853,13 @@ def _map_elmhurst_rir_surface(
|
|||
if kind == "gable_wall" and surface.gable_type == "Sheltered":
|
||||
kind = "gable_wall_external"
|
||||
u_value_override = surface.default_u_value
|
||||
elif kind == "gable_wall" and surface.gable_type is None and is_flat:
|
||||
# Flat with RR: gables are external by default (top of block,
|
||||
# no neighbour above). Lodge as gable_wall_external with no
|
||||
# u_value override so the cascade falls through to the main-
|
||||
# wall U (`uw` in heat_transmission.py:674) — matches cert
|
||||
# 9501's worksheet treatment of both gable walls at U=1.7.
|
||||
kind = "gable_wall_external"
|
||||
area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m)
|
||||
if kind in ("gable_wall", "gable_wall_external"):
|
||||
# Gable walls aren't insulated through Table 17 — they use Table
|
||||
|
|
@ -2852,14 +2879,21 @@ def _map_elmhurst_rir_surface(
|
|||
|
||||
def _map_elmhurst_room_in_roof(
|
||||
rir: Optional[ElmhurstRoomInRoof],
|
||||
*,
|
||||
is_flat: bool = False,
|
||||
) -> Optional[SapRoomInRoof]:
|
||||
"""Build a `SapRoomInRoof` from the Elmhurst §8.1 detail. Returns
|
||||
None when no RR is lodged (the dwelling has no room-in-roof storey
|
||||
— Summary PDF lacks the `Room(s) in Roof:` row or its area is 0)."""
|
||||
— Summary PDF lacks the `Room(s) in Roof:` row or its area is 0).
|
||||
|
||||
`is_flat` propagates to `_map_elmhurst_rir_surface` so un-typed
|
||||
gable walls in flats route to `gable_wall_external` (RdSAP §3.10
|
||||
+ Table 4 — gables of a top-floor flat are exposed external
|
||||
walls, not party walls)."""
|
||||
if rir is None or rir.floor_area_m2 <= 0:
|
||||
return None
|
||||
detailed = [
|
||||
s for s in (_map_elmhurst_rir_surface(s) for s in rir.surfaces)
|
||||
s for s in (_map_elmhurst_rir_surface(s, is_flat=is_flat) for s in rir.surfaces)
|
||||
if s is not None
|
||||
]
|
||||
return SapRoomInRoof(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue