Slice S0380.108: Connected-to-heated-space RR gables deduct from A_RR (RdSAP 10 §3.9.2 + Table 4 row 4)

Closes the largest single localised fabric residual on cert 000565
(roof +1.59 W/K over, area +4.70 m² over) by routing
Connected-gable surfaces through a new `connected_wall` kind that
deducts area from the residual A_RR per the spec but contributes
0 W/K per RdSAP 10 Table 4 row 4.

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

  "The areas of gable walls are deducted from the calculated total
   RR area, and the remaining area of RR, ARR_final is then
   calculated. This area is treated as roof structure.
       ARR_final = ARR_wall − (ΣARR_common_wall + ΣARR_gable +
                               ΣARR_party + ΣARR_sheltered +
                               ΣARR_connected)"

RdSAP 10 Table 4 row 4 (PDF p.22):

  "ARR_connected — Adjacent to heated space — U-value = 0"

The U=0 means no heat-loss contribution, but the area STILL appears
in the deduction equation as ΣARR_connected. Pre-slice the mapper's
`_map_elmhurst_rir_surface` returned None for Connected gables,
dropping them entirely from `detailed_surfaces` so the cascade
neither billed them nor deducted them. The residual A_RR was
therefore over by their lodged area.

Cert 000565 Ext1 §8.1 lodges (Simplified Type 2):
  Gable Wall 1   L=4.00  H=6.00  Connected  U=0
  Gable Wall 2   L=8.00  H=9.00  Exposed    U=1.70
  Common Wall 1  L=9.00  H=1.00  U=1.70
  Common Wall 2  L=5.00  H=1.80  U=1.70

Gable Wall 1 area via §3.9.2 quadratic:
  A_gable_1 = 4 × (0.25 + 6)
              − (6 − 1)²/2   ← subtract triangle above Common Wall 1
              − (6 − 1.8)²/2 ← subtract triangle above Common Wall 2
            = 25.0 − 12.5 − 8.82
            = 3.68 m²

Pre-slice:
  A_RR shell = 12.5 × √(34 / 1.5) = 59.51 m²
  Σ wall areas = 11.25 + 10.25 + 16.08 = 37.58 m²
  Residual    = 21.93 m² (worksheet: 18.25; over by +3.68)
  Roof W/K = 21.93 × 0.35 = 7.68 (worksheet: 6.39; over by +1.29)

3-layer fix:
1. Mapper `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py)
   now routes "Connected" gable_type to kind="connected_wall" with
   u_value=0 and area via the Simplified Type 2 quadratic correction.
2. Heat transmission `heat_transmission_from_cert` (domain/sap10_
   calculator/worksheet/heat_transmission.py) adds a connected_wall
   branch that deducts area from rr_walls_in_a_rr_area but skips
   walls/party W/K contribution.
3. AAA test pins Ext1 Connected gable area at 3.68 m² and U=0.

Movement at HEAD `b7fa5f74` → post-slice (cert 000565):

Fabric (cascade vs ws):
  walls           602.53 → 602.53 (Δ -1.54 W/K; unchanged)
  roof             52.97 →  51.68 (Δ +1.59 → +0.30 W/K; closes 81%)
  TB              129.35 → 128.80 (Δ +0.70 → +0.15 W/K; closes 79%)
  total area      862.34 → 858.66 (Δ +4.70 → +1.02 m²; closes 78%)
  total W/K       937.40 → 935.54 (Δ +0.33 → -1.52 W/K; sign flips)

End-result pins:
  **sap_score (int)   28 → 29 ✓ EXACT vs ws 29**  (RECOVERED from
                                                   S0380.107 transient
                                                   rounding flip)
  sap_score_continuous 28.4959 → 28.5380 (Δ -0.0128 → +0.0293)
  ecf                   5.3881 →  5.3838 (Δ +0.0015 → -0.0028)
  total_fuel_cost_gbp 4681.39  → 4677.64 (Δ +1.13 → -2.62)
  co2_kg_per_yr      6449.13  → 6444.27 (Δ +1.51 → -3.35)
  space_heating_kwh 59028.80  → 58974.84 (Δ +20.5 → -33.5)
  main_heating_fuel 34722.83  → 34691.09 (Δ +12.0 → -19.7)
  lighting_kwh       1382.67  → 1382.67 (unchanged)
  pumps_fans_kwh ✓ EXACT (unchanged)

Continuous SAP and downstream pins SIGN-FLIPPED again
(cascade was over post-.107, now under post-.108). Per user
direction: transient drift acceptable while closing a true
intermediate-value bug. The remaining net HTC -1.52 W/K is
mostly walls (-1.54 W/K) — closing the Detailed-RR walls
residual is the next leverage front.

Cohort safety: none of the 6 cohort certs lodge a Connected
gable (grep audit across all Summary fixtures). The new
`connected_wall` branch only fires for the cert 000565 Ext1 BP.

Test count: 606 pass + 8 expected 000565 fails → **608 pass +
7 expected 000565 fails** (sap_score back to exact + new
Connected-gable test green). Pyright net-zero per touched
file (57 baseline → 57 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 17:40:42 +00:00
parent b7fa5f74ec
commit 9159e91fbc
3 changed files with 102 additions and 5 deletions

View file

@ -2065,6 +2065,75 @@ def test_summary_000565_window_routing_uses_bp_roof_type_per_rdsap_10_section_3_
)
def test_summary_000565_ext1_rir_connected_gable_deducts_from_a_rr_per_rdsap_10_section_3_9_2() -> None:
# Arrange — RdSAP 10 §3.9.2 (PDF p.23) step (d) verbatim:
#
# "The areas of gable walls are deducted from the calculated total
# RR area, and the remaining area of RR, ARR_final is then
# calculated. This area is treated as roof structure.
# ARR_final = ARR_wall (ΣARR_common_wall + ΣARR_gable +
# ΣARR_party + ΣARR_sheltered +
# ΣARR_connected)"
#
# RdSAP 10 Table 4 row 4 (PDF p.22): "ARR_connected — Adjacent to
# heated space — U-value = 0". The U=0 means no heat-loss
# contribution, but the area STILL deducts from the residual A_RR
# (spec step (d) explicitly sums ARR_connected in the deduction).
#
# Cert 000565 Ext1 §8.1 lodges (Simplified Type 2 RR):
#
# Gable Wall 1 L=4.00 H=6.00 Connected U=0
# Gable Wall 2 L=8.00 H=9.00 Exposed U=1.70
# Common Wall 1 L=9.00 H=1.00 U=1.70
# Common Wall 2 L=5.00 H=1.80 U=1.70
#
# Gable area via §3.9.2 quadratic (subtract triangular slice above
# each common wall):
#
# A_gable_1 = 4 × (0.25 + 6) (6 1)²/2 (6 1.8)²/2
# = 25.0 12.5 8.82
# = 3.68 m²
#
# Pre-S0380.108 the mapper dropped Connected gables entirely
# (`_map_elmhurst_rir_surface` returned None). The cascade's
# residual A_RR was therefore over by +3.68 m²:
#
# A_RR shell = 12.5 × √(34 / 1.5) = 59.51 m²
# Σ wall areas (current) = 11.25 + 10.25 + 16.08 = 37.58 m²
# Residual (cascade) = 59.51 37.58 = 21.93 m² (over)
# Residual (worksheet) = 59.51 37.58 3.68 = 18.25 m²
#
# Worksheet (30) row "Roof room Ext1 remaining area: 18.25" at U=0.35
# → 6.3875 W/K. Cascade pre-slice 21.93 × 0.35 → 7.6755 W/K
# (over by +1.29 W/K on roof — the largest single localised
# residual on cert 000565 per HANDOVER_POST_S0380_103.md).
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 — Ext1 RIR detailed_surfaces holds the Connected gable
# with the quadratic-corrected area, so the cascade deducts it
# from A_RR per step (d).
ext1_rir = epc.sap_building_parts[1].sap_room_in_roof
assert ext1_rir is not None
assert ext1_rir.detailed_surfaces is not None
connected_gables = [
s for s in ext1_rir.detailed_surfaces
if s.kind == "connected_wall"
]
assert len(connected_gables) == 1, (
f"expected 1 Connected gable; got {len(connected_gables)} "
f"(detailed_surfaces kinds: "
f"{[s.kind for s in ext1_rir.detailed_surfaces]})"
)
# 4 × (0.25 + 6) (6 1)²/2 (6 1.8)²/2 = 3.68
assert abs(connected_gables[0].area_m2 - 3.68) <= 1e-4
# U-value = 0 per Table 4 row 4 (no heat-loss contribution)
assert connected_gables[0].u_value == 0.0
def test_summary_000565_main_1_ashp_sap_code_224_routes_to_main_heating_category_4_per_sap_table_4a() -> None:
# Arrange — SAP 10.2 Table 4a (PDF p.165) "Main heating systems":
# the category column lists "Heat pumps" as category 4. Codes in

View file

@ -3349,12 +3349,31 @@ def _map_elmhurst_rir_surface(
"""
if surface.length_m <= 0 or surface.height_m <= 0:
return None
# 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.
# 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
# STILL deducts from the residual A_RR per the explicit
# ΣARR_connected term in the spec equation. Route to a discrete
# "connected_wall" kind so heat_transmission can deduct the area
# without adding to walls or party W/K. Area follows the same
# Simplified Type 2 quadratic as exposed gables.
if surface.gable_type in ("Connected", "Connected to heated space"):
return None
length_m, height_m = surface.length_m, surface.height_m
if is_simplified and common_wall_heights:
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(length_m, height_m)
return SapRoomInRoofSurface(
kind="connected_wall",
area_m2=area_m2,
u_value=0.0,
)
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

View file

@ -914,6 +914,15 @@ def heat_transmission_from_cert(
rr_detailed_area += area
walls += u_common * area
rr_walls_in_a_rr_area += area
elif kind == "connected_wall":
# RdSAP 10 Table 4 row 4 (PDF p.22) — "Adjacent to
# heated space" gables have U=0 (no heat-loss
# contribution) but §3.9.2 step (d) explicitly
# deducts ΣA_RR_connected from the residual A_RR.
# Mapper precomputes the area via the Simplified
# Type 2 quadratic. Skip walls/party W/K; count the
# area in the A_RR deduction only.
rr_walls_in_a_rr_area += area
# RdSAP 10 §3.10.1 residual area = simplified A_RR shell
# minus the wall surfaces just enumerated. Uses the same
# §3.9.1 `12.5 × √(A_RR_floor / 1.5)` formula as the