Slice S0380.111: roof-window inclination adj via Table 6e Note 2 (SAP 10.2 p.180)

SAP 10.2 §3.2 "Roof windows" (PDF p.10) verbatim:

  "In the case of roof windows, unless the measurement or calculation
  has been done for the actual inclination of the roof window,
  adjustments as given in Notes 1 and 2 to Table 6e or from BR443
  (2019) should be applied."

SAP 10.2 Table 6e Note 2 (PDF p.180) — "For roof windows the
following adjustments should be applied to convert a known vertical
U-value into the U-value for the known inclined position":

   Inclination                    Twin skin or DG    Triple skin or TG
   70° or more (vertical)               +0.0              +0.0
   < 70° and > 60°                      +0.2              +0.1
   60° and > 40°                        +0.3              +0.2
   40° and > 30°                        +0.4              +0.2
   30° or less (horizontal)             +0.5              +0.3

SAP 10.2 §3.2 formula (2):

    U_w,effective = 1 / (1/U_w + 0.04)                          (2)

The +0.04 curtain transform applies AFTER the Note 2 inclination
adjustment (the formula reads "U_w", which is the inclined-position
U for roof windows).

Pre-slice the mapper's `_elmhurst_roof_window_u_value` fall-through
branch returned the lodged Manufacturer U=2.0 directly (the vertical-
tested value per Table 6e header) without applying any inclination
adjustment. The cascade then applied formula (2) → U_eff = 1/(1/2.0 +
0.04) = 1.852 for both cert 000565 rooflights, totalling 1.7 × 1.852
= 3.1484 W/K vs the worksheet's (27a) Σ A × 2.1062 = 3.5806 W/K
(residual -0.43 W/K).

Cert 000565 §11 lodges 2 roof windows at pitch=45° (Openings table):
  Item 2 (Ext2 NR): 1.2 m², "Triple between 2002 and 2021",
    Manufacturer U=2.0, g=0.72, PVC FF=0.70
  Item 5 (Ext4 A):  0.5 m², "Double between 2002 and 2021",
    Manufacturer U=2.0, g=0.72, Wood FF=0.70

Both lodge at pitch=45° → Note 2 "60° and > 40°" row. The worksheet
applies +0.30 W/m²K uniformly to both (DG-column value), yielding
U_inclined = 2.30 → formula (2) → U_eff = 2.1062 in both cases.
Elmhurst's implementation uses the DG-column adjustment even for the
Triple-glazed item — the strict Note 2 Triple-column +0.20
alternative would yield 2.0222 for Item 2, contradicting the
worksheet's 2.1062.

Fix scope (mapper-side, single helper):

`datatypes/epc/domain/mapper.py` `_elmhurst_roof_window_u_value`:
  - New constant `_ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_
    M2K = 0.30` (Table 6e Note 2 DG @ 40-60°).
  - Fall-through branch now returns `w.u_value + 0.30` instead of
    `w.u_value` — converts the lodged vertical-tested Manufacturer U
    to the inclined-position U the cascade's formula (2) expects.
  - Lookup path (`_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double pre 2002"]
    = 3.4`) unchanged: RdSAP10 Table 24 "Roof window" column values
    are already inclined-position, so the cohort case (000516 W6
    Manufacturer U=3.10 → Table 24 returns 3.40 → cascade formula
    (2) → 2.9930) stays bit-exact.

Cohort safety verified at 000516 worksheet (27a): U_eff = 2.9930
preserved (Table 24 lookup path unaffected).

Cert 000565 cascade snapshot (HEAD 9461e657 → this):
  roof_windows_w_per_k    3.1484  → 3.5806  ✓ EXACT (Δ -0.43 → +0.0001)
  total_w_per_k           937.09  → 937.51  (Δ +0.03 → +0.45 — closing
                                              roof_windows exposes
                                              previously-cancelling
                                              roof +0.30 + TB +0.15
                                              over-counts)
  sap_score (int)             29 → 28 (transiently — continuous
                                       crossed 28.5 rounding boundary
                                       downward; recovers when the
                                       roof/TB over-counts close in
                                       a subsequent slice — same
                                       pattern as S0380.107 → .108)
  sap_score_continuous   28.5002 → 28.4903 (Δ -0.0085 → -0.0184)
  ecf                     5.3877 → 5.3887   (Δ +0.0011 → +0.0021)
  total_fuel_cost_gbp    4681.01 → 4681.89  (+0.75 → +1.63)
  co2_kg_per_yr          6448.59 → 6449.73  (+0.96 → +2.10)
  space_heating_kwh     59019.18 → 59031.86 (+10.83 → +23.51)
  main_heating_fuel     34717.16 → 34724.63 (+6.37  → +13.83)
  lighting_kwh_per_yr         ✓ EXACT (preserved)

This is the [[feedback-spec-floor-skepticism]] pattern: a spec-correct
closure exposes previously-cancelling residuals elsewhere. Continuous
SAP magnitude widens (0.0085 → 0.0184) and integer SAP sign-flips
across the 28.5 boundary, but the spec-correct path is now in place.
The next slice would close the roof (+0.30) or TB (+0.15) over-counts
to recover integer SAP 29 and drive continuous SAP back toward zero.

Pyright net-zero (45 → 45 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 18:43:10 +00:00
parent 9461e657a5
commit 794ef7ed8b
2 changed files with 112 additions and 4 deletions

View file

@ -2350,6 +2350,89 @@ def test_summary_000565_rooflight_per_window_g_l_routes_via_glazing_type_per_sap
)
def test_summary_000565_roof_window_u_value_applies_table_6e_note_2_inclination_adjustment_per_sap_10_2_section_3_2() -> None:
# Arrange — SAP 10.2 §3.2 "Roof windows" (PDF p.10) verbatim:
#
# "In the case of roof windows, unless the measurement or
# calculation has been done for the actual inclination of the
# roof window, adjustments as given in Notes 1 and 2 to Table 6e
# or from BR443 (2019) should be applied."
#
# SAP 10.2 Table 6e Note 2 (PDF p.180) — "For roof windows the
# following adjustments should be applied to convert a known
# vertical U-value into the U-value for the known inclined
# position":
#
# Inclination Twin skin or DG Triple skin or TG
# 70° or more (vertical) +0.0 +0.0
# < 70° and > 60° +0.2 +0.1
# 60° and > 40° +0.3 +0.2
# 40° and > 30° +0.4 +0.2
# 30° or less (horizontal) +0.5 +0.3
#
# SAP 10.2 §3.2 formula (2) — curtain transform applied after the
# inclination adjustment:
#
# U_w,effective = 1 / (1/U_w + 0.04) (2)
#
# Cert 000565 §11 lodges 2 roof windows (per S0380.107 routing) at
# pitch=45° (Openings table: "Roof Windows 1(Ext2), Roof Window,
# External roof Ext2, North West, 45, ..."):
#
# Item 2 (Ext2 NR): 1.2 m², "Triple between 2002 and 2021",
# PVC FF=0.70, Manufacturer U=2.0, g=0.72
# Item 5 (Ext4 A): 0.5 m², "Double between 2002 and 2021",
# Wood FF=0.70, Manufacturer U=2.0, g=0.72
#
# Both lodge as Manufacturer-supplied U=2.0 (vertical-tested per
# Table 6e header), so Note 2 inclination adjustment applies. The
# worksheet (27a) shows U_eff = 2.1062 for BOTH items — back-solving
# via formula (2): 1/2.1062 = 0.4748; 0.4748 - 0.04 = 0.4348;
# U_inclined = 1/0.4348 = 2.3000 = U_raw + 0.30. Elmhurst applies
# the DG-column +0.30 adjustment uniformly across roof windows at
# 40-60° inclination (the Triple-glazed-column +0.20 alternative
# would yield 2.0222, contradicting the worksheet's 2.1062 for the
# Triple item). The +0.30 = Note 2 "60° and > 40°" DG row.
#
# Worksheet (27a) totals: 1.2 × 2.1062 + 0.5 × 2.1062 = 3.5806 W/K.
# Pre-slice cascade: u_eff = 1/(1/2.0 + 0.04) = 1.852 for both →
# 1.7 × 1.852 = 3.1484 W/K. Net residual -0.43 W/K.
#
# Cohort safety: cert 000516 W6 ("Double pre 2002", Manufacturer
# U=3.10) is routed via the mapper's RdSAP10 Table 24 lookup which
# already returns 3.40 (the pre-adjusted inclined-position value
# per RdSAP10 Table 24 "Roof window" column). The new inclination
# adjustment fires ONLY in the fall-through branch (i.e. when the
# lodged glazing label is not in `_ELMHURST_ROOF_WINDOW_U_BY_
# GLAZING`), so 000516's 3.40 stays unchanged.
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[*].u_value_raw carries the inclined-
# position U (mapper applies +0.30) so the cascade's formula (2)
# curtain transform lands on the worksheet's U_eff=2.1062.
assert epc.sap_roof_windows is not None
inclined_us = sorted(round(float(rw.u_value_raw), 4) for rw in epc.sap_roof_windows)
assert inclined_us == [2.3000, 2.3000], (
f"sap_roof_windows u_value_raw: {inclined_us} (expected [2.3, 2.3] "
f"after Table 6e Note 2 DG-column +0.30 W/m²K adjustment fires on "
f"both rooflights for pitch 40-60°)"
)
# Assert — roof_windows_w_per_k closes to the worksheet's Σ A×U_eff
# at abs=1e-4. ws (27a) = 1.2×2.1062 + 0.5×2.1062 = 3.5805 W/K.
assert abs(ht.roof_windows_w_per_k - 3.5805) <= 1e-4, (
f"cascade roof_windows_w_per_k={ht.roof_windows_w_per_k:.4f}; "
f"ws (27a)=3.5805; Δ={ht.roof_windows_w_per_k - 3.5805:+.4f} "
f"(expected within 1e-4 after Table 6e Note 2 inclination "
f"adjustment + formula (2) curtain transform)"
)
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

