mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.84: RR mapper spec-correct routing + cascade common_wall handling per RdSAP 10 §3.9.2/§3.10
Cascades the spec-correct §3.10 Room-in-Roof routing through the
mapper + heat-transmission section. Three coupled changes:
1. **Mapper drops "Connected" gables** — per RdSAP 10 Table 4 (PDF p.22)
row 4 a gable wall "Connected to heated space" is an internal
partition, NOT a heat-loss surface. The Elmhurst Summary §8.1 PDF
may lodge the short form "Connected" or the verbose "Connected to
heated space"; both route to `return None` in
`_map_elmhurst_rir_surface`.
2. **Mapper routes "Exposed" gables → `gable_wall_external` with the
lodged U** — per Table 4 row 1 an exposed RR gable wall bills at the
lodged U-value (or the storey-below main-wall U). For non-flat
dwellings the `default_u_value` rides through as `u_value` override
so the cascade uses the lodged figure directly. Flats preserve their
legacy no-override routing so the cascade falls through to main-wall
U (cert 9501).
3. **Mapper surfaces Common Wall surfaces + applies spec area formula**
per RdSAP 10 §3.9.2 + Table 4:
Detailed assessment → raw L × H per surface
Simplified + Common Walls → L × (0.25 + H) for common walls;
L × (0.25 + H_gable)
− Σ_n (H_gable − H_common,n)² / 2
for gables
Simplified + no Common Walls → raw L × H for gables
The 0.25-m structural-gap offset accounts for the space between the
RR floor and the storey-below ceiling. The gable correction
subtracts the triangular slice above each common wall.
4. **Cascade adds `common_wall` kind** in `heat_transmission.py` — mirror
of `gable_wall_external`: walls += area × (`surf.u_value` or main-wall
U). Mapper precomputes the spec area so the cascade reads `area_m2`
directly.
Verified against the cert 000565 U985 worksheet PDF "External Walls"
section per BP:
| BP | Surface | Formula | Worksheet | Cascade |
|----|---------------------|-------------------------------------------|-----------|---------|
| 0 | Main GW1 (Exposed) | 4 × 2.45 (Simplified, no CW) | 9.80 | 9.80 ✓ |
| 0 | Main GW2 (Sheltered)| 6 × 2.45 | 14.70 | 14.70 ✓|
| 1 | Ext1 CW1 | 9 × (0.25 + 1.0) (Simplified + CW) | 11.25 | 11.25 ✓|
| 1 | Ext1 CW2 | 5 × (0.25 + 1.8) | 10.25 | 10.25 ✓|
| 1 | Ext1 GW2 (Exposed) | 8 × (0.25 + 9) − ((9−1)²+(9−1.8)²)/2 | 16.08 | 16.08 ✓|
| 2 | Ext2 GW2 (Exposed) | 3 × 8 (Detailed) | 24.00 | 24.00 ✓|
| 3 | Ext3 CW1 | 5 × (0.25 + 1.5) (Simplified + CW) | 8.75 | 8.75 ✓ |
| 3 | Ext3 CW2 | 7.5 × (0.25 + 0.3) | 4.13 | 4.13 ✓ |
| 3 | Ext3 GW1 (Exposed) | 9 × (0.25+7) − ((7−1.5)²+(7−0.3)²)/2 | 27.68 | 27.68 ✓|
| 4 | Ext4 CW1 | 4 × 1 (Detailed) | 4.00 | 4.00 ✓ |
| 4 | Ext4 CW2 | 3.5 × 0.6 | 2.10 | 2.10 ✓ |
Cohort impact:
- Cert 9501 (top-floor flat with Detailed RR + Exposed gables) —
PASSES (the flat-RR elif still routes; gables stay at main-wall U
via cascade fall-through).
- All other cohort fixtures: unaffected (no RR or fully-Detailed RR
where raw L × H is also the spec answer).
Cert 000565 cascade subtotals close substantially:
walls 322.21 → 443.51 (worksheet 604.07, Δ −282 → Δ −161, 43% closed)
party walls 153.46 → 93.26 (worksheet 65.13, Δ +88 → Δ +28, 68% closed)
HTC fabric 716.43 → 795.24 (Δ +79 W/K — cascade closer to worksheet)
The remaining 161 W/K under-count in walls + 28 W/K over-count in
party walls localise to the BP main-wall cascade (NOT RR). The cert
000565 sap_score e2e pin regresses from EXACT (29) to Δ−3 (26) because
the previous compensating cascade gaps are now exposed — the
spec-correct fix is real, the residual is real, and the next slice
closes the BP main-wall gap (likely the "External walls Main alt.1"
basement-override at 23 m² × U=2.34 = 53.82 W/K + per-BP main-wall
U/area refinements). Per [[feedback-spec-citation-in-commits]] +
[[feedback-spec-floor-skepticism]] the spec-correct fix ships even
when the test pin temporarily regresses; the diagnostic signal is
sharper now.
Test baseline: 555 pass + 9 expected `test_sap_result_pin[000565-*]`
fails (was 555 + 8; sap_score now in the failing set with cascade-
exposed BP main-wall gap surfaced). Cohort + golden fixtures
unaffected. Pyright net-zero on touched files (59 errors, matches
baseline).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ed8fdc6ae3
commit
49622f5525
4 changed files with 216 additions and 26 deletions
|
|
@ -582,6 +582,96 @@ def test_summary_000565_extractor_recognises_exposed_and_connected_gable_types()
|
|||
)
|
||||
|
||||
|
||||
def test_summary_000565_rr_mapper_routes_exposed_to_external_drops_connected_and_surfaces_common_walls() -> None:
|
||||
"""RdSAP 10 §3.9 (Simplified) + §3.10 (Detailed) + Table 4 (PDF p.22):
|
||||
the cert's Room-in-Roof per-surface table classifies gable walls
|
||||
by exposure column AND derives areas via two different methods
|
||||
depending on assessment type:
|
||||
|
||||
Gable / common-wall environment column → heat-loss routing:
|
||||
Exposed → external wall at lodged or main-wall U
|
||||
Sheltered → external wall at lodged U
|
||||
Party → party wall at U = 0.25
|
||||
Connected → internal partition (NOT a heat-loss surface)
|
||||
|
||||
Area derivation:
|
||||
Detailed assessment → raw L × H per surface
|
||||
Simplified + Common Walls → L × (0.25 + H) for common walls;
|
||||
L × (0.25 + H_gable) − Σ_n
|
||||
(H_gable − H_common,n)² / 2 for
|
||||
gables
|
||||
Simplified + no Common Walls → raw L × H for gables (no
|
||||
structural-gap offset)
|
||||
|
||||
The 0.25-m offset accounts for the structural gap between the RR
|
||||
floor and the storey-below ceiling (per RdSAP 10 §3.9.2 + Table 4
|
||||
p.22). The gable correction subtracts the triangular slice above
|
||||
each common wall where the gable above transitions to the common
|
||||
wall below.
|
||||
|
||||
Pin: cert 000565 BP[1] Ext1 lodges (Simplified, Common Wall 1 9×1,
|
||||
Common Wall 2 5×1.8, Gable Wall 1 4×6 Connected, Gable Wall 2 8×9
|
||||
Exposed @ U=1.70). After this slice the mapper produces:
|
||||
- Common Wall 1 → SapRoomInRoofSurface(kind='common_wall',
|
||||
area_m2=11.25, u_value=1.70)
|
||||
- Common Wall 2 → SapRoomInRoofSurface(kind='common_wall',
|
||||
area_m2=10.25, u_value=1.70)
|
||||
- Gable Wall 1 → dropped (Connected, internal partition)
|
||||
- Gable Wall 2 → SapRoomInRoofSurface(kind='gable_wall_external',
|
||||
area_m2=16.08, u_value=1.70)
|
||||
|
||||
All three values pin to the U985 worksheet for this BP at abs=1e-2:
|
||||
Roof room Ext1 common wall 1: 11.25
|
||||
Roof room Ext1 common wall 2: 10.25
|
||||
Roof room Ext1 Gable Wall 2 : 16.08
|
||||
"""
|
||||
# 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[1] is the Ext1 building part (BPs[0]=Main, [1]=Ext1).
|
||||
ext1_bp = epc.sap_building_parts[1]
|
||||
rir = ext1_bp.sap_room_in_roof
|
||||
assert rir is not None and rir.detailed_surfaces is not None
|
||||
detailed = rir.detailed_surfaces
|
||||
|
||||
# Connected gables drop — no kind='gable_wall' surface at the raw 24 m² area.
|
||||
gable_walls_24 = [
|
||||
s for s in detailed
|
||||
if s.kind == "gable_wall" and abs(s.area_m2 - 24.0) <= 1e-2
|
||||
]
|
||||
assert not gable_walls_24, (
|
||||
f"Connected gable (24 m² raw) leaked into kind='gable_wall': "
|
||||
f"{gable_walls_24}"
|
||||
)
|
||||
|
||||
# Common walls surfaced at spec-formula areas.
|
||||
common_walls = [s for s in detailed if s.kind == "common_wall"]
|
||||
common_areas = sorted(s.area_m2 for s in common_walls)
|
||||
assert any(abs(a - 10.25) <= 1e-2 for a in common_areas), (
|
||||
f"Ext1 Common Wall 2 (5 × (0.25 + 1.8) = 10.25) missing from "
|
||||
f"common_wall surfaces: areas={common_areas}"
|
||||
)
|
||||
assert any(abs(a - 11.25) <= 1e-2 for a in common_areas), (
|
||||
f"Ext1 Common Wall 1 (9 × (0.25 + 1.0) = 11.25) missing from "
|
||||
f"common_wall surfaces: areas={common_areas}"
|
||||
)
|
||||
|
||||
# Exposed gable surfaced at spec-corrected area + lodged U.
|
||||
gable_externals = [s for s in detailed if s.kind == "gable_wall_external"]
|
||||
assert any(
|
||||
abs(s.area_m2 - 16.08) <= 1e-2 and s.u_value == 1.70
|
||||
for s in gable_externals
|
||||
), (
|
||||
f"Ext1 Gable Wall 2 (8 × (0.25 + 9) − ((9−1)² + (9−1.8)²)/2 = "
|
||||
f"16.08, U=1.70) missing from gable_wall_external surfaces: "
|
||||
f"{[(s.area_m2, s.u_value) for s in gable_externals]}"
|
||||
)
|
||||
|
||||
|
||||
def test_summary_9501_pv_array_surfaced_from_elmhurst_section_19() -> None:
|
||||
# Arrange — cert 9501's Elmhurst §19.0 PV section lodges measured
|
||||
# array detail (2.36 kWp, South-West orientation, 45° elevation,
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ class SapRoomInRoofSurface:
|
|||
"connected to heated space" U=0) are not yet seen in the corpus.
|
||||
"""
|
||||
|
||||
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall" | "gable_wall_external"
|
||||
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall" | "gable_wall_external" | "common_wall"
|
||||
area_m2: float
|
||||
insulation_thickness_mm: Optional[int] = None
|
||||
insulation_type: Optional[str] = None # "mineral_wool" / "eps" / "pur" / "pir"
|
||||
|
|
|
|||
|
|
@ -3244,24 +3244,67 @@ def _map_elmhurst_rir_surface(
|
|||
surface: ElmhurstRoomInRoofSurface,
|
||||
*,
|
||||
is_flat: bool = False,
|
||||
is_simplified: bool = False,
|
||||
common_wall_heights: Optional[List[float]] = None,
|
||||
) -> 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).
|
||||
Connected gable (internal partition to heated space, NOT a heat-
|
||||
loss surface per RdSAP 10 §3.10 + Table 4 p.22).
|
||||
|
||||
`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.
|
||||
Area derivation follows the assessment type per RdSAP 10:
|
||||
Detailed → raw `L × H` for every surface
|
||||
Simplified + Common Walls present:
|
||||
common walls → `L × (0.25 + H)` (§3.9.2)
|
||||
gable_wall_external → `L × (0.25 + H_gable)
|
||||
− Σ_n (H_gable − H_common,n)² / 2`
|
||||
Simplified + no Common Walls
|
||||
gables → raw `L × H` (no structural-gap offset)
|
||||
|
||||
Gable / common-wall environment column → heat-loss routing per
|
||||
Table 4 (p.22):
|
||||
Exposed → external wall at lodged U (uses `default_u_value`
|
||||
as `u_value` override; cascade applies that or
|
||||
falls through to main-wall U when None)
|
||||
Sheltered → external wall at lodged U
|
||||
Party → party wall at U = 0.25 (default `gable_wall`)
|
||||
Connected → internal partition — return None
|
||||
|
||||
`is_flat=True` keeps the legacy flat-RR routing for the `gable_type
|
||||
in (None, "Exposed")` case (cert 9501 — top-floor flat with RR;
|
||||
both gables external at main-wall U with no override).
|
||||
|
||||
`common_wall_heights` carries the heights of the BP's Common Wall
|
||||
surfaces (after the 0×0 filter) for the gable-correction term;
|
||||
callers compute it once per BP and pass it through to all surfaces
|
||||
so the correction applies consistently across both gables.
|
||||
"""
|
||||
if surface.length_m <= 0 or surface.height_m <= 0:
|
||||
return None
|
||||
if surface.name.startswith("Common Wall"):
|
||||
# RdSAP 10 §3.10 Table 4 row 4 — "Connected to heated space" gables
|
||||
# are internal partitions, not heat-loss surfaces. Per Summary PDF
|
||||
# schema the column reads "Connected" (or the verbose "Connected
|
||||
# to heated space"); drop either form.
|
||||
if surface.gable_type in ("Connected", "Connected to heated space"):
|
||||
return None
|
||||
if surface.name.startswith("Common Wall"):
|
||||
# RdSAP 10 §3.9.2 Simplified Type 2 — common walls billing into
|
||||
# the RR carry the storey-below main-wall U via the lodged
|
||||
# `default_u_value`. Detailed assessment uses raw L × H per
|
||||
# surface; Simplified applies the 0.25-m structural-gap offset
|
||||
# so the area matches the worksheet's "Roof room <BP> common
|
||||
# wall N" entry.
|
||||
length_m, height_m = surface.length_m, surface.height_m
|
||||
if is_simplified:
|
||||
area_m2 = _round_half_up_2dp(length_m, 0.25 + height_m)
|
||||
else:
|
||||
area_m2 = _round_half_up_2dp(length_m, height_m)
|
||||
return SapRoomInRoofSurface(
|
||||
kind="common_wall",
|
||||
area_m2=area_m2,
|
||||
u_value=surface.default_u_value,
|
||||
)
|
||||
prefix = next(
|
||||
(p for p in _RIR_KIND_FROM_NAME_PREFIX if surface.name.startswith(p)),
|
||||
None,
|
||||
|
|
@ -3269,29 +3312,52 @@ def _map_elmhurst_rir_surface(
|
|||
if prefix is None:
|
||||
return None
|
||||
kind = _RIR_KIND_FROM_NAME_PREFIX[prefix]
|
||||
# RdSAP Table 4 Gable Wall variant: "Party" → "gable_wall" (default
|
||||
# U=0.25 per Table 4 row 2); "Sheltered" → "gable_wall_external"
|
||||
# with the assessor-lodged U-value (line 29 of the U985 worksheet
|
||||
# carries the lodged measurement) overriding the cascade.
|
||||
u_value_override: Optional[float] = None
|
||||
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 == "Exposed":
|
||||
kind = "gable_wall_external"
|
||||
if not is_flat:
|
||||
# Non-flat dwelling: the cert lodges the gable's measured
|
||||
# U via `default_u_value` (e.g. cert 000565 BP[0] Gable
|
||||
# Wall 1 lodges U=0.35 matching the BP main-wall U). Apply
|
||||
# as override so the cascade uses the lodged figure.
|
||||
u_value_override = surface.default_u_value
|
||||
# else: flat with Exposed gable — preserve the legacy no-override
|
||||
# path so the cascade falls through to main-wall U (`uw` in
|
||||
# heat_transmission.py). Matches cert 9501.
|
||||
elif (
|
||||
kind == "gable_wall"
|
||||
and surface.gable_type in (None, "Exposed")
|
||||
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.
|
||||
# Per Summary PDF schema the gable env column reads "Exposed"
|
||||
# for the same case the legacy heuristic detected via None;
|
||||
# both lodging shapes route here.
|
||||
# Flat with un-typed gable (pre-S0380.83 extractor data shape):
|
||||
# route external with no override. Same final cascade output
|
||||
# as the "Exposed" + is_flat branch above.
|
||||
kind = "gable_wall_external"
|
||||
area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m)
|
||||
# Area derivation per assessment + common-wall presence.
|
||||
if (
|
||||
kind == "gable_wall_external"
|
||||
and is_simplified
|
||||
and common_wall_heights
|
||||
):
|
||||
# Spec formula (RdSAP 10 §3.9.2 + Table 4 p.22):
|
||||
# A_gable = L × (0.25 + H_gable)
|
||||
# − Σ_each_common (H_gable − H_common,n)² / 2
|
||||
# Clamp each correction at zero when the common wall is taller
|
||||
# than the gable (negative-area protection).
|
||||
length_m, height_m = surface.length_m, surface.height_m
|
||||
correction = sum(
|
||||
((height_m - h) ** 2) / 2.0
|
||||
for h in common_wall_heights
|
||||
if height_m > h
|
||||
)
|
||||
area_m2 = _round_half_up_2dp(
|
||||
1.0, max(0.0, length_m * (0.25 + height_m) - correction)
|
||||
)
|
||||
else:
|
||||
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
|
||||
# 4 / measured U. Don't lodge an insulation thickness on them.
|
||||
|
|
@ -3320,11 +3386,33 @@ def _map_elmhurst_room_in_roof(
|
|||
`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)."""
|
||||
walls, not party walls).
|
||||
|
||||
Computes the per-BP common-wall heights once and threads them
|
||||
through every surface so the Simplified-assessment gable
|
||||
correction (RdSAP 10 §3.9.2 + Table 4 p.22) applies consistently.
|
||||
"""
|
||||
if rir is None or rir.floor_area_m2 <= 0:
|
||||
return None
|
||||
# Pre-compute heights of lodged Common Walls for the gable
|
||||
# correction; only non-zero rows count toward the worksheet sum.
|
||||
common_wall_heights = [
|
||||
s.height_m
|
||||
for s in rir.surfaces
|
||||
if s.name.startswith("Common Wall")
|
||||
and s.length_m > 0
|
||||
and s.height_m > 0
|
||||
]
|
||||
is_simplified = rir.assessment.startswith("Simplified")
|
||||
detailed = [
|
||||
s for s in (_map_elmhurst_rir_surface(s, is_flat=is_flat) for s in rir.surfaces)
|
||||
s for s in (
|
||||
_map_elmhurst_rir_surface(
|
||||
s, is_flat=is_flat,
|
||||
is_simplified=is_simplified,
|
||||
common_wall_heights=common_wall_heights,
|
||||
)
|
||||
for s in rir.surfaces
|
||||
)
|
||||
if s is not None
|
||||
]
|
||||
return SapRoomInRoof(
|
||||
|
|
|
|||
|
|
@ -873,6 +873,18 @@ def heat_transmission_from_cert(
|
|||
u_gable = surf.u_value if surf.u_value is not None else uw
|
||||
rr_detailed_area += area
|
||||
walls += u_gable * area
|
||||
elif kind == "common_wall":
|
||||
# RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22
|
||||
# "Common wall": billed as external wall at the
|
||||
# storey-below main-wall U. Mapper precomputes the
|
||||
# spec area: `L × (0.25 + H)` on Simplified BPs,
|
||||
# raw `L × H` on Detailed BPs. The lodged
|
||||
# `default_u_value` rides through as `surf.u_value`;
|
||||
# cascade falls through to main-wall U when None
|
||||
# (mirror of the `gable_wall_external` rule above).
|
||||
u_common = surf.u_value if surf.u_value is not None else uw
|
||||
rr_detailed_area += area
|
||||
walls += u_common * area
|
||||
floor += uf * floor_area_total
|
||||
# RdSAP "first floor over passageway" cantilever — only fires
|
||||
# for houses (property_type=0); see `_part_geometry` filters.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue