mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice 24: rooflight line (27a) for 000516 — SapRoofWindow datatype + cascade
Closes 000516's §3 LINE_33 0.8215 W/K rooflight gap. Adds SapRoofWindow to EpcPropertyData (area + raw U from RdSAP10 Table 24 "Roof window" column, p.50/113) and iterates them in heat_transmission_from_cert alongside vertical windows — same SAP10.2 §3.2 curtain transform R=0.04. Rooflight area is subtracted from the main part's roof gross so net (30) + (27a) = original gross, leaving (31) area aggregate invariant. 000516 LINE_33 residual: 0.8215 W/K → 0.0038 W/K. Remaining 0.0038 is the same pre-existing wall-perimeter + per-window curtain precision drift biting 000474/477/480/490 (slice 27). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1ac22f3a58
commit
af51be1780
9 changed files with 109 additions and 24 deletions
|
|
@ -170,6 +170,20 @@ class WindowTransmissionDetails:
|
|||
solar_transmittance: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoofWindow:
|
||||
"""RdSAP10 §3 worksheet line (27a) — a pitched roof window cut into a
|
||||
storey-below roof. Heat-transmission contribution is A × U_eff where
|
||||
U_eff applies the SAP10.2 §3.2 curtain resistance (R=0.04 m²K/W) to
|
||||
`u_value_raw`. Roof windows draw their U-value from RdSAP 10 Table 24
|
||||
(p.50/113) "Roof window" column — distinct from the standard-window
|
||||
column (e.g. double-glazed roof window U=3.4, vs 2.8 for standard).
|
||||
"""
|
||||
|
||||
area_m2: float
|
||||
u_value_raw: float # RdSAP10 Table 24 roof-window column, pre-curtain.
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
frame_material: Optional[str]
|
||||
|
|
@ -551,6 +565,12 @@ class EpcPropertyData:
|
|||
# Optional cert-level addendum + LZC source codes.
|
||||
addendum: Optional[Addendum] = None
|
||||
lzc_energy_sources: Optional[List[int]] = None
|
||||
# RdSAP10 §3 line (27a) — roof windows cut into a storey-below roof.
|
||||
# Distinct from `sap_windows` (vertical, line (27)) because Table 24
|
||||
# has a separate roof-window U-value column. None when the dwelling
|
||||
# has no roof windows; for cert-cascade fixtures the bootstrap path
|
||||
# lodges per-window area + raw U.
|
||||
sap_roof_windows: Optional[List[SapRoofWindow]] = None
|
||||
calculation_software_version: Optional[str] = None # Do we care about this?
|
||||
mechanical_vent_duct_placement: Optional[int] = None
|
||||
mechanical_vent_duct_insulation: Optional[int] = None
|
||||
|
|
|
|||
|
|
@ -133,6 +133,10 @@ Two test files contain the strict pins:
|
|||
Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully
|
||||
close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere).
|
||||
|
||||
(Post-slice-24: pin counts unchanged at abs=1e-4 — closure was numeric, not
|
||||
gate-clearing. 000516 LINE_33 went 0.8215 → 0.0038 W/K; still > 1e-4 due to
|
||||
unrelated pre-existing wall + window precision drift.)
|
||||
|
||||
### B.2 SapResult pin matrix (post-slice-22/23)
|
||||
|
||||
```
|
||||
|
|
@ -155,7 +159,7 @@ pumps_fans_kwh_per_yr | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|||
is still off by sub-SAP-point amounts on every fixture — none of `sap_score_
|
||||
continuous` is closed at abs=1e-4.
|
||||
|
||||
### B.3 §3 residuals after slice 22 (window curtain) + slice 23 (000516 RR)
|
||||
### B.3 §3 residuals after slice 24 (rooflight 27a for 000516)
|
||||
|
||||
```
|
||||
fixture | LINE_31 Δ | LINE_33 Δ | LINE_36 Δ | LINE_37 Δ
|
||||
|
|
@ -164,13 +168,15 @@ fixture | LINE_31 Δ | LINE_33 Δ | LINE_36 Δ | LINE_37 Δ
|
|||
000480 | 0.0060 | 0.0168 | 0.0009 | 0.0177
|
||||
000487 | 8.82 | 37.88 | 1.32 | 39.21
|
||||
000490 | 0.0010 | 0.0282 | 0.0002 | 0.0284
|
||||
000516 | 0.0025 | 0.8215 | 0.0004 | 0.8219
|
||||
000516 | 0.0025 | 0.0038 | 0.0004 | 0.0042
|
||||
```
|
||||
|
||||
5 of 6 fixtures have §3 residuals under 0.2 W/K. 000516's 0.82 W/K is the
|
||||
rooflight (line 27a) not yet wired into the §3 cascade. 000487's huge gaps are
|
||||
the RR fixture defect + the U=0.86 external-gable variant our `gable_wall`
|
||||
enum doesn't handle.
|
||||
5 of 6 fixtures have §3 residuals under 0.2 W/K. 000516's 0.82 W/K rooflight
|
||||
gap was closed by slice 24 — line (27a) is now in the cascade. The residual
|
||||
0.0038 W/K on 000516 LINE_33 is the same pre-existing wall-perimeter +
|
||||
per-window curtain precision that's biting 000474/477/480/490 (slice 27
|
||||
territory). 000487's huge gaps are the RR fixture defect + the U=0.86
|
||||
external-gable variant our `gable_wall` enum doesn't handle.
|
||||
|
||||
### B.4 §4 residuals
|
||||
|
||||
|
|
@ -187,6 +193,7 @@ fixture | section §4 pin status
|
|||
### B.5 Recent slices (in reverse order — newest first)
|
||||
|
||||
```
|
||||
Slice 24: rooflight (line 27a) — SapRoofWindow datatype + 000516 cascade closure
|
||||
ac68cf88 Slice 23: 000516 detailed RR + exposed_floor + door_count fixture lodgement
|
||||
6be8fdb7 Slice 22: per-window curtain resistance fix (mixed glazing)
|
||||
024244ec Slice 21d: §3 cascade pins + heat_transmission_section_from_cert helper
|
||||
|
|
@ -205,17 +212,18 @@ e2d9f77d Slice 20: lodge per-window u_value on mixed-glazing fixtures
|
|||
|
||||
## §C — Work queue (in priority order)
|
||||
|
||||
### C.1 Slice 24 — Rooflight (line 27a) heat transmission, for 000516
|
||||
### C.1 Slice 24 — ~~Rooflight (line 27a) heat transmission, for 000516~~ DONE
|
||||
|
||||
000516 PDF lodges a 1.18 m² rooflight on line (27a) at U_eff=2.9930 → 3.5317
|
||||
W/K. Our §3 cascade doesn't include roof windows in heat transmission (only
|
||||
solar gains via SECTION_6_ROOF_WINDOWS).
|
||||
Done. 000516 PDF lodged 1.18 m² rooflight on line (27a) at U_eff=2.9930 →
|
||||
3.5317 W/K. Wired by adding `SapRoofWindow` datatype to `EpcPropertyData`
|
||||
and iterating `epc.sap_roof_windows` alongside vertical windows in
|
||||
`heat_transmission_from_cert` — same SAP10.2 §3.2 curtain transform R=0.04
|
||||
applied; rooflight area subtracted from main part's roof gross. Raw U=3.40
|
||||
sourced from RdSAP10 Table 24 (p.50/113) "Roof window" column.
|
||||
|
||||
Closes 000516 §3 LINE_33 residual (0.82 W/K → ~0). Likely fixture lodgement +
|
||||
small calc change to iterate roof windows alongside vertical windows when
|
||||
applying curtain-resistance + summing.
|
||||
|
||||
Spec source: SAP 10.2 §3 (page 17-22), worksheet line (27a).
|
||||
§3 LINE_33 residual for 000516: 0.8215 W/K → 0.0038 W/K. Remaining 0.0038
|
||||
is the same pre-existing wall-perimeter + per-window curtain precision
|
||||
drift biting 000474/477/480/490 — closes in slice 27.
|
||||
|
||||
### C.2 Slice 25 — 000487 RR + HW + external gable variant
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
SapEnergySource,
|
||||
SapFloorDimension,
|
||||
SapHeating,
|
||||
SapRoofWindow,
|
||||
SapRoomInRoof,
|
||||
SapVentilation,
|
||||
SapWindow,
|
||||
|
|
@ -240,6 +241,7 @@ def make_minimal_sap10_epc(
|
|||
region_code: Optional[str] = None,
|
||||
country_code: Optional[str] = None,
|
||||
sap_windows: Optional[list[SapWindow]] = None,
|
||||
sap_roof_windows: Optional[list[SapRoofWindow]] = None,
|
||||
sap_building_parts: Optional[list[SapBuildingPart]] = None,
|
||||
sap_heating: Optional[SapHeating] = None,
|
||||
photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None,
|
||||
|
|
@ -277,6 +279,9 @@ def make_minimal_sap10_epc(
|
|||
has_fixed_air_conditioning=False,
|
||||
),
|
||||
sap_windows=list(sap_windows) if sap_windows is not None else [],
|
||||
sap_roof_windows=(
|
||||
list(sap_roof_windows) if sap_roof_windows is not None else None
|
||||
),
|
||||
sap_energy_source=SapEnergySource(
|
||||
mains_gas=mains_gas,
|
||||
meter_type="Single",
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
|
|||
"floor_w_per_k": ht.floor_w_per_k,
|
||||
"party_walls_w_per_k": ht.party_walls_w_per_k,
|
||||
"windows_w_per_k": ht.windows_w_per_k,
|
||||
"roof_windows_w_per_k": ht.roof_windows_w_per_k,
|
||||
"doors_w_per_k": ht.doors_w_per_k,
|
||||
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
|
||||
# Annual means for the back-compat single-float audit dict; full
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ def _baseline_dwelling() -> CalculatorInputs:
|
|||
floor_w_per_k=20.0,
|
||||
party_walls_w_per_k=0.0,
|
||||
windows_w_per_k=25.0,
|
||||
roof_windows_w_per_k=0.0,
|
||||
doors_w_per_k=5.0,
|
||||
thermal_bridging_w_per_k=20.0,
|
||||
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ def _baseline_inputs() -> CalculatorInputs:
|
|||
floor_w_per_k=20.0,
|
||||
party_walls_w_per_k=0.0,
|
||||
windows_w_per_k=25.0,
|
||||
roof_windows_w_per_k=0.0,
|
||||
doors_w_per_k=5.0,
|
||||
thermal_bridging_w_per_k=20.0,
|
||||
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
|
||||
|
|
@ -611,7 +612,7 @@ def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
|
|||
base = _baseline_inputs()
|
||||
no_loss = replace(
|
||||
base,
|
||||
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
monthly_infiltration_ach=(0.0,) * 12,
|
||||
space_heating_monthly_kwh=(0.0,) * 12,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ Worksheet line mapping (SAP 10.2 §3, canonical xlsx rows 121-207):
|
|||
(26) solid doors
|
||||
(27) windows — uses effective U = 1/(1/U + 0.04) per §3.2 (curtain
|
||||
allowance, R = 0.04 m²K/W); raw U from RdSAP Table 24
|
||||
(27a) roof windows — same curtain transform as (27), but raw U from
|
||||
the RdSAP10 Table 24 "Roof window" column (p.50/113), not the
|
||||
standard-window column (e.g. double-glazed roof window U=3.4 vs
|
||||
2.8 for standard).
|
||||
(28a) ground floor (per part)
|
||||
(29a) external walls (main + alternative walls 1 & 2, RdSAP §1.4.2)
|
||||
(30) roof (per part)
|
||||
|
|
@ -42,6 +46,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
EpcPropertyData,
|
||||
SapAlternativeWall,
|
||||
SapBuildingPart,
|
||||
SapRoofWindow,
|
||||
)
|
||||
|
||||
from domain.ml.rdsap_uvalues import (
|
||||
|
|
@ -85,6 +90,7 @@ class HeatTransmission:
|
|||
floor_w_per_k: float # (28a)
|
||||
party_walls_w_per_k: float # (32)
|
||||
windows_w_per_k: float # (27) — uses effective U
|
||||
roof_windows_w_per_k: float # (27a) — same curtain transform as (27)
|
||||
doors_w_per_k: float # (26)
|
||||
thermal_bridging_w_per_k: float # (36)
|
||||
fabric_heat_loss_w_per_k: float # (33) = Σ (A×U), no bridging
|
||||
|
|
@ -255,7 +261,7 @@ def heat_transmission_from_cert(
|
|||
exposure = DwellingExposure()
|
||||
parts = epc.sap_building_parts or []
|
||||
if not parts:
|
||||
return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
country = Country.from_code(epc.country_code)
|
||||
roof_description = _joined_descriptions(epc.roofs)
|
||||
|
|
@ -294,6 +300,23 @@ def heat_transmission_from_cert(
|
|||
else 0.0
|
||||
)
|
||||
windows_w_per_k_total = window_u * window_total_area_m2
|
||||
|
||||
# SAP10.2 §3 (27a) — per-roof-window curtain transform, same R=0.04
|
||||
# rule as (27). Total area is apportioned to the first (main) part
|
||||
# below so the storey-below roof gross is reduced by the rooflight
|
||||
# opening — same convention as wall windows reducing the gross wall.
|
||||
roof_windows_list: list[SapRoofWindow] = list(epc.sap_roof_windows or [])
|
||||
roof_windows_w_per_k_total = 0.0
|
||||
roof_windows_area_total = 0.0
|
||||
for rw in roof_windows_list:
|
||||
a_rw = float(rw.area_m2)
|
||||
u_raw_rw = float(rw.u_value_raw)
|
||||
u_eff_rw = (
|
||||
1.0 / (1.0 / u_raw_rw + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W)
|
||||
if u_raw_rw > 0 else 0.0
|
||||
)
|
||||
roof_windows_w_per_k_total += a_rw * u_eff_rw
|
||||
roof_windows_area_total += a_rw
|
||||
primary_age = parts[0].construction_age_band
|
||||
door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None)
|
||||
door_insulated_u = (
|
||||
|
|
@ -386,7 +409,14 @@ def heat_transmission_from_cert(
|
|||
d_area = door_area if i == 0 else 0.0
|
||||
net_wall_area = max(0.0, gross_wall_area - w_area - d_area)
|
||||
party_area = geom["party_wall_area_m2"]
|
||||
roof_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
|
||||
# Roof windows cut into the storey-below roof, reducing the regular
|
||||
# roof's net area. Allocated to the first (main) part — same
|
||||
# convention as `sap_windows` / `door_area`.
|
||||
rw_area_part = roof_windows_area_total if i == 0 else 0.0
|
||||
gross_roof_area = (
|
||||
geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
|
||||
)
|
||||
roof_area = max(0.0, gross_roof_area - rw_area_part)
|
||||
floor_area_total = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0
|
||||
|
||||
# RdSAP §1.4.2: a building part can have up to 2 alternative walls,
|
||||
|
|
@ -481,19 +511,24 @@ def heat_transmission_from_cert(
|
|||
# the external surfaces per the spec — A_RR contributes to (31)
|
||||
# alongside walls + roof + floor + openings.
|
||||
part_external_area = (
|
||||
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr + rr_detailed_area
|
||||
main_wall_area + alt_walls_total_area + roof_area + floor_area_total
|
||||
+ w_area + d_area + rw_area_part + rr_a_rr + rr_detailed_area
|
||||
)
|
||||
total_external_area += part_external_area
|
||||
bridging += y * part_external_area
|
||||
|
||||
fabric_heat_loss = walls + roof + floor + party + windows + doors # (33)
|
||||
total = fabric_heat_loss + bridging # (37)
|
||||
roof_windows_w_per_k = roof_windows_w_per_k_total
|
||||
fabric_heat_loss = (
|
||||
walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33)
|
||||
)
|
||||
total = fabric_heat_loss + bridging # (37)
|
||||
return HeatTransmission(
|
||||
walls_w_per_k=walls,
|
||||
roof_w_per_k=roof,
|
||||
floor_w_per_k=floor,
|
||||
party_walls_w_per_k=party,
|
||||
windows_w_per_k=windows,
|
||||
roof_windows_w_per_k=roof_windows_w_per_k,
|
||||
doors_w_per_k=doors,
|
||||
thermal_bridging_w_per_k=bridging,
|
||||
fabric_heat_loss_w_per_k=fabric_heat_loss,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
EpcPropertyData,
|
||||
SapBuildingPart,
|
||||
SapFloorDimension,
|
||||
SapRoofWindow,
|
||||
SapRoomInRoof,
|
||||
SapRoomInRoofSurface,
|
||||
SapVentilation,
|
||||
|
|
@ -128,6 +129,15 @@ def build_epc() -> EpcPropertyData:
|
|||
# AREA_M2 (1.85). Same as 000477/000480 — single worksheet entry
|
||||
# but the area resolves to 2 physical doors.
|
||||
door_count=2,
|
||||
# 000516 PDF (27a) — single 1.18 m² roof window cut into the
|
||||
# storey-below "External roof Main". Cert lodges it as
|
||||
# "Manufacturer, Roof Window, Double glazed, FF=0.70" without a
|
||||
# measured U; raw U=3.40 from RdSAP10 Table 24 (p.50/113) "Roof
|
||||
# window" column. After SAP10.2 §3.2 curtain transform (R=0.04
|
||||
# m²K/W): U_eff = 1/(1/3.40 + 0.04) = 2.9930, matching the PDF
|
||||
# (27a) lodgement exactly. Contribution = 1.18 × 2.9930 = 3.5317
|
||||
# W/K.
|
||||
sap_roof_windows=[SapRoofWindow(area_m2=1.18, u_value_raw=3.40)],
|
||||
percent_draughtproofed=75,
|
||||
low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL,
|
||||
sap_windows=list(SECTION_6_VERTICAL_WINDOWS),
|
||||
|
|
@ -208,6 +218,7 @@ LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
|
|||
# §3 Heat losses (reference only — §3 test currently checks invariants;
|
||||
# our calculator under-reports because RR slope/stud/gable sub-areas
|
||||
# aren't yet modelled by SapRoomInRoof).
|
||||
LINE_27A_ROOF_WINDOWS_W_PER_K: float = 3.5317 # 1.18 × U_eff(3.40) = 2.9930
|
||||
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 122.0100
|
||||
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 211.3188
|
||||
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 18.3015 # 0.15 × 122.01
|
||||
|
|
|
|||
|
|
@ -891,7 +891,8 @@ def test_heat_transmission_exposes_line_31_total_external_area_and_line_33_fabri
|
|||
# Assert — invariants
|
||||
expected_33 = (
|
||||
result.walls_w_per_k + result.roof_w_per_k + result.floor_w_per_k
|
||||
+ result.party_walls_w_per_k + result.windows_w_per_k + result.doors_w_per_k
|
||||
+ result.party_walls_w_per_k + result.windows_w_per_k
|
||||
+ result.roof_windows_w_per_k + result.doors_w_per_k
|
||||
)
|
||||
assert result.fabric_heat_loss_w_per_k == pytest.approx(expected_33, rel=1e-9)
|
||||
assert result.total_w_per_k == pytest.approx(
|
||||
|
|
@ -1097,7 +1098,8 @@ def test_basement_floor_uses_table_23_u_value_for_whole_floor_when_basement_dete
|
|||
# Per-element invariant still holds.
|
||||
assert basement.fabric_heat_loss_w_per_k == pytest.approx(
|
||||
basement.walls_w_per_k + basement.roof_w_per_k + basement.floor_w_per_k
|
||||
+ basement.party_walls_w_per_k + basement.windows_w_per_k + basement.doors_w_per_k,
|
||||
+ basement.party_walls_w_per_k + basement.windows_w_per_k
|
||||
+ basement.roof_windows_w_per_k + basement.doors_w_per_k,
|
||||
rel=1e-9,
|
||||
)
|
||||
|
||||
|
|
@ -1297,7 +1299,8 @@ def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType)
|
|||
# Assert — internal invariants
|
||||
expected_fabric = (
|
||||
result.walls_w_per_k + result.roof_w_per_k + result.floor_w_per_k
|
||||
+ result.party_walls_w_per_k + result.windows_w_per_k + result.doors_w_per_k
|
||||
+ result.party_walls_w_per_k + result.windows_w_per_k
|
||||
+ result.roof_windows_w_per_k + result.doors_w_per_k
|
||||
)
|
||||
assert result.fabric_heat_loss_w_per_k == pytest.approx(expected_fabric, rel=1e-9)
|
||||
assert result.total_w_per_k == pytest.approx(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue