Slice S0380.112: per-BP rooflight allocation (RdSAP 10 §3.7 p.19)

RdSAP 10 §3.7 (PDF p.19) verbatim:

  "for each building part, software will deduct window/door areas
  contained in the relevant wall areas"

The same per-BP deduction applies to roof windows / rooflights
piercing each BP's roof. Pre-slice the cascade lumped every
rooflight's area onto BP[0] Main's `rw_area_part` (S0380.106-era
convention), leaving the actual host BP's gross roof un-deducted.

Cert 000565 §11 Openings lodges:
  Roof Windows 1(Ext2)  External roof Ext2, 1.20 m²
  Roof Windows 2(Ext4)  External roof Ext4, 0.50 m²

Worksheet (30) ground truth — each rooflight deducts from its
host BP's gross roof:
  Ext2: 25.00 − 1.20 = 23.80 net × 0.30 = 7.1400 W/K
  Ext4:  3.00 − 0.50 =  2.50 net × 0.00 = 0.0000 W/K

Pre-slice cascade:
  Ext2: 25.00 (un-deducted) × 0.30 = 7.5000 (+0.36 W/K over)
  Plus 1.70 m² of RW area lumped onto Main's external aggregate
  → +1.20 m² double-count (Ext2 gross + Main rw_area_part)

3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `window_location:
   Union[int, str] = 0` to SapRoofWindow (mirror of
   `SapWindow.window_location` shape).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
   thread `w.building_part` through (mirror of
   `_map_elmhurst_window`'s pass-through).
3. `domain/sap10_calculator/worksheet/heat_transmission.py`: pre-loop
   compute `rw_area_by_bp[i]` from each `SapRoofWindow.window_location`
   via the existing `_window_bp_index` resolver; per-BP loop reads
   `rw_area_by_bp[i]` instead of allocating everything to BP[0].

Cohort safety: cert 000516's lone rooflight is on the Main BP
(Summary §11 row "Main, External wall"), so the per-BP allocation
returns Main = 0 = same as the prior lump-on-Main convention. The
000516 hand-built fixture's SapRoofWindow now sets
`window_location="Main"` to mirror the Elmhurst mapper string-form.

Cert 000565 cascade snapshot (HEAD 794ef7ed → this):
  roof_w_per_k          51.6773 → 51.3185 (Δ +0.30 → -0.06)
  total_external_area  858.66  → 857.46  (Δ +1.02 → -0.18)
  thermal_bridging_w/k 128.80  → 128.62  (Δ +0.15 → -0.03)
  sap_score (int)          28 → 29 ✓ EXACT (recovered)
  sap_score_continuous 28.4903 → 28.5027  (Δ -0.0184 → -0.0060)
  ecf                   5.3887 →  5.3877
  total_fuel_cost_gbp  4681.89 → 4681.01
  co2_kg_per_yr        6449.73 → 6448.59
  space_heating_kwh   59031.86 → 59019.21
  main_heating_fuel   34724.63 → 34715.31

Closes the +1.20 m² Ext2 rooflight double-count. Remaining
residuals (Ext3 -0.17 m² + -0.06 W/K) closed by S0380.113 (H=0
gable retention).

Pyright net-zero (58 → 58 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 19:15:16 +00:00
parent 794ef7ed8b
commit a461b70d19
5 changed files with 123 additions and 9 deletions

View file

@ -2433,6 +2433,88 @@ def test_summary_000565_roof_window_u_value_applies_table_6e_note_2_inclination_
)
def test_summary_000565_rooflights_deduct_from_their_own_bp_gross_roof_per_rdsap_10_section_3_7() -> None:
# Arrange — RdSAP 10 §3.7 "Door and window areas" (PDF p.19)
# verbatim:
#
# "for each building part, software will deduct window/door areas
# contained in the relevant wall areas"
#
# The same convention applies to roof windows / rooflights piercing
# a BP's roof: the rooflight's area deducts from the BP's gross roof
# area on worksheet (30). Pre-S0380.112 the cascade lumped every
# rooflight's area onto BP[0] Main's `rw_area_part`, leaving the
# actual host BP's gross roof un-deducted — a +1.20 m² double-count
# for cert 000565 (RW1 area 1.20 lives on Ext2 but Ext2's gross
# roof 25.00 stayed un-deducted, and the same 1.20 also appeared in
# Main's `rw_area_part`).
#
# Cert 000565 §11 Openings table lodges:
# Roof Windows 1(Ext2) Roof Window, External roof Ext2, ...
# Roof Windows 2(Ext4) Roof Window, External roof Ext4, ...
#
# Per-BP roof-window allocation (worksheet ground truth):
# Ext2 (BP[2]): gross 25.00 1.20 RW1 = 23.80 net, U=0.30
# → ws (30): 23.80 × 0.30 = 7.1400 W/K
# Ext4 (BP[4]): gross 3.00 0.50 RW2 = 2.50 net, U=0.00
# → ws (30): 2.50 × 0.00 = 0.0000 W/K
#
# Pre-slice cascade:
# Ext2 cascade: 25.00 (un-deducted) × 0.30 = 7.5000 → +0.36 W/K over ws
# Ext4 cascade: 0 (party roof, rooflight allocated to Main) → no contribution
# Plus +1.70 m² of rooflight area lumped onto Main's external area
#
# Post-slice expected:
# sap_roof_windows[*].window_location threads the lodged BP index
# so the cascade's per-BP loop deducts each rooflight's area from
# its host BP's gross roof + contributes the area to that BP's
# external area aggregate (matching the worksheet's per-BP rows).
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
from domain.sap10_calculator.rdsap.cert_to_inputs import heat_transmission_section_from_cert
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert — sap_roof_windows lodge their host-BP signal so the
# cascade's per-BP rooflight deduction routes correctly. Mirrors
# SapWindow.window_location Union[int, str] shape — the cascade
# resolves both forms via `_window_bp_index`. Here we assert the
# raw lodged string the Elmhurst mapper threads through (matches
# how `_map_elmhurst_window` populates `SapWindow.window_location`).
assert epc.sap_roof_windows is not None
rooflights_by_area = {
round(float(rw.area_m2), 2): rw for rw in epc.sap_roof_windows
}
assert rooflights_by_area[1.20].window_location == "2nd Extension", (
f"RW1 (Ext2 rooflight) window_location="
f"{rooflights_by_area[1.20].window_location!r} (expected '2nd Extension')"
)
assert rooflights_by_area[0.50].window_location == "4th Extension", (
f"RW2 (Ext4 rooflight) window_location="
f"{rooflights_by_area[0.50].window_location!r} (expected '4th Extension')"
)
# Assert — roof_w_per_k closes to ws (51.38 from §3 per-element rows;
# Ext3 -0.06 W/K residual from absent Gable Wall 2 is closed in
# S0380.113 — until then, this slice closes the Ext2 +0.36 W/K
# over-count, leaving cascade UNDER by 0.06).
assert abs(ht.roof_w_per_k - 51.32) <= 1e-2, (
f"cascade roof_w_per_k={ht.roof_w_per_k:.4f}; "
f"expected ~51.32 (= ws 51.38 0.06 Ext3 residual deferred to "
f"S0380.113); Δ={ht.roof_w_per_k - 51.32:+.4f}"
)
# Assert — total external area closes the Ext2 +1.20 m² double-count
# (remaining residual is Ext3 -0.17 m² from absent Gable Wall 2,
# closed by S0380.113).
assert abs(ht.total_external_element_area_m2 - 857.46) <= 1e-2, (
f"cascade total_external_element_area_m2={ht.total_external_element_area_m2:.4f}; "
f"expected ~857.46 (= ws 857.64 0.17 Ext3 residual deferred to "
f"S0380.113); Δ={ht.total_external_element_area_m2 - 857.46:+.4f}"
)
def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None:
# Arrange — strict-coverage gate per [[reference-unmapped-api-
# code]] mirror: an Elmhurst wall_type lodgement that isn't in

View file

@ -212,6 +212,14 @@ class SapRoofWindow:
Light transmittance column). Defaults to 3 (Double 2002-2021) the
modal cohort lodgement and the type assumed by hand-built worksheet
fixtures that pre-date this field.
`window_location` is the SAP10.2 building-part index (0=Main, 1=Ext1,
). Mirrors `SapWindow.window_location`. The cascade's per-BP loop
deducts each rooflight's area from the gross roof of the BP it
pierces (RdSAP10 §3.7 "for each building part, software will deduct
window/door areas contained in the relevant wall areas"). Defaults
to 0 (Main) for hand-built fixtures and the prior pre-S0380.112
convention where all rooflights were lumped onto BP[0].
"""
area_m2: float
@ -221,6 +229,11 @@ class SapRoofWindow:
g_perpendicular: float = 0.76
frame_factor: float = 0.70
glazing_type: int = 3 # SAP10.2 Table U2; 3 = Double 2002-2021 (cohort modal).
# SAP10.2 BP index; 0=Main, 1..4=Ext1..Ext4. Mirrors
# `SapWindow.window_location` shape (int from API, str from
# site notes) — `_window_bp_index` in heat_transmission handles
# the Union resolution.
window_location: Union[int, str] = 0
@dataclass

View file

@ -3724,6 +3724,16 @@ def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow:
# `_map_elmhurst_window` so cohort and 000565 rooflights both
# carry their lodged glazing-type signal end-to-end.
glazing_type=_elmhurst_glazing_type_code(w.glazing_type),
# RdSAP 10 §3.7 (PDF p.19) "for each building part, software
# will deduct window/door areas contained in the relevant wall
# areas" — extended to roof windows. Threads the Elmhurst
# lodged building-part string ("Main" / "1st Extension" / ...)
# through to the cascade's `_window_bp_index` resolver (mirror
# of `_map_elmhurst_window`'s `window_location=w.building_part`
# pass-through) so the per-BP loop deducts the rooflight area
# from its host BP's gross roof and bills the area to that BP's
# external area aggregate.
window_location=w.building_part,
)

View file

@ -513,12 +513,17 @@ def heat_transmission_from_cert(
)
# SAP10.2 §3 (27a) — per-roof-window curtain transform, same R=0.04
# rule as (27). Total area is apportioned to the first (main) part
# below so the storey-below roof gross is reduced by the rooflight
# opening — same convention as wall windows reducing the gross wall.
# rule as (27). Per RdSAP 10 §3.7 (PDF p.19) "for each building
# part, software will deduct window/door areas contained in the
# relevant wall areas" — each rooflight's area deducts from the
# gross roof of the BP it pierces (same convention as wall windows
# reducing the gross wall). The per-BP loop below reads
# `rw_area_by_bp[i]` to subtract from each BP's `gross_roof_area`
# and bill into that BP's external area aggregate.
roof_windows_list: list[SapRoofWindow] = list(epc.sap_roof_windows or [])
roof_windows_w_per_k_total = 0.0
roof_windows_area_total = 0.0
rw_area_by_bp = [0.0] * len(parts)
for rw in roof_windows_list:
a_rw = _round_half_up(float(rw.area_m2), _AREA_ROUND_DP)
u_raw_rw = float(rw.u_value_raw)
@ -528,6 +533,8 @@ def heat_transmission_from_cert(
)
roof_windows_w_per_k_total += a_rw * u_eff_rw
roof_windows_area_total += a_rw
bp_idx = _window_bp_index(rw.window_location, len(parts))
rw_area_by_bp[bp_idx] += a_rw
primary_age = parts[0].construction_age_band
door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None)
door_insulated_u = (
@ -752,12 +759,13 @@ def heat_transmission_from_cert(
d_area = door_area if i == 0 else 0.0
net_wall_area = max(0.0, gross_wall_area - w_area - d_area)
party_area = geom["party_wall_area_m2"]
# Roof windows cut into the storey-below roof, reducing the regular
# roof's net area. Allocated to the first (main) part — same
# convention as `sap_windows` / `door_area`.
rw_area_part = (
_round_half_up(roof_windows_area_total, _AREA_ROUND_DP) if i == 0 else 0.0
)
# Roof windows cut into the storey-below roof, reducing the
# regular roof's net area. Per RdSAP 10 §3.7 (PDF p.19) "for
# each building part, software will deduct window/door areas
# contained in the relevant wall areas" — each rooflight
# deducts from its host BP's gross roof. `rw_area_by_bp[i]` is
# built pre-loop from each `SapRoofWindow.window_location`.
rw_area_part = _round_half_up(rw_area_by_bp[i], _AREA_ROUND_DP)
# RdSAP 10 §3.8 "Roof area": roof area is the greatest of the
# floor areas on each level. For a pitched roof with a sloping
# ceiling, divide that area by cos(30°) — the worksheet enters

View file

@ -170,6 +170,7 @@ def build_epc() -> EpcPropertyData:
g_perpendicular=0.76,
frame_factor=0.70,
glazing_type=2, # SAP10.2 Table U2 "Double pre 2002"
window_location="Main", # Mirrors Elmhurst mapper's string form.
),
],
percent_draughtproofed=75,