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
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
29 PASS / 43 FAIL. §3 fully closes for 5 of 6 fixtures at abs=1e-4 — every
LINE_31/33/36/37 pin passes on 000474/477/480/490/516. Remaining cascade
failures are §4 monthly (000477/487 HW defects, slice 25), §3 (000487 RR
defect, slice 25), and downstream SapResult pins still drifting because
of §5§9a precision not yet pinned.)
(Post-slice-26: section_cascade_pins 220 PASS / 20 FAIL, e2e SapResult
30 PASS / 42 FAIL. §3 + §5 fully close for 5 of 6 fixtures at abs=1e-4.
Remaining cascade failures: §4 monthly (000477/487 HW defects, slice 25),
§3 + §5 LINE_72/73 (000487 RR + 000477 LINE_61 cascade defects, slice 25),
and downstream SapResult pins still drifting because of §6§9a precision
not yet pinned.)
### 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)
```
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 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
@ -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
`<section>_from_cert(epc)` helper from `cert_to_inputs` (mirroring slice
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.
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,
no widening.
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.
- **§7 mean internal temperature** (lines 85-94). 10 line refs, mostly monthly.
- **§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.dimensions import dimensions_from_cert
from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory,
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:
"""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,
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),
)
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 (
cert_to_inputs,
heat_transmission_section_from_cert,
internal_gains_section_from_cert,
ventilation_from_cert,
water_heating_section_from_cert,
)
@ -310,3 +311,71 @@ def test_section_4_monthly_line_refs_match_pdf(
# Assert
for m in range(12):
_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}",
)