diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index 2951205d..a2245d0d 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -133,12 +133,14 @@ 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-26b: section_cascade_pins 230 PASS / 22 FAIL, e2e SapResult -32 PASS / 40 FAIL. §3 + §5 + §6 fully close for 5 of 6 fixtures at -abs=1e-4. Remaining cascade failures: §4 monthly (000477/487 HW defects, -slice 25), §5 LINE_72/73 + §6 LINE_84 on 000477/487 (cascaded from §4), -§3 (000487 RR defect, slice 25), and downstream SapResult pins still -drifting because of §7–§9a precision not yet pinned.) +(Post-slice-26c: section_cascade_pins 274 PASS / 38 FAIL, e2e SapResult +32 PASS / 40 FAIL. §3 + §5 + §6 + §7 (mostly) pinned. §7 LINE_85..91 ++ LINE_87/88/89/90 close at abs=1e-4 for all 5 non-487 fixtures. +LINE_92/93 marginal residuals (~0.0001 K, just over threshold) on +000474/477/480/490 — investigation needed (possible PDF intermediate +rounding precision artefact). 000487 fully cascades from §3/§4 defects +(slice 25). e2e SapResult unchanged because cert_to_inputs was already +running the §7 calc internally — pin tests just surface it now.) ### B.2 SapResult pin matrix (post-slice-22/23) @@ -199,6 +201,7 @@ fixture | section §4 pin status ### B.5 Recent slices (in reverse order — newest first) ``` +Slice 26c: §7 mean internal temp cascade pin (60 cases, 44 PASS) — LINE_85..94 Slice 26b: §6 solar gains cascade pin (12 cases, 10 PASS) + SapRoofWindow solar attrs + plumb to §6 cascade 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) @@ -268,7 +271,7 @@ The cascade pin work continues in worksheet order. For each section: Sections still to pin: - ~~**§5 internal gains** (lines 66-73 + 232 lighting kWh)~~ DONE (slice 26) - ~~**§6 solar gains** (lines 83-84)~~ DONE (slice 26b — 5/6 fixtures close, 000477/487 cascade from §4) -- **§7 mean internal temperature** (lines 85-94). 10 line refs, mostly monthly. +- ~~**§7 mean internal temperature** (lines 85-94)~~ MOSTLY DONE (slice 26c — 44/60 PASS; LINE_92/93 marginal ~0.0001 K residual on 000474/477/480/490 needs investigation; 000487 cascades from §3/§4 defects). - **§8 space heating** (lines 95-99). 4 monthly + 2 annual. - **§9a energy requirements** (lines 201, 206-208, 211-215, 219). 5 scalar + 2 monthly. Currently only the annual aggregates show on `SapResult` — may need 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 a9dc40b8..87928920 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -88,6 +88,7 @@ from domain.sap.worksheet.heat_transmission import ( ) from domain.sap.climate.appendix_u import external_temperature_c from domain.sap.worksheet.mean_internal_temperature import ( + MeanInternalTemperatureResult, mean_internal_temperature_monthly, ) from domain.sap.worksheet.energy_requirements import ( @@ -827,6 +828,54 @@ def _roof_windows_for_solar_gains( ) +def mean_internal_temperature_section_from_cert( + epc: EpcPropertyData, +) -> Optional[MeanInternalTemperatureResult]: + """SAP 10.2 §7 cert→inputs cascade for `mean_internal_temperature_monthly`. + + Composes §1 (dim) + §2 (effective_monthly_ach) + §3 (total HLC) + §5 + (internal gains) + §6 (solar gains) + climate (external temp) and + threads them through the §7 orchestrator — exactly as cert_to_inputs + computes internally. Returns the full + `MeanInternalTemperatureResult` (every (85)..(94) line ref) so + cascade pin tests can assert each §7 line ref against the U985 PDF. + + Returns `None` when TFA is missing (matches other section helpers). + """ + if epc.total_floor_area_m2 is None: + return None + dim = dimensions_from_cert(epc) + ventilation = ventilation_from_cert(epc) + ht = heat_transmission_section_from_cert(epc) + ig = internal_gains_section_from_cert(epc) + sg = solar_gains_section_from_cert(epc) + assert ig is not None, "internal_gains None despite TFA present" + internal_gains_monthly_w = ig.total_internal_gains_monthly_w + solar_gains_monthly_w = sg.total_solar_gains_monthly_w + monthly_total_gains_w = tuple( + internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) + ) + monthly_htc_w_per_k = tuple( + ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m] + for m in range(12) + ) + main = _first_main_heating(epc) + region = _region_index(epc.region_code) + return mean_internal_temperature_monthly( + monthly_external_temp_c=tuple( + external_temperature_c(region, m) for m in range(1, 13) + ), + monthly_total_gains_w=monthly_total_gains_w, + monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, + thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + total_floor_area_m2=dim.total_floor_area_m2, + control_type=_control_type(main), + responsiveness=_responsiveness(main), + living_area_fraction=_living_area_fraction(epc.habitable_rooms_count), + control_temperature_adjustment_c=0.0, + ) + + def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult: """SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`. 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 6ef8532a..b950a100 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 @@ -20,6 +20,7 @@ from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, heat_transmission_section_from_cert, internal_gains_section_from_cert, + mean_internal_temperature_section_from_cert, solar_gains_section_from_cert, ventilation_from_cert, water_heating_section_from_cert, @@ -439,3 +440,81 @@ def test_section_6_line_84_total_gains_match_pdf( # Assert for m in range(12): _pin(actual[m], expected[m], f"§6 (84)[{m+1}] {fixture_name}") + + +# ============================================================================ +# §7 Mean internal temperature — LINE_85..LINE_94 scalar + monthly tuples +# ============================================================================ + +_SECTION_7_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_85_T_H1_C", "living_area_heating_temp_c"), + ("LINE_91_LIVING_AREA_FRACTION", "living_area_fraction"), +) + +_SECTION_7_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_86_M_UTILISATION_LIVING", "utilisation_factor_living_monthly"), + ("LINE_87_M_MIT_LIVING_C", "mean_internal_temp_living_monthly"), + ("LINE_88_M_T_H2_C", "elsewhere_heating_temp_monthly"), + ("LINE_89_M_UTILISATION_ELSEWHERE", "utilisation_factor_elsewhere_monthly"), + ("LINE_90_M_MIT_ELSEWHERE_C", "mean_internal_temp_elsewhere_monthly"), + ("LINE_92_M_MIT_C", "mean_internal_temp_monthly"), + ("LINE_93_M_ADJUSTED_MIT_C", "adjusted_mean_internal_temp_monthly"), + ("LINE_94_M_UTILISATION_WHOLE", "utilisation_factor_whole_monthly"), +) + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_7_SCALAR_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_7_scalar_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§7 scalar pins — (85) T_h1 living-area heating temp + (91) living + area fraction.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + mit = mean_internal_temperature_section_from_cert(epc) + assert mit is not None, f"{fixture_name}: mit_from_cert returned None" + actual = getattr(mit, result_attr) + + # Assert + _pin(actual, expected, f"§7 {fixture_attr} {fixture_name}") + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_7_MONTHLY_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_7_monthly_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§7 monthly pins — every Jan..Dec value of (86)..(94) MIT + η lines + 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 + mit = mean_internal_temperature_section_from_cert(epc) + assert mit is not None, f"{fixture_name}: mit_from_cert returned None" + actual = getattr(mit, result_attr) + + # Assert + for m in range(12): + _pin(actual[m], expected[m], f"§7 {fixture_attr}[{m+1}] {fixture_name}")