mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
a461b70d19
commit
59de805e63
3 changed files with 123 additions and 22 deletions
|
|
@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue