Slice S0380.113: H=0 gable lodgement deducts per RdSAP 10 §3.9.2 step (b)

RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim:

  "Software calculates the area of each gable or adjacent wall by
  using the equation:
         A_RR_gable = L_gable × (0.25 + H_gable) − [(H_gable − H_common_1)² / 2
                                                   + (H_gable − H_common_2)² / 2]"

Step (d):
  A_RR_final = A_RR_wall − (Σ A_common + Σ A_gable + Σ A_party
                            + Σ A_sheltered + Σ A_connected)

The spec equation is signed and applies for all L > 0 — including
H_gable = 0. When the gable is shorter than the common walls the
correction term `(H_gable − H_common)² / 2` exceeds the
L × (0.25 + H_gable) term, producing a negative A_RR_gable.
Elmhurst's worksheet evaluates the equation literally; the negative
value adjusts A_RR_final upward via step (d) without billing a
physical wall area.

Cert 000565 §8.1 lodges Ext3's RR (Simplified Type 2) with an
absent Gable Wall 2:

  Gable Wall 1   L=9.00  H=7.00   Exposed     U=0.45
  Gable Wall 2   L=4.00  H=0.00               U=0.00   ← lodged but H=0
  Common Wall 1  L=5.00  H=1.50               U=0.45
  Common Wall 2  L=7.50  H=0.30               U=0.45

Spec equation for Gable Wall 2:
  A_gable_2 = 4 × (0.25 + 0) − (0 − 1.5)²/2 − (0 − 0.30)²/2
            = 1.0 − 1.125 − 0.045 = −0.17 m²

Worksheet (30) Ext3 residual = 17.35 m² back-solves exactly:
  A_RR_shell = 12.5 × √(32.0 / 1.5)                = 57.7350
  Σ walls (incl. -0.17 absent gable)               = 40.3850
  residual = shell − walls                         = 17.3500  ✓ 4 d.p.

Pre-slice the mapper had two clamps that together dropped the
spec-computed −0.17 m² adjustment:

  mapper.py:3350  `if length_m <= 0 or height_m <= 0: return None`
                  → filtered out any H=0 surface
  mapper.py:3443  `area_m2 = max(0.0, length_m * (0.25 + H) − correction)`
                  → clamped negative gable areas at 0

Combined the cascade computed residual = 17.18 m² (cascade UNDER
by 0.17). Plus a related secondary `if height_m > h` filter on the
correction sum that masked the all-common-walls-taller case.

3-layer fix:

1. `datatypes/epc/domain/mapper.py` `_map_elmhurst_rir_surface`:
   - Split the early-return filter: drop only when L<=0 (no wall),
     OR when H<=0 AND not (Simplified Type 2 with common walls).
   - Apply the spec gable-area formula to BOTH `gable_wall` (party
     default) and `gable_wall_external` kinds in Simplified Type 2
     (the U-value routing differs by kind, but the area equation
     is the same).
   - Remove `max(0.0, ...)` clamp so the signed result reaches the
     cascade.
   - Remove `if height_m > h` correction-sum filter (spec applies
     the full square unconditionally).

2. `domain/sap10_calculator/worksheet/heat_transmission.py` per-
   surface loop:
   - `gable_wall` branch: skip `party += 0.25 × area` when area < 0
     (wall doesn't exist physically) but still add the signed area
     to `rr_walls_in_a_rr_area` so the residual deduction in step (d)
     grows by |area|.
   - `gable_wall_external` branch: same skip pattern for `walls +=
     u × area` and `rr_detailed_area += area`.

Cohort safety: only cert 000565 Ext3 hits this in the corpus. All
other cohort certs are Type 1 RR (no common walls, formula gives
the same answer) or have all gables H > 0. The cascade's per-element
test pins (Ext1's Connected gable + Exposed gable, Ext4's Detailed
RR) unchanged.

Cert 000565 cascade snapshot (HEAD a461b70d → this):
  roof_w_per_k         51.3185 → 51.3768  ✓ EXACT (Δ -0.06 → -0.003)
  total_external_area 857.46  → 857.6323  ✓ EXACT (Δ -0.18 → -0.008)
  thermal_bridging    128.62  → 128.6448  ✓ EXACT (Δ -0.03 → -0.005)
  total_w_per_k       936.97  → 937.0563  ✓ EXACT (Δ -0.09 → -0.004)

  sap_score (int)         29 ✓ EXACT (preserved)
  sap_score_continuous 28.5027 → 28.5007 (Δ -0.0060 → -0.0080)
  ecf                   5.3877 →  5.3876
  total_fuel_cost_gbp  4681.01 → 4680.97
  co2_kg_per_yr        6448.59 → 6448.53
  space_heating_kwh   59019.21 → 59018.52
  main_heating_fuel   34715.31 → 34716.78

**Cert 000565 fabric cascade now essentially exact** (HTC −0.004 W/K
total residual across all 8 fabric components). The remaining
continuous SAP -0.0080 / cost +£0.71 / SH +10 kWh residuals come
from non-fabric upstream (likely ventilation or appliances) —
candidates for a future audit.

Pyright net-zero (57 → 57 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:23:12 +00:00
parent a461b70d19
commit 59de805e63
3 changed files with 123 additions and 22 deletions

View file

@ -2496,22 +2496,85 @@ def test_summary_000565_rooflights_deduct_from_their_own_bp_gross_roof_per_rdsap
f"{rooflights_by_area[0.50].window_location!r} (expected '4th Extension')" 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; # Assert — Ext2 +0.36 W/K roof over-count closes (cascade no longer
# Ext3 -0.06 W/K residual from absent Gable Wall 2 is closed in # leaves Ext2's gross roof un-deducted). Combined with S0380.113
# S0380.113 — until then, this slice closes the Ext2 +0.36 W/K # (H=0 gable retention) the cascade closes to ws within 1e-2.
# over-count, leaving cascade UNDER by 0.06). assert abs(ht.roof_w_per_k - 51.3795) <= 1e-2, (
assert abs(ht.roof_w_per_k - 51.32) <= 1e-2, (
f"cascade roof_w_per_k={ht.roof_w_per_k:.4f}; " 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"ws 51.3795; Δ={ht.roof_w_per_k - 51.3795:+.4f}"
f"S0380.113); Δ={ht.roof_w_per_k - 51.32:+.4f}"
) )
# Assert — total external area closes the Ext2 +1.20 m² double-count # Assert — Ext2 +1.20 m² rooflight double-count closes.
# (remaining residual is Ext3 -0.17 m² from absent Gable Wall 2, assert abs(ht.total_external_element_area_m2 - 857.64) <= 1e-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"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"ws 857.64; Δ={ht.total_external_element_area_m2 - 857.64:+.4f}"
f"S0380.113); Δ={ht.total_external_element_area_m2 - 857.46:+.4f}" )
def test_summary_000565_ext3_absent_gable_h_zero_lodgement_deducts_per_rdsap_10_section_3_9_2_step_b() -> None:
# Arrange — RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim:
#
# ┌ ┌ (H_gable H_common_1)² (H_gable H_common_2)² ┐ ┐
# A_RR_gable=│ L_gable × (0.25 + H_gable) │ ─────────────────────── + ─────────────────────── │ │
# └ └ 2 2 ┘ ┘
#
# Step (d): A_RR_final = A_RR_shell Σ(common + gable + party +
# sheltered + connected).
#
# Cert 000565 §8.1 lodges Ext3's Room in Roof as Simplified Type 2:
#
# Gable Wall 1 L=9.00 H=7.00 Exposed U=0.45
# Gable Wall 2 L=4.00 H=0.00 U=0.00 ← lodged but H=0
# Common Wall 1 L=5.00 H=1.50 U=0.45
# Common Wall 2 L=7.50 H=0.30 U=0.45
#
# Elmhurst's worksheet (30) shows Ext3 remaining area = 17.35 m².
# Back-solving via the spec equation with the H=0 Gable Wall 2:
#
# A_gable_2 = 4 × (0.25 + 0) (0 1.5)²/2 (0 0.30)²/2
# = 1.0 1.125 0.045 = 0.17 m² (negative)
#
# A_RR_shell = 12.5 × √(32.0 / 1.5) = 57.7350
# Σ walls (incl. -0.17 absent gable) = 40.3850
# residual = shell walls = 17.3500 ✓
#
# Pre-slice the mapper filtered out lodged surfaces with
# `height_m <= 0` (mapper.py:3350) and clamped the gable area at 0
# via `max(0.0, ...)` (mapper.py:3443). Both clamps prevented the
# spec-computed 0.17 m² adjustment from reaching the cascade —
# cascade residual landed at 17.18 m² (= 57.735 40.555), -0.17
# m² under the worksheet.
#
# Spec-correct path: lodged Type 2 gable walls with H=0 still
# contribute via §3.9.2 step (b). The result can go negative when
# the common walls are taller than the gable; that signed value
# adjusts the residual deduction (step d) without billing a
# physical wall area (the wall doesn't exist).
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 — total external area closes to worksheet (31) at 1e-2.
# Pre-slice it was 857.46 (cascade UNDER by 0.18 after S0380.112);
# this slice picks up the +0.17 m² Ext3 residual adjustment, taking
# the cascade to ~857.63, within ws 857.64.
assert abs(ht.total_external_element_area_m2 - 857.64) <= 1e-2, (
f"cascade total_external_element_area_m2={ht.total_external_element_area_m2:.4f}; "
f"ws (31)=857.64; Δ={ht.total_external_element_area_m2 - 857.64:+.4f} "
f"(expected within 1e-2 after lodged H=0 gable contributes 0.17 "
f"m² via the §3.9.2 step (b) spec equation)"
)
# Assert — roof_w_per_k closes the Ext3 0.06 W/K residual. Ws
# row "Roof room Ext3 remaining area" = 17.35 × 0.35 = 6.0725 W/K.
# Pre-slice cascade ran the residual on 17.175 × 0.35 = 6.011 W/K.
assert abs(ht.roof_w_per_k - 51.3795) <= 1e-2, (
f"cascade roof_w_per_k={ht.roof_w_per_k:.4f}; "
f"ws 51.3795; Δ={ht.roof_w_per_k - 51.3795:+.4f} "
f"(expected within 1e-2 after Ext3 residual area picks up the "
f"+0.17 m² adjustment)"
) )

View file

@ -3347,7 +3347,18 @@ def _map_elmhurst_rir_surface(
callers compute it once per BP and pass it through to all surfaces callers compute it once per BP and pass it through to all surfaces
so the correction applies consistently across both gables. so the correction applies consistently across both gables.
""" """
if surface.length_m <= 0 or surface.height_m <= 0: # No length → no wall (drop regardless of mode).
if surface.length_m <= 0:
return None
# H=0 + Simplified Type 2 + common walls present is a SPECIAL CASE:
# per RdSAP 10 §3.9.2 step (b) (PDF p.23) the gable equation
# `A_gable = L × (0.25 + H) Σ (H H_common)² / 2` is defined
# for H=0 (returns L × 0.25 minus the common-wall correction —
# often negative). Elmhurst's worksheet evaluates this literally
# and the result deducts from A_RR_final in step (d). For all
# other modes (Detailed, Simplified Type 1, or no common walls),
# H=0 still means "absent surface" and gets dropped.
if surface.height_m <= 0 and not (is_simplified and common_wall_heights):
return None return None
# RdSAP 10 §3.9.2 step (d) (PDF p.23) — Connected-to-heated-space # RdSAP 10 §3.9.2 step (d) (PDF p.23) — Connected-to-heated-space
# gables contribute U=0 (Table 4 row 4, PDF p.22) but their area # gables contribute U=0 (Table 4 row 4, PDF p.22) but their area
@ -3424,23 +3435,34 @@ def _map_elmhurst_rir_surface(
kind = "gable_wall_external" kind = "gable_wall_external"
# Area derivation per assessment + common-wall presence. # Area derivation per assessment + common-wall presence.
if ( if (
kind == "gable_wall_external" kind in ("gable_wall", "gable_wall_external")
and is_simplified and is_simplified
and common_wall_heights and common_wall_heights
): ):
# Spec formula (RdSAP 10 §3.9.2 + Table 4 p.22): # Spec formula (RdSAP 10 §3.9.2 + Table 4 p.22):
# A_gable = L × (0.25 + H_gable) # A_gable = L × (0.25 + H_gable)
# Σ_each_common (H_gable H_common,n)² / 2 # Σ_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). # Applies to all gable kinds (exposed/sheltered/party) in
# Simplified Type 2 — only `kind` differs (which routes the
# U-value), not the area equation. The correction term is
# always non-negative (square of a real), so when the gable
# is shorter than the common walls the formula returns a
# negative area. Elmhurst's worksheet applies the equation
# literally, including the H_gable=0 absent-gable case
# (cert 000565 Ext3 "Gable Wall 2 L=4 H=0":
# 4 × 0.25 1.5²/2 0.30²/2 = 0.17 m²). The negative value
# deducts from A_RR_final via step (d) without billing a
# physical wall area — `heat_transmission.py`'s per-surface
# loop skips `walls += u × area` and `party += 0.25 × area`
# when area is negative.
length_m, height_m = surface.length_m, surface.height_m length_m, height_m = surface.length_m, surface.height_m
correction = sum( correction = sum(
((height_m - h) ** 2) / 2.0 ((height_m - h) ** 2) / 2.0
for h in common_wall_heights for h in common_wall_heights
if height_m > h
) )
area_m2 = _round_half_up_2dp( area_m2 = _round_half_up_2dp(
1.0, max(0.0, length_m * (0.25 + height_m) - correction) 1.0, length_m * (0.25 + height_m) - correction
) )
else: else:
area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m) area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m)

View file

@ -905,7 +905,13 @@ def heat_transmission_from_cert(
insulation_type=surf.insulation_type, insulation_type=surf.insulation_type,
) )
elif kind == "gable_wall": elif kind == "gable_wall":
party += 0.25 * area # Negative area = §3.9.2 step (b) absent-gable
# adjustment (see gable_wall_external branch below
# for full rationale). Skip the party billing —
# the wall doesn't physically exist — but include
# the signed area in the residual deduction.
if area >= 0:
party += 0.25 * area
rr_walls_in_a_rr_area += area rr_walls_in_a_rr_area += area
elif kind == "gable_wall_external": elif kind == "gable_wall_external":
# RdSAP10 Table 4 (p.22) row 1: exposed gable U = "as # RdSAP10 Table 4 (p.22) row 1: exposed gable U = "as
@ -913,9 +919,19 @@ def heat_transmission_from_cert(
# below (`uw`). Assessor-lodged `u_value` (e.g. # below (`uw`). Assessor-lodged `u_value` (e.g.
# 000487 Gable Wall 2 at U=0.86) overrides the # 000487 Gable Wall 2 at U=0.86) overrides the
# cascade. # cascade.
#
# A negative `area` is a §3.9.2 step (b) absent-
# gable adjustment (cert 000565 Ext3 "Gable Wall 2
# L=4 H=0" → -0.17 m² via the spec equation). The
# negative value rolls into `rr_walls_in_a_rr_area`
# so the §3.10.1 residual = `a_rr_shell walls`
# grows by |area| (step d). Skip both the external-
# area count and the wall billing because the wall
# doesn't physically exist.
u_gable = surf.u_value if surf.u_value is not None else uw u_gable = surf.u_value if surf.u_value is not None else uw
rr_detailed_area += area if area >= 0:
walls += u_gable * area rr_detailed_area += area
walls += u_gable * area
rr_walls_in_a_rr_area += area rr_walls_in_a_rr_area += area
elif kind == "common_wall": elif kind == "common_wall":
# RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22 # RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22