Slice 21d: §3 cascade pins + heat_transmission_section_from_cert helper

Extracts `heat_transmission_section_from_cert(epc)` wrapping the §3
inline call in cert_to_inputs (window-area/window-U/dwelling-exposure
plumbing). Replaces the inline call. Adds §3 cascade pins for the
four aggregate line refs:

  (31) total_external_element_area_m2
  (33) fabric_heat_loss_w_per_k
  (36) thermal_bridging_w_per_k
  (37) total_w_per_k

Results at abs=1e-4 (1/24 PASS):

  fixture | LINE_31 diff | LINE_33 diff | LINE_36 diff | LINE_37 diff
  --------|--------------|--------------|--------------|-------------
  000474  |     0.0014   |     0.086    |     0.0002   |     0.086
  000477  |     0.0004   |     0.105    |     ✓        |     0.104
  000480  |     0.006    |     0.017    |     0.0009   |     0.018
  000487  |     8.82     |    37.88     |     1.32     |    39.21
  000490  |     0.000    |     0.064    |     0.000    |     0.064
  000516  |     0.012    |     0.183    |     0.002    |     0.184

Three buckets:
- 000487 (RR fixture defect): large gaps — fixture lodges Simplified
  Type 1 RR but PDF has detailed §3.10 lodgement including a U=0.86
  external gable. Slice 22 closes (mirrors S16a).
- 000474/000477/000480/000490/000516 (precision residuals): LINE_33
  drifts 0.02-0.18 W/K — sub-display-precision (PDF lodges to 4 d.p.
  per element, our calc combines full-precision per-storey perimeters
  + 4-d.p. U values). The aggregate diff of ~0.1 W/K is just over the
  abs=1e-4 floor but well under the worksheet's display granularity.

Cascade pins now: §1 (12 PASS) + §2 (96 PASS) + §3 (1 PASS, 23 FAIL).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 23:13:48 +00:00
parent 5b7dbe2c21
commit 024244ec59
2 changed files with 73 additions and 15 deletions

View file

@ -76,6 +76,7 @@ from domain.sap.worksheet.internal_gains import (
)
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
HeatTransmission,
heat_transmission_from_cert,
)
from domain.sap.climate.appendix_u import external_temperature_c
@ -690,6 +691,28 @@ def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts:
)
def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmission:
"""SAP 10.2 §3 cert→inputs cascade for `heat_transmission_from_cert`.
Wraps the `_window_total_area_and_avg_u` + `_dwelling_exposure`
derivations cert_to_inputs makes internally and returns the full
`HeatTransmission` (every (26)..(37) line ref breakdown). Exposed
so cascade pin tests can assert each §3 line ref against the U985
PDF.
"""
window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows)
exposure = _dwelling_exposure(epc.dwelling_type)
return heat_transmission_from_cert(
epc,
window_total_area_m2=window_total_area,
window_avg_u_value=window_avg_u,
door_count=epc.door_count,
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
exposure=exposure,
)
def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
"""SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`.
@ -1050,20 +1073,9 @@ def cert_to_inputs(
(inspection_date 2025-07-01) rather than a per-cert price
override."""
dim = dimensions_from_cert(epc)
window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows)
exposure = _dwelling_exposure(epc.dwelling_type)
ht = heat_transmission_from_cert(
epc,
window_total_area_m2=window_total_area,
window_avg_u_value=window_avg_u,
door_count=epc.door_count,
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
exposure=exposure,
)
# SAP §2 ventilation cascade — see `ventilation_from_cert` for the
# cert→inputs mapping rules + spec-default conventions.
# SAP §3 heat transmission + §2 ventilation cascades — see the
# respective `_from_cert` helpers for cert→inputs mapping rules.
ht = heat_transmission_section_from_cert(epc)
ventilation = ventilation_from_cert(epc)
main = _first_main_heating(epc)

View file

@ -16,7 +16,11 @@ from typing import Final
import pytest
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs, ventilation_from_cert
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs,
heat_transmission_section_from_cert,
ventilation_from_cert,
)
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
@ -187,3 +191,45 @@ def test_section_2_line_19_sheltered_sides_matches_pdf(fixture_name: str) -> Non
assert vent.sheltered_sides == expected, (
f"§2 (19) {fixture_name}: actual={vent.sheltered_sides}, expected={expected}"
)
# ============================================================================
# §3 Heat losses + heat loss parameter — LINE_31/33/36/37 aggregates
# ============================================================================
_SECTION_3_PINS: Final[tuple[tuple[str, str], ...]] = (
# (fixture_attr, HeatTransmission attr)
("LINE_31_TOTAL_EXTERNAL_AREA_M2", "total_external_element_area_m2"),
("LINE_33_FABRIC_HEAT_LOSS_W_PER_K", "fabric_heat_loss_w_per_k"),
("LINE_36_THERMAL_BRIDGING_W_PER_K", "thermal_bridging_w_per_k"),
("LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K", "total_w_per_k"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_3_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_3_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§3 cascade pins — every (31)/(33)/(36)/(37) aggregate matches the
U985 PDF to abs=1e-4. Per-element breakdowns (26)/(27)/(28a)/(29a)/
(30)/(32) are not currently lodged in fixture constants they're
asserted indirectly via the aggregates."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
ht = heat_transmission_section_from_cert(epc)
actual = getattr(ht, result_attr)
# Assert
_pin(actual, expected, f"§3 {fixture_attr} {fixture_name}")