Slice S0380.22: per-BP roof exposure — closes cert 0036 Ext1 flat roof

For multi-BP dwellings the dwelling-level `exposure.has_exposed_roof`
flag (derived from `dwelling_type` via `_dwelling_exposure`) zeroed
out ALL BPs' roof contributions uniformly. That's wrong when a flat
has an extension with its own external roof — e.g. ground-floor flat
with a single-storey extension whose flat roof is exposed.

Replace the global suppression with a per-BP signal:
  - Per-BP `roof_construction_type` containing "another dwelling
    above" → that BP's roof is party → suppress.
  - Otherwise BP 0 (Main) falls back to the dwelling-level flag
    (covers flat lodgements that don't explicitly mark the Main
    roof type).
  - Extensions (i > 0) expose their roof by default unless their
    own roof_construction_type lodges as party.

Cohort cert 0036-6325-1100-0063-1226 (ground-floor flat, age D):
  - Main lodges roof_construction_type = "Another dwelling above"
    → contributes 0 W/K (matches worksheet line (30) "External roof
    Main 57.93 m² × U=0 = 0.0").
  - Ext1 lodges roof_construction_type = "Flat" → contributes
    1.09 m² × U=2.30 = 2.507 W/K (matches worksheet "External roof
    Ext1 1.09 m² × U=2.30 = 2.507", spec line (30)).
  - Cascade SAP closes from +0.2987 → -6e-6 vs worksheet 62.7471.

Houses + bungalows are unaffected: dwelling-level flag stays True
and the per-BP guard only activates on explicit party-roof lodgement.
Single-BP flat tests stay correct: the per-BP guard is a no-op when
no roof_construction_type is lodged (i==0 → falls back to dwelling-
level flag).

Spec citation:
  - RdSAP 10 §3 / §5.11 — heat-loss surfaces and party-roof
    treatment. SAP 10.2 spec line (30) sums external roofs only;
    party roofs sit in the (32) party-element channel with U=0.

Cohort-2 distribution (38 certs, Summary path) shifts:
  exact (<1e-4): 19 → **20**  (+1: 0036)
  0.07..0.5:     2  → **1**   (-1: 0036 → exact)

Pyright net-zero (heat_transmission.py 13→13, test file 71→71).
Test counts: 702 → 703 pass (+1 new test), 10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 09:20:57 +00:00 committed by Jun-te Kim
parent f7d863a9fa
commit 7136edf2fb
2 changed files with 77 additions and 1 deletions

View file

@ -686,8 +686,26 @@ def heat_transmission_from_cert(
# floor areas on each level. For a pitched roof with a sloping
# ceiling, divide that area by cos(30°) — the worksheet enters
# the inclined surface area, not the horizontal projection.
top_floor_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
roof_type = (part.roof_construction_type or "").lower()
# Per-BP roof exposure: an extension on a flat can have its own
# external roof even when the dwelling-level position says the
# primary building's roof is party (cohort cert 0036: ground-
# floor flat with single-storey Ext1 flat roof, worksheet (30)
# = 1.09 m² × U=2.30 = 2.51 W/K). The per-BP signal is the
# explicit `roof_construction_type` lodgement of "another
# dwelling above" — when present, suppress that BP's roof; when
# absent, the dwelling-level `exposure.has_exposed_roof` flag
# applies to the primary BP (i==0) and extensions (i>0) expose
# by default. Houses + bungalows pass through unchanged because
# their dwelling-level flag stays True.
part_roof_is_party = "another dwelling above" in roof_type
if part_roof_is_party:
part_has_exposed_roof = False
elif i == 0:
part_has_exposed_roof = exposure.has_exposed_roof
else:
part_has_exposed_roof = True
top_floor_area = geom["top_floor_area_m2"] if part_has_exposed_roof else 0.0
if "sloping ceiling" in roof_type:
top_floor_area = top_floor_area / _COS_30_DEG
gross_roof_area = _round_half_up(top_floor_area, _AREA_ROUND_DP)

View file

@ -762,6 +762,64 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None:
assert ground.roof_w_per_k == 0.0
def test_ground_floor_flat_extension_with_flat_roof_exposes_extension_roof_only() -> None:
"""Per-BP roof exposure: an extension on a ground-floor flat can have
its own external (e.g. single-storey) roof even though the dwelling-
level position says the main building's roof is party. Cohort cert
0036-6325-1100-0063-1226: ground-floor flat, Main lodges roof type
"Another dwelling above" (party); Ext1 lodges roof type "Flat" with
its own external surface. Worksheet (30) sums Ext1's 1.09 m² × U=2.30
= 2.507 W/K; Main contributes 0 W/K. Without the per-BP signal the
dwelling-level `has_exposed_roof=False` zeroes both -0.30 SAP
over-prediction.
"""
# Arrange
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="D",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=60.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
],
)
main.roof_construction_type = "Another dwelling above"
ext1 = make_building_part(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="D",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=1.09, room_height_m=2.0,
party_wall_length_m=0.0, heat_loss_perimeter_m=3.0, floor=0,
),
],
)
ext1.roof_construction_type = "Flat"
epc = make_minimal_sap10_epc(
total_floor_area_m2=61.09,
country_code="ENG",
sap_building_parts=[main, ext1],
)
# Act
result = heat_transmission_from_cert(
epc,
exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=False),
)
# Assert — only Ext1's 1.09 m² flat roof contributes; Main's roof is
# party. Age D flat-roof default per Table 18 col (3) = 2.30 W/m²K.
expected_ext1_roof = 1.09 * 2.30
assert abs(result.roof_w_per_k - expected_ext1_roof) <= 0.01, (
f"got {result.roof_w_per_k:.4f}, want {expected_ext1_roof:.4f}"
)
# ============================================================================
# New §3 worksheet-line-mapped tests: alternative walls, effective window U,
# and the (31)/(33) line-ref fields. Reference: SAP10.2 §3.2, RdSAP10 §1.4.2.