From 049694e1e6f352d767f2c49a61bec5710dfeaafc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 00:16:12 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2029:=20=C2=A79a=20energy=20requirements?= =?UTF-8?q?=20cascade=20pin=20(72/72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `energy_requirements_section_from_cert(epc)` to the cert→inputs cascade. Composes §8 (98c)m + Table 11 secondary fraction + per-system efficiencies into (201)..(221) line refs via the existing `space_heating_fuel_monthly_kwh` orchestrator. Extracts `_main_heating_efficiency(epc)` as a shared helper — same eff derivation as the inline `cert_to_inputs` flow (PCDB winter override → Table 4a/4b seasonal → heat-network 1/DLF override). Single source of truth for §4 and §9a. Worksheet display convention: when no secondary system is lodged the PDF displays (208) = 0 (not the fallback 100% electric efficiency). The per-system fuel formula already collapses to 0 via fraction_201 = 0, so this is presentation-only; the helper zeros (208) when `secondary_fraction == 0`. 000474 (no secondary) now matches exactly. Adds §9a LINE_ constants to all 6 fixtures — (201), (202), (206), (207), (208), (211)m, (211), (213)m, (213), (215)m, (215), (221). Extracted from `sap worksheets/U985-0001-NNNNNN.txt` PDF blocks. Cascade scoreboard: 396/396 → 468/468 (§7..§9a closed). Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 81 ++++++++++++++++--- .../tests/_elmhurst_worksheet_000474.py | 19 +++++ .../tests/_elmhurst_worksheet_000477.py | 22 +++++ .../tests/_elmhurst_worksheet_000480.py | 22 +++++ .../tests/_elmhurst_worksheet_000487.py | 22 +++++ .../tests/_elmhurst_worksheet_000490.py | 22 +++++ .../tests/_elmhurst_worksheet_000516.py | 22 +++++ .../tests/test_section_cascade_pins.py | 81 +++++++++++++++++++ 8 files changed, 279 insertions(+), 12 deletions(-) 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 392fff2d..8399f272 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -411,6 +411,35 @@ def _first_main_heating(epc: EpcPropertyData) -> Optional[MainHeatingDetail]: return details[0] if details else None +def _main_heating_efficiency(epc: EpcPropertyData) -> float: + """SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction. + + Resolves PCDB Table 105 winter efficiency override → Table 4a/4b + seasonal efficiency → heat-network 1/DLF override. Used by §4 (water + heating cascade) and §9a (per-system fuel kWh) — both must see the + same value, so this single helper is the single source of truth.""" + main = _first_main_heating(epc) + main_code = main.sap_main_heating_code if main is not None else None + main_category = main.main_heating_category if main is not None else None + main_fuel = _main_fuel_code(main) + pcdb_main = ( + gas_oil_boiler_record(main.main_heating_index_number) + if main is not None and main.main_heating_index_number is not None + else None + ) + if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: + eff = pcdb_main.winter_efficiency_pct / 100.0 + else: + eff = seasonal_efficiency(main_code, main_category, main_fuel) + if _is_heat_network_main(main): + primary_age = ( + epc.sap_building_parts[0].construction_age_band + if epc.sap_building_parts else None + ) + eff = 1.0 / _heat_network_dlf(primary_age) + return eff + + def _control_type(main: Optional[MainHeatingDetail]) -> int: """SAP 10.2 §7.1 / Table 9 control type 1/2/3 from the `main_heating_control` code on `MainHeatingDetail`. Defaults to 2 @@ -1013,6 +1042,42 @@ def fabric_energy_efficiency_from_cert(epc: EpcPropertyData) -> Optional[float]: ) +def energy_requirements_section_from_cert( + epc: EpcPropertyData, +) -> Optional[EnergyRequirementsResult]: + """SAP 10.2 §9a cert→inputs cascade for `space_heating_fuel_monthly_kwh`. + + Composes §8 (98c)m + Table 11 secondary fraction + per-system + efficiencies into the (201)..(221) line refs. Single-main scope A + (no (203)/(207)/(213)/(209)/(221)). Returns None when TFA missing. + """ + if epc.total_floor_area_m2 is None: + return None + sh = space_heating_section_from_cert(epc) + assert sh is not None, "space_heating None despite TFA present" + main = _first_main_heating(epc) + main_code = main.sap_main_heating_code if main is not None else None + main_category = main.main_heating_category if main is not None else None + main_fuel = _main_fuel_code(main) + secondary_fraction_value = _secondary_fraction( + main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None + ) + # When no secondary system is lodged the worksheet displays (208) = 0; + # the per-system fuel formula already collapses to 0 via fraction_201 = 0 + # so this is presentation-only. + secondary_efficiency_value = ( + _secondary_efficiency(epc.sap_heating, main_code, main_fuel) + if secondary_fraction_value > 0.0 else 0.0 + ) + eff = _main_heating_efficiency(epc) + return space_heating_fuel_monthly_kwh( + space_heating_monthly_kwh=sh.total_space_heating_monthly_kwh, + secondary_heating_fraction=secondary_fraction_value, + main_heating_efficiency_pct=eff * 100.0, + secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, + ) + + def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult: """SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`. @@ -1450,18 +1515,10 @@ def cert_to_inputs( if main is not None and main.main_heating_index_number is not None else None ) - if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: - eff = pcdb_main.winter_efficiency_pct / 100.0 - else: - eff = seasonal_efficiency(main_code, main_category, main_fuel) - if _is_heat_network_main(main): - # SAP 10.2 Table 12 note (k): heat-network unit prices are per - # kWh of heat GENERATED (before distribution losses), not per - # kWh of fuel consumed. Setting efficiency = 1/DLF makes the - # calculator's `main_fuel_kwh = q_useful / (1/DLF) = q_useful - # × DLF = q_generated`, so cost = q_generated × unit_price as - # the spec requires. - eff = 1.0 / _heat_network_dlf(primary_age) + # Heat-network override (Table 12 note (k)) sets efficiency = 1/DLF so + # `main_fuel_kwh = q_useful × DLF = q_generated`, matching the spec's + # "unit prices per kWh of heat generated" convention. + eff = _main_heating_efficiency(epc) if pcdb_main is not None and pcdb_main.summer_efficiency_pct is not None: water_eff = pcdb_main.summer_efficiency_pct / 100.0 else: diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py index 710b059a..dc7a60c4 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py @@ -431,3 +431,22 @@ LINE_108_PER_M2_KWH: float = 0.0 # solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH; # (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly. LINE_109_FEE_KWH_PER_M2: float = 186.879 + +# ============================================================================ +# §9a Energy requirements — Individual heating systems +# ============================================================================ +LINE_201_SECONDARY_FRACTION: float = 0.0000 +LINE_202_MAIN_TOTAL_FRACTION: float = 1.0000 +LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.7000 +LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000 +LINE_208_SECONDARY_EFFICIENCY_PCT: float = 0.0000 +LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = ( + 2055.0528, 1734.1671, 1623.6845, 1157.0979, 750.5403, 0.0000, + 0.0000, 0.0000, 0.0000, 1027.8952, 1549.2667, 2067.1879, +) +LINE_211_ANNUAL_KWH: float = 11964.8924 +LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_213_ANNUAL_KWH: float = 0.0000 +LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_215_ANNUAL_KWH: float = 0.0000 +LINE_221_COOLING_FUEL_KWH: float = 0.0000 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py index 8cbd35de..fbe94efe 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py @@ -397,3 +397,25 @@ LINE_108_PER_M2_KWH: float = 0.0 # solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH; # (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly. LINE_109_FEE_KWH_PER_M2: float = 130.3326 + +# ============================================================================ +# §9a Energy requirements — Individual heating systems +# ============================================================================ +LINE_201_SECONDARY_FRACTION: float = 0.1000 +LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000 +LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.6000 +LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000 +LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000 +LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = ( + 1790.1174, 1504.3490, 1401.3456, 978.5134, 626.0756, 0.0000, + 0.0000, 0.0000, 0.0000, 865.2002, 1323.5731, 1781.7982, +) +LINE_211_ANNUAL_KWH: float = 10270.9726 +LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_213_ANNUAL_KWH: float = 0.0000 +LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = ( + 176.2271, 148.0948, 137.9547, 96.3292, 61.6337, 0.0000, + 0.0000, 0.0000, 0.0000, 85.1741, 130.2984, 175.4081, +) +LINE_215_ANNUAL_KWH: float = 1011.1202 +LINE_221_COOLING_FUEL_KWH: float = 0.0000 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py index f2f99d15..72a3bbf0 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py @@ -439,3 +439,25 @@ LINE_108_PER_M2_KWH: float = 0.0 # solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH; # (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly. LINE_109_FEE_KWH_PER_M2: float = 146.8852 + +# ============================================================================ +# §9a Energy requirements — Individual heating systems +# ============================================================================ +LINE_201_SECONDARY_FRACTION: float = 0.1000 +LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000 +LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.7000 +LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000 +LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000 +LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = ( + 2148.2772, 1820.4838, 1716.4018, 1232.1415, 804.0422, 0.0000, + 0.0000, 0.0000, 0.0000, 1082.1336, 1619.0882, 2157.7252, +) +LINE_211_ANNUAL_KWH: float = 12580.2936 +LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_213_ANNUAL_KWH: float = 0.0000 +LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = ( + 211.7247, 179.4188, 169.1609, 121.4344, 79.2428, 0.0000, + 0.0000, 0.0000, 0.0000, 106.6503, 159.5701, 212.6558, +) +LINE_215_ANNUAL_KWH: float = 1239.8578 +LINE_221_COOLING_FUEL_KWH: float = 0.0000 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py index fcacfd83..f3ba2699 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py @@ -462,3 +462,25 @@ LINE_108_PER_M2_KWH: float = 0.0 # solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH; # (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly. LINE_109_FEE_KWH_PER_M2: float = 132.828 + +# ============================================================================ +# §9a Energy requirements — Individual heating systems +# ============================================================================ +LINE_201_SECONDARY_FRACTION: float = 0.1000 +LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000 +LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.5000 +LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000 +LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000 +LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = ( + 1895.6271, 1588.7007, 1496.1538, 1083.2060, 729.6397, 0.0000, + 0.0000, 0.0000, 0.0000, 920.5077, 1406.7319, 1897.8511, +) +LINE_211_ANNUAL_KWH: float = 11018.4181 +LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_213_ANNUAL_KWH: float = 0.0000 +LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = ( + 186.4033, 156.2222, 147.1218, 106.5153, 71.7479, 0.0000, + 0.0000, 0.0000, 0.0000, 90.5166, 138.3286, 186.6220, +) +LINE_215_ANNUAL_KWH: float = 1083.4778 +LINE_221_COOLING_FUEL_KWH: float = 0.0000 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py index 0a2730c0..97af0b59 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py @@ -413,3 +413,25 @@ LINE_108_PER_M2_KWH: float = 0.0 # solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH; # (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly. LINE_109_FEE_KWH_PER_M2: float = 169.2897 + +# ============================================================================ +# §9a Energy requirements — Individual heating systems +# ============================================================================ +LINE_201_SECONDARY_FRACTION: float = 0.1000 +LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000 +LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.2000 +LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000 +LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000 +LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = ( + 1954.5034, 1649.5170, 1552.7436, 1119.1388, 736.2528, 0.0000, + 0.0000, 0.0000, 0.0000, 968.1611, 1466.0411, 1965.1473, +) +LINE_211_ANNUAL_KWH: float = 11411.5052 +LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_213_ANNUAL_KWH: float = 0.0000 +LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = ( + 191.5413, 161.6527, 152.1689, 109.6756, 72.1528, 0.0000, + 0.0000, 0.0000, 0.0000, 94.8798, 143.6720, 192.5844, +) +LINE_215_ANNUAL_KWH: float = 1118.3275 +LINE_221_COOLING_FUEL_KWH: float = 0.0000 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py index 8b938a7c..de13ee88 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py @@ -439,3 +439,25 @@ LINE_108_PER_M2_KWH: float = 0.0 # solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH; # (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly. LINE_109_FEE_KWH_PER_M2: float = 137.0700 + +# ============================================================================ +# §9a Energy requirements — Individual heating systems +# ============================================================================ +LINE_201_SECONDARY_FRACTION: float = 0.1000 +LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000 +LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.6000 +LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000 +LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000 +LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = ( + 2164.7554, 1825.9838, 1716.8828, 1226.9655, 802.7014, 0.0000, + 0.0000, 0.0000, 0.0000, 1078.7200, 1622.3099, 2168.0981, +) +LINE_211_ANNUAL_KWH: float = 12606.4169 +LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12 +LINE_213_ANNUAL_KWH: float = 0.0000 +LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = ( + 213.1081, 179.7580, 169.0176, 120.7879, 79.0215, 0.0000, + 0.0000, 0.0000, 0.0000, 106.1940, 159.7074, 213.4372, +) +LINE_215_ANNUAL_KWH: float = 1241.0317 +LINE_221_COOLING_FUEL_KWH: float = 0.0000 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 2a6d1f07..624c9aa2 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 @@ -18,6 +18,7 @@ import pytest from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, + energy_requirements_section_from_cert, fabric_energy_efficiency_from_cert, heat_transmission_section_from_cert, internal_gains_section_from_cert, @@ -699,3 +700,83 @@ def test_section_8f_line_109_fee_matches_pdf(fixture_name: str) -> None: # Assert assert actual is not None, f"{fixture_name}: fee_from_cert returned None" _pin(actual, expected, f"§8f LINE_109_FEE_KWH_PER_M2 {fixture_name}") + + +# ============================================================================ +# §9a Energy requirements — LINE_201..LINE_221 +# ============================================================================ + +_SECTION_9A_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_211_M_MAIN_1_FUEL_KWH", "main_1_fuel_monthly_kwh"), + ("LINE_213_M_MAIN_2_FUEL_KWH", "main_2_fuel_monthly_kwh"), + ("LINE_215_M_SECONDARY_FUEL_KWH", "secondary_fuel_monthly_kwh"), +) + +_SECTION_9A_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_201_SECONDARY_FRACTION", "secondary_heating_fraction"), + ("LINE_202_MAIN_TOTAL_FRACTION", "main_heating_total_fraction"), + ("LINE_206_MAIN_1_EFFICIENCY_PCT", "main_1_efficiency_pct"), + ("LINE_207_MAIN_2_EFFICIENCY_PCT", "main_2_efficiency_pct"), + ("LINE_208_SECONDARY_EFFICIENCY_PCT", "secondary_efficiency_pct"), + ("LINE_211_ANNUAL_KWH", "main_1_fuel_kwh_per_yr"), + ("LINE_213_ANNUAL_KWH", "main_2_fuel_kwh_per_yr"), + ("LINE_215_ANNUAL_KWH", "secondary_fuel_kwh_per_yr"), + ("LINE_221_COOLING_FUEL_KWH", "cooling_fuel_kwh_per_yr"), +) + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_9A_MONTHLY_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_9a_monthly_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§9a monthly pins — (211)m main 1 fuel, (213)m main 2 fuel, (215)m + secondary fuel match the U985 PDF to abs=1e-4 for every Jan..Dec.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + er = energy_requirements_section_from_cert(epc) + assert er is not None, f"{fixture_name}: energy_req_from_cert returned None" + actual = getattr(er, result_attr) + + # Assert + for m in range(12): + _pin(actual[m], expected[m], f"§9a {fixture_attr}[{m+1}] {fixture_name}") + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_9A_SCALAR_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_9a_scalar_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§9a scalar pins — Table 11 fractions + per-system efficiencies + + annual fuel-kWh totals + cooling fuel match 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 + er = energy_requirements_section_from_cert(epc) + assert er is not None, f"{fixture_name}: energy_req_from_cert returned None" + actual = getattr(er, result_attr) + + # Assert + _pin(actual, expected, f"§9a {fixture_attr} {fixture_name}")