mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
S0380.196: API Simplified Type 1 RR gables deduct from A_RR shell (RdSAP 10 §3.9.1(e) p.21)
Golden cert 6035's residual (SAP -2 / PE +19.16 / CO2 +0.42t) was a real API-mapper bug, NOT lodged divergence (prior claim retracted). The API `room_in_roof_type_1` block lodges gable walls by length only (no height). The mapper carried just the scalar `gable_*_length_m`, and the cascade's `_part_geometry` gable formula silently drops height-less gables (needs a height) -> the whole A_RR shell `12.5√(A_RR_floor/1.5)` billed as roof at U_RR=2.30 instead of the §3.9.1(e) residual `A_RR − Σ gables`. On 6035 that over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof 130.73 -> 78.33, matching the site-notes case-4 replica at 1e-4 — cross-mapper parity). RdSAP 10 §3.9.1(e) (PDF p.21): "the area of the room-in-roof gable walls ... is deducted from A_RR to give the residual roof area." Fix: route the Type 1 gables through `detailed_surfaces` (gable area = L × the §3.9.1 default RR storey height 2.45 m; gable_wall_type 0=Party->gable_wall U=0.25, 1=Exposed->gable_wall_external "as common wall" per Table 4 p.22) so the cascade's Detailed-RR residual fires — the same path the site-notes mapper already uses. Re-pinned golden residuals: - 6035: SAP -2 -> +0 (exact), PE +19.16 -> +1.84, CO2 +0.42 -> +0.01 - 0240: same fix applies (2 Party gables L=6.4); PE +5.80 -> +3.91, CO2 +0.32 -> +0.22, SAP integer unchanged Also corrected the stale "gable_wall_type 0 = external" schema comment (6035's Summary proves 0=Party, 1=Exposed) and added a strict UnmappedApiCode raise for unknown gable_wall_type codes. Suite: 2342 passed, 1 skipped. New code: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
af477678c2
commit
8861dac694
4 changed files with 155 additions and 23 deletions
|
|
@ -2710,20 +2710,84 @@ def _api_build_sap_floor_dimensions(
|
|||
return out
|
||||
|
||||
|
||||
# RdSAP 10 §3.9.1 (PDF p.21): a Simplified Type 1 RR gable has no measured
|
||||
# height — the worksheet applies the default RR storey height of 2.45 m, so
|
||||
# the gable area is L × 2.45 (cert 6035 Summary lodges H=2.45 explicitly,
|
||||
# matching this default; gable area 4.65 × 2.45 = 11.39 m²).
|
||||
_RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45
|
||||
|
||||
# GOV.UK API `room_in_roof_type_1.gable_wall_type_*` integer → the
|
||||
# `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by
|
||||
# U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔
|
||||
# "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25):
|
||||
# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25)
|
||||
# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall")
|
||||
_API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = {
|
||||
0: "gable_wall",
|
||||
1: "gable_wall_external",
|
||||
}
|
||||
|
||||
|
||||
def _api_type_1_gable_kind(gable_type: Optional[int]) -> str:
|
||||
"""Map a `gable_wall_type_*` code to the cascade's RR surface kind.
|
||||
|
||||
`None` (type unlodged) defaults to `gable_wall` (party) — the modal
|
||||
RR gable and the conservative choice (party billing at U=0.25 vs the
|
||||
main-wall U). A lodged integer outside the known set raises
|
||||
`UnmappedApiCode` so a new gable variant forces an explicit mapping
|
||||
rather than silently mis-routing its U-value (mirror of the strict-
|
||||
raise pattern on the other API helpers)."""
|
||||
if gable_type is None:
|
||||
return "gable_wall"
|
||||
if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND:
|
||||
raise UnmappedApiCode("gable_wall_type", gable_type)
|
||||
return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type]
|
||||
|
||||
|
||||
def _api_type_1_gable_surfaces(
|
||||
type_1: Any,
|
||||
) -> Optional[List[SapRoomInRoofSurface]]:
|
||||
"""Translate the Simplified Type 1 scalar gable fields into the
|
||||
per-surface list the cascade's Detailed-RR branch consumes.
|
||||
|
||||
Gable area = L × the §3.9.1 default RR storey height (2.45 m); the
|
||||
`gable_wall_type_*` code routes the kind (Exposed vs Party). U-values
|
||||
are left to the cascade (Exposed falls through to the main-wall U;
|
||||
Party uses the fixed 0.25). Returns None when no gable length is
|
||||
lodged so the cascade keeps its existing residual-only behaviour."""
|
||||
surfaces: List[SapRoomInRoofSurface] = []
|
||||
for gable_type, length in (
|
||||
(type_1.gable_wall_type_1, type_1.gable_wall_length_1),
|
||||
(type_1.gable_wall_type_2, type_1.gable_wall_length_2),
|
||||
):
|
||||
if length is None or length <= 0:
|
||||
continue
|
||||
surfaces.append(
|
||||
SapRoomInRoofSurface(
|
||||
kind=_api_type_1_gable_kind(gable_type),
|
||||
area_m2=_round_half_up_2dp(
|
||||
float(length), _RIR_TYPE_1_GABLE_HEIGHT_M
|
||||
),
|
||||
)
|
||||
)
|
||||
return surfaces or None
|
||||
|
||||
|
||||
def _api_build_room_in_roof(
|
||||
bp_rir: Any, *, is_flat: bool = False,
|
||||
) -> Optional[SapRoomInRoof]:
|
||||
"""Build `SapRoomInRoof` from the API schema's per-bp RR block. Two
|
||||
real-API shapes coexist:
|
||||
- `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1
|
||||
Simplified Type 1 — gable lengths only, cascade applies the
|
||||
2.45 m default storey height.
|
||||
Simplified Type 1 — gable lengths only (no heights). Built into
|
||||
`detailed_surfaces` using the 2.45 m default RR storey height so
|
||||
the cascade's residual deducts the gables from the A_RR shell.
|
||||
- `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR —
|
||||
per-surface lengths + heights + flat-ceiling detail.
|
||||
When the Detailed block is present, build `detailed_surfaces` so the
|
||||
cascade's per-surface RR branch (heat_transmission.py:629) picks
|
||||
up exact gable + flat-ceiling areas instead of falling through to
|
||||
the Table 18 col(4) "all elements" default U.
|
||||
For BOTH shapes we build `detailed_surfaces` so the cascade's
|
||||
per-surface RR branch picks up exact gable + flat-ceiling areas and
|
||||
the §3.9.1(e) residual roof (A_RR shell − Σ gables), instead of
|
||||
billing the whole shell at the Table 18 col(4) "all elements" U.
|
||||
"""
|
||||
if bp_rir is None:
|
||||
return None
|
||||
|
|
@ -2733,10 +2797,20 @@ def _api_build_room_in_roof(
|
|||
)
|
||||
type_1 = getattr(bp_rir, "room_in_roof_type_1", None)
|
||||
if type_1 is not None:
|
||||
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights —
|
||||
# the cascade applies the 2.45 m default storey height).
|
||||
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights).
|
||||
rir.gable_1_length_m = type_1.gable_wall_length_1
|
||||
rir.gable_2_length_m = type_1.gable_wall_length_2
|
||||
# Route the gables through `detailed_surfaces` so the cascade's
|
||||
# Detailed-RR residual deducts each gable from the A_RR shell
|
||||
# (RdSAP 10 §3.9.1(e) p.21: A_RR_final = 12.5√(A_RR_floor/1.5) −
|
||||
# Σ gables) — the same path the site-notes mapper builds. The
|
||||
# scalar `gable_*_length_m` fields alone can't trigger this: the
|
||||
# cascade's `_part_geometry` gable formula needs a height and
|
||||
# silently drops height-less gables, billing the WHOLE shell as
|
||||
# roof (a ~52 W/K over-count on cert 6035). Gable area = L × the
|
||||
# §3.9.1 default RR storey height (2.45 m); the type code routes
|
||||
# the U-value (Exposed → main-wall U, Party → 0.25).
|
||||
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1)
|
||||
details = getattr(bp_rir, "room_in_roof_details", None)
|
||||
if details is not None:
|
||||
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
|
||||
|
|
|
|||
|
|
@ -175,10 +175,11 @@ class SapFloorDimension:
|
|||
class RoomInRoofType1:
|
||||
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
|
||||
|
||||
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
|
||||
full enum not yet mapped). `gable_wall_length_*` is the run of the
|
||||
external gable in metres. Heights are NOT lodged here — the cascade
|
||||
applies the §3.9.1 default storey height (2.45 m)."""
|
||||
`gable_wall_type_*` is the Table 4 gable variant (0 = Party,
|
||||
1 = Exposed — established from cert 6035's Summary; other variants
|
||||
not yet seen). `gable_wall_length_*` is the run of the gable in
|
||||
metres. Heights are NOT lodged here — the mapper applies the §3.9.1
|
||||
default RR storey height (2.45 m)."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
|
|
|
|||
|
|
@ -207,10 +207,11 @@ class SapFloorDimension:
|
|||
class RoomInRoofType1:
|
||||
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
|
||||
|
||||
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
|
||||
full enum not yet mapped). `gable_wall_length_*` is the run of the
|
||||
external gable in metres. Heights are NOT lodged here — the cascade
|
||||
applies the §3.9.1 default storey height (2.45 m)."""
|
||||
`gable_wall_type_*` is the Table 4 gable variant (0 = Party,
|
||||
1 = Exposed — established from cert 6035's Summary; other variants
|
||||
not yet seen). `gable_wall_length_*` is the run of the gable in
|
||||
metres. Heights are NOT lodged here — the mapper applies the §3.9.1
|
||||
default RR storey height (2.45 m)."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
|||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_demand_inputs,
|
||||
cert_to_inputs,
|
||||
heat_transmission_section_from_cert,
|
||||
)
|
||||
|
||||
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden"
|
||||
|
|
@ -82,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0240-0200-5706-2365-8010",
|
||||
actual_sap=73,
|
||||
expected_sap_resid=-1,
|
||||
expected_pe_resid_kwh_per_m2=+5.8007,
|
||||
expected_co2_resid_tonnes_per_yr=+0.3173,
|
||||
expected_pe_resid_kwh_per_m2=+3.9138,
|
||||
expected_co2_resid_tonnes_per_yr=+0.2213,
|
||||
notes=(
|
||||
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
|
||||
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
|
||||
|
|
@ -120,7 +121,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"extract-fans default (age J, 4 hab rooms → 2 fans). "
|
||||
"Cascade ventilation HLC rises ~0.07 ACH × volume → SH "
|
||||
"demand rises proportionally; PE +2.5225 → +5.8007, CO2 "
|
||||
"+0.1395 → +0.3173. SAP integer unchanged at 72."
|
||||
"+0.1395 → +0.3173. SAP integer unchanged at 72. "
|
||||
"Slice S0380.196 (the 6035 RR fix) also applies here: this "
|
||||
"cert's `room_in_roof_type_1` lodges two gables (L=6.4, both "
|
||||
"Party) with no height, previously dropped → roof over-count. "
|
||||
"Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) "
|
||||
"from the A_RR shell → roof drops, tightening PE +5.8007 → "
|
||||
"+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
@ -193,9 +200,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
_GoldenExpectation(
|
||||
cert_number="6035-7729-2309-0879-2296",
|
||||
actual_sap=70,
|
||||
expected_sap_resid=-2,
|
||||
expected_pe_resid_kwh_per_m2=+19.1566,
|
||||
expected_co2_resid_tonnes_per_yr=+0.4211,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=+1.8379,
|
||||
expected_co2_resid_tonnes_per_yr=+0.0103,
|
||||
notes=(
|
||||
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
|
||||
"S0380.189 fixed the dominant driver: walls are solid brick "
|
||||
|
|
@ -223,7 +230,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) "
|
||||
"produces ~150 kWh/yr more HW fuel than the pre-slice flat-"
|
||||
"winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 "
|
||||
"→ +1.0779."
|
||||
"→ +1.0779. "
|
||||
"Slice S0380.196 CLOSED the residual (the prior 'lodged "
|
||||
"divergence' claim is RETRACTED — it was a real API-mapper "
|
||||
"bug). The API `room_in_roof_type_1` block lodges two gable "
|
||||
"walls (L=4.65 each) but no heights; the mapper carried only "
|
||||
"the scalar lengths, and the cascade's `_part_geometry` gable "
|
||||
"formula silently drops height-less gables → the whole "
|
||||
"55.67 m² A_RR shell billed as roof at U_RR=2.30 instead of "
|
||||
"the §3.9.1(e) residual `12.5√(29.75/1.5) − 2×11.39 = 32.89`. "
|
||||
"That over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof "
|
||||
"130.73 → 78.33, matching the site-notes case-4 replica at "
|
||||
"1e-4). Fix: route the Type 1 gables through `detailed_"
|
||||
"surfaces` (gable area = L × the §3.9.1 default RR storey "
|
||||
"height 2.45 m; Exposed → gable_wall_external, Party → "
|
||||
"gable_wall) so the cascade's Detailed-RR residual fires. "
|
||||
"SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 "
|
||||
"+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW "
|
||||
"(no worksheet for 6035 itself to pin further)."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
@ -731,3 +755,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number(
|
|||
assert main.main_heating_index_number == expected_pcdb_id
|
||||
if expected_winter_eff is not None:
|
||||
assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3
|
||||
|
||||
|
||||
def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None:
|
||||
"""Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_
|
||||
type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e)
|
||||
(PDF p.21) the gable areas deduct from the A_RR shell — the residual
|
||||
roof area is `12.5√(A_RR_floor/1.5) − Σ gables`, NOT the full shell.
|
||||
|
||||
The API mapper must route these scalar gables through
|
||||
`detailed_surfaces` (gable area = L × the §3.9.1 default RR storey
|
||||
height 2.45 m) so the cascade's Detailed-RR residual fires, exactly
|
||||
as the site-notes path does. Before the fix the gables (no lodged
|
||||
height) were silently dropped → the whole 55.67 m² shell billed at
|
||||
U_RR=2.30, a +52 W/K roof over-count and the entire 6035 residual.
|
||||
|
||||
Cross-mapper parity: this is the value the site-notes case-4 replica
|
||||
(`worksheet/_elmhurst_worksheet_001431_6035.py`) pins to its
|
||||
worksheet at 1e-4:
|
||||
loft (41.73−29.75=11.98) × U_roof(300 mm) = 1.6772
|
||||
ext (7.21) × U_roof(300 mm) = 1.0094
|
||||
RR residual (55.67 − 2×11.39 = 32.89) × U_RR(age A=2.30) = 75.647
|
||||
→ 78.3336 W/K
|
||||
"""
|
||||
# Arrange
|
||||
doc = _load_cert("6035-7729-2309-0879-2296")
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Act
|
||||
roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k
|
||||
|
||||
# Assert
|
||||
assert abs(roof_w_per_k - 78.3336) <= 1e-4
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue