Slice 26: §5 internal gains cascade pin (50/54 PASS) + rooflight daylight plumb

Added `internal_gains_section_from_cert` helper composing §1 (volume) +
§4 (heat_gains line 65)m → §5 orchestrator, and 54 strict pin cases for
worksheet lines (66)..(73) monthly + (232) annual lighting kWh.

Also fixed a missing input plumb: cert_to_inputs was passing
`rooflight_total_area_m2=0` to `internal_gains_from_cert`, so the
000516 roof window (lodged on `epc.sap_roof_windows` since slice 24)
wasn't contributing to the L2a daylight factor. Added
`_rooflight_total_area_m2_from_cert` and routed it through both the
public cert→inputs cascade and the new §5 section helper.

§5 cascade:
  field    | 474 | 477 | 480 | 487 | 490 | 516
  LINE_66  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_67  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  (rooflight plumb)
  LINE_68  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_69  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_70  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_71  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_72  |  ✓  |  ✗  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_73  |  ✓  |  ✗  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_232 |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓

Remaining failures are 000477 + 000487 LINE_72/73 — cascaded from §4
LINE_65 heat_gains residuals (000477 combi loss, 000487 HW lodgement
defect). Both fixtures are slice 25 territory.

Scoreboard:
  section_cascade_pins: 170 → 220 PASS (+50; 54 new tests, 4 fail)
  e2e SapResult:        29 →  30 PASS (+1, downstream from rooflight plumb)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 21:21:32 +00:00
parent d4c090fc7c
commit 9cb79d9c98
3 changed files with 123 additions and 8 deletions

View file

@ -133,12 +133,12 @@ Two test files contain the strict pins:
Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully 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). close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere).
(Post-slice-27b: section_cascade_pins 170 PASS / 16 FAIL, e2e SapResult (Post-slice-26: section_cascade_pins 220 PASS / 20 FAIL, e2e SapResult
29 PASS / 43 FAIL. §3 fully closes for 5 of 6 fixtures at abs=1e-4 — every 30 PASS / 42 FAIL. §3 + §5 fully close for 5 of 6 fixtures at abs=1e-4.
LINE_31/33/36/37 pin passes on 000474/477/480/490/516. Remaining cascade Remaining cascade failures: §4 monthly (000477/487 HW defects, slice 25),
failures are §4 monthly (000477/487 HW defects, slice 25), §3 (000487 RR §3 + §5 LINE_72/73 (000487 RR + 000477 LINE_61 cascade defects, slice 25),
defect, slice 25), and downstream SapResult pins still drifting because and downstream SapResult pins still drifting because of §6§9a precision
of §5§9a precision not yet pinned.) not yet pinned.)
### B.2 SapResult pin matrix (post-slice-22/23) ### B.2 SapResult pin matrix (post-slice-22/23)
@ -199,6 +199,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first) ### B.5 Recent slices (in reverse order — newest first)
``` ```
Slice 26: §5 internal gains cascade pin (54 cases, 50 PASS / 4 FAIL) + rooflight plumb to daylight factor
Slice 27b: §3 element-area + door-area rounding to 2 d.p. per RdSAP10 §15 (p.66) Slice 27b: §3 element-area + door-area rounding to 2 d.p. per RdSAP10 §15 (p.66)
Slice 27: BS EN ISO 13370 floor U rounded to 2 d.p. per RdSAP10 §5.12 Slice 27: BS EN ISO 13370 floor U rounded to 2 d.p. per RdSAP10 §5.12
Slice 24: rooflight (line 27a) — SapRoofWindow datatype + 000516 cascade closure Slice 24: rooflight (line 27a) — SapRoofWindow datatype + 000516 cascade closure
@ -256,14 +257,15 @@ The cascade pin work continues in worksheet order. For each section:
1. Identify the cert→inputs cascade entry point. May need to extract a 1. Identify the cert→inputs cascade entry point. May need to extract a
`<section>_from_cert(epc)` helper from `cert_to_inputs` (mirroring slice `<section>_from_cert(epc)` helper from `cert_to_inputs` (mirroring slice
21c's `ventilation_from_cert`, 21d's `heat_transmission_section_from_cert`, 21c's `ventilation_from_cert`, 21d's `heat_transmission_section_from_cert`,
21e's `water_heating_section_from_cert`). 21e's `water_heating_section_from_cert`, 26's
`internal_gains_section_from_cert`).
2. Map fixture `LINE_X_<NAME>` constants to result struct attributes. 2. Map fixture `LINE_X_<NAME>` constants to result struct attributes.
3. Add scalar + monthly pin tests at abs=1e-4 to `test_section_cascade_pins.py`. 3. Add scalar + monthly pin tests at abs=1e-4 to `test_section_cascade_pins.py`.
4. Run, see failures, diagnose. Fixture defect or calculator bug — fix in place, 4. Run, see failures, diagnose. Fixture defect or calculator bug — fix in place,
no widening. no widening.
Sections still to pin: Sections still to pin:
- **§5 internal gains** (lines 66-73 + 232 lighting kWh). 6 monthly + 1 annual. - ~~**§5 internal gains** (lines 66-73 + 232 lighting kWh)~~ DONE (slice 26)
- **§6 solar gains** (lines 83-84). 2 monthly tuples. - **§6 solar gains** (lines 83-84). 2 monthly tuples.
- **§7 mean internal temperature** (lines 85-94). 10 line refs, mostly monthly. - **§7 mean internal temperature** (lines 85-94). 10 line refs, mostly monthly.
- **§8 space heating** (lines 95-99). 4 monthly + 2 annual. - **§8 space heating** (lines 95-99). 4 monthly + 2 annual.

View file

@ -71,6 +71,7 @@ from domain.sap.tables.table_32 import (
from domain.sap.worksheet.fuel_cost import FuelCostResult, fuel_cost from domain.sap.worksheet.fuel_cost import FuelCostResult, fuel_cost
from domain.sap.worksheet.dimensions import dimensions_from_cert from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.internal_gains import ( from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory, OvershadingCategory,
internal_gains_from_cert, internal_gains_from_cert,
) )
@ -755,6 +756,48 @@ def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmissio
) )
def _rooflight_total_area_m2_from_cert(epc: EpcPropertyData) -> float:
"""Σ area of `epc.sap_roof_windows` for §5 daylight-factor L2a +
§6 horizontal solar gain. Returns 0.0 when none are lodged.
Roof windows behave as rooflights for §5 L2a (Z_L = 1.0 per Table 6d
note 2) same treatment as horizontal rooflights for the daylight
bonus. Areas are 2-d.p.-rounded inputs (RdSAP10 §15) when lodged on
the SapRoofWindow datatype."""
return sum(float(rw.area_m2) for rw in epc.sap_roof_windows or [])
def internal_gains_section_from_cert(
epc: EpcPropertyData,
) -> Optional[InternalGainsResult]:
"""SAP 10.2 §5 cert→inputs cascade for `internal_gains_from_cert`.
Composes §1 (dim.volume_m3) + §4 (heat_gains_from_water_heating
monthly_kwh, line (65)m) and threads them through the §5 orchestrator
exactly as `cert_to_inputs` computes internally. Returns the full
`InternalGainsResult` (every (66)..(73) line ref + annual lighting kWh
line (232)) so cascade pin tests can assert each §5 line ref against
the U985 PDF.
Returns `None` when TFA is missing (matches the §4 helper contract;
tests using this helper should skip those fixtures).
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
wh = water_heating_section_from_cert(epc)
hw_heat_gains_monthly_kwh = (
wh.heat_gains_monthly_kwh if wh is not None else (0.0,) * 12
)
return internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=dim.volume_m3,
heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
rooflight_total_area_m2=_rooflight_total_area_m2_from_cert(epc),
)
def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult: def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
"""SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`. """SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`.
@ -1212,6 +1255,7 @@ def cert_to_inputs(
dwelling_volume_m3=dim.volume_m3, dwelling_volume_m3=dim.volume_m3,
heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh, heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
rooflight_total_area_m2=_rooflight_total_area_m2_from_cert(epc),
) )
internal_gains_monthly_w = ( internal_gains_monthly_w = (
internal_gains_result.total_internal_gains_monthly_w internal_gains_result.total_internal_gains_monthly_w

View file

@ -19,6 +19,7 @@ import pytest
from domain.sap.rdsap.cert_to_inputs import ( from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs, cert_to_inputs,
heat_transmission_section_from_cert, heat_transmission_section_from_cert,
internal_gains_section_from_cert,
ventilation_from_cert, ventilation_from_cert,
water_heating_section_from_cert, water_heating_section_from_cert,
) )
@ -310,3 +311,71 @@ def test_section_4_monthly_line_refs_match_pdf(
# Assert # Assert
for m in range(12): for m in range(12):
_pin(actual[m], expected[m], f"§4 {fixture_attr}[{m+1}] {fixture_name}") _pin(actual[m], expected[m], f"§4 {fixture_attr}[{m+1}] {fixture_name}")
# ============================================================================
# §5 Internal gains — LINE_66..LINE_73 monthly + LINE_232 annual
# ============================================================================
_SECTION_5_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_66_M_METABOLIC_W", "metabolic_monthly_w"),
("LINE_67_M_LIGHTING_W", "lighting_monthly_w"),
("LINE_68_M_APPLIANCES_W", "appliances_monthly_w"),
("LINE_69_M_COOKING_W", "cooking_monthly_w"),
("LINE_70_M_PUMPS_FANS_W", "pumps_fans_monthly_w"),
("LINE_71_M_LOSSES_W", "losses_monthly_w"),
("LINE_72_M_WATER_HEATING_GAINS_W", "water_heating_gains_monthly_w"),
("LINE_73_M_TOTAL_INTERNAL_GAINS_W", "total_internal_gains_monthly_w"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_5_MONTHLY_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_5_monthly_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§5 monthly pins — every Jan..Dec value of (66)..(73) internal-gain
component matches the U985 PDF to abs=1e-4."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
ig = internal_gains_section_from_cert(epc)
assert ig is not None, f"{fixture_name}: internal_gains_from_cert returned None"
actual = getattr(ig, result_attr)
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§5 {fixture_attr}[{m+1}] {fixture_name}")
@pytest.mark.parametrize("fixture_name", list(_FIXTURES), ids=lambda x: x)
def test_section_5_line_232_lighting_kwh_per_yr_matches_pdf(
fixture_name: str,
) -> None:
"""§5 (232) — annual lighting kWh from Appendix L, fuels the cost side
`inputs.lighting_kwh_per_yr`."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = mod.LINE_232_LIGHTING_KWH_PER_YR # type: ignore[attr-defined]
# Act
ig = internal_gains_section_from_cert(epc)
assert ig is not None, f"{fixture_name}: internal_gains_from_cert returned None"
# Assert
_pin(
ig.lighting_kwh_per_yr,
expected,
f"§5 (232) {fixture_name}",
)