@ -3677,12 +3677,37 @@ _ELMHURST_ROOF_WINDOW_U_BY_GLAZING: Dict[str, float] = {
}
# SAP 10.2 Table 6e Note 2 (PDF p.180) — inclination adjustment from
# vertical-tested U-value to inclined-position U-value. Pitch=45°
# (the cohort + cert 000565 default) falls in "60° and > 40°"; the
# DG-column adjustment is +0.30 W/m²K. Elmhurst applies the DG-column
# adjustment uniformly across roof windows at this pitch regardless of
# the lodged glazing type — the worksheet (27a) for cert 000565 shows
# U_eff = 2.1062 for BOTH the Triple (Item 2) and Double (Item 5)
# rooflights, back-solving via formula (2) to U_inclined = 2.30 =
# 2.0 + 0.30 in both cases. The strict Table 6e Note 2 Triple-column
# +0.20 alternative would yield 2.0222 for Item 2, contradicting the
# worksheet.
_ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K: Final[float] = 0.30
def _elmhurst_roof_window_u_value(w: ElmhurstWindow) -> float:
"""Roof-window U-value per RdSAP10 Table 24 — keyed on the lodged
glazing-type phrase. Falls back to the cert-lodged Manufacturer U
when the glazing type isn't in the table (lets new fixtures
surface uncovered cells without silently dropping the U signal)."""
return _ELMHURST_ROOF_WINDOW_U_BY_GLAZING.get(w.glazing_type, w.u_value)
glazing-type phrase. Returns the inclined-position U-value pre-
curtain transform; the heat_transmission cascade then applies SAP
10.2 §3.2 formula (2) (R=0.04 m²K/W curtain resistance).
Two paths:
1. Lodged glazing label in `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING`
return the RdSAP10 Table 24 "Roof window" column value
(already inclined-position per Table 24 derivation).
2. Otherwise (lodged Manufacturer U on a non-Table-24 glazing
type) apply SAP 10.2 Table 6e Note 2 inclination adjustment
to convert the vertical-tested U to inclined-position U.
"""
if w.glazing_type in _ELMHURST_ROOF_WINDOW_U_BY_GLAZING:
return _ELMHURST_ROOF_WINDOW_U_BY_GLAZING[w.glazing_type]
return w.u_value + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K
def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow: