From 9cb79d9c98f75bb07f362044874c8bc689400743 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 23 May 2026 21:21:32 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2026:=20=C2=A75=20internal=20gains=20casc?= =?UTF-8?q?ade=20pin=20(50/54=20PASS)=20+=20rooflight=20daylight=20plumb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/sap-spec/HANDOVER_NEXT.md | 18 ++--- .../src/domain/sap/rdsap/cert_to_inputs.py | 44 ++++++++++++ .../tests/test_section_cascade_pins.py | 69 +++++++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index ea002f5e..0e9664a5 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -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 `
_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_` 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. diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index e89b4fe3..93f81670 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index 8bd1eaf9..656e8c21 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -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}", + )