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 committed by Jun-te Kim
parent 610d2498e1
commit 637df557bb
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')"
)
# 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, (
# Assert — Ext2 +0.36 W/K roof over-count closes (cascade no longer
# leaves Ext2's gross roof un-deducted). Combined with S0380.113
# (H=0 gable retention) the cascade closes to ws within 1e-2.
assert abs(ht.roof_w_per_k - 51.3795) <= 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}"
f"ws 51.3795; Δ={ht.roof_w_per_k - 51.3795:+.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, (
# Assert — Ext2 +1.20 m² rooflight double-count closes.
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"expected ~857.46 (= ws 857.64 0.17 Ext3 residual deferred to "
f"S0380.113); Δ={ht.total_external_element_area_m2 - 857.46:+.4f}"
f"ws 857.64; Δ={ht.total_external_element_area_m2 - 857.64:+.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
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
# 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
@ -3424,23 +3435,34 @@ def _map_elmhurst_rir_surface(
kind = "gable_wall_external"
# Area derivation per assessment + common-wall presence.
if (
kind == "gable_wall_external"
kind in ("gable_wall", "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).
#
# 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
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)
1.0, length_m * (0.25 + height_m) - correction
)
else:
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,
)
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
elif kind == "gable_wall_external":
# 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.
# 000487 Gable Wall 2 at U=0.86) overrides the
# 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
rr_detailed_area += area
walls += u_gable * area
if area >= 0:
rr_detailed_area += area
walls += u_gable * area
rr_walls_in_a_rr_area += area
elif kind == "common_wall":
# RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22