From 43cc16bc65199aaa13681c28d6aa80d4a8b177a5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 21 May 2026 08:12:45 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A78f=20slice=201:=20fabric=5Fenergy=5Feffic?= =?UTF-8?q?iency=5Fkwh=5Fper=5Fm2=5Fyr=20+=206-fixture=20conformance=20+?= =?UTF-8?q?=20atomic=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec line 7898: (109) = (98a) ÷ (4) + (108). New `worksheet/fabric_energy_efficiency.py` exposes a free function (no dataclass — single scalar output); `SpaceHeatingResult.space_heating_requirement_kwh_per_yr` (Σ(98a)) added so the spec literal — pre Appendix H solar offset — is the FEE input, not Σ(98c). cert_to_inputs computes FEE from local SpaceHeatingResult + SpaceCoolingResult and passes via new `CalculatorInputs.fabric_energy_efficiency_kwh_per_m2_yr` (default 0.0 for backwards compat); calculator pass-through to `SapResult.fabric_energy_efficiency_kwh_per_m2_yr`. MonthlyEntry untouched — FEE has no per-month physics, only an annual scalar. Six Elmhurst fixtures all (98b)=0 + (108)=0 → LINE_109 = LINE_99 exactly; ALL_FIXTURES asserts within 5e-3 tolerance (display-rounding floor inherited from LINE_98C_ANNUAL_KWH pins). Round-trip test asserts SapResult.fee equals space_heating_kwh_per_yr / TFA for the SAP10 minimal cert. §11 compliance conditions (different ventilation / HW / lighting / gains column) are deferred — the FEE here is computed off rating-conditions inputs as a transparency output. Future §11 slice invokes the same function with §11-conditions upstream values. Co-Authored-By: Claude Opus 4.7 --- packages/domain/src/domain/sap/calculator.py | 7 +++ .../src/domain/sap/rdsap/cert_to_inputs.py | 15 +++++ .../sap/rdsap/tests/test_cert_to_inputs.py | 22 ++++++++ .../sap/worksheet/fabric_energy_efficiency.py | 30 ++++++++++ .../src/domain/sap/worksheet/space_heating.py | 3 + .../tests/_elmhurst_worksheet_000474.py | 8 +++ .../tests/_elmhurst_worksheet_000477.py | 8 +++ .../tests/_elmhurst_worksheet_000480.py | 8 +++ .../tests/_elmhurst_worksheet_000487.py | 8 +++ .../tests/_elmhurst_worksheet_000490.py | 8 +++ .../tests/_elmhurst_worksheet_000516.py | 8 +++ .../tests/test_fabric_energy_efficiency.py | 56 +++++++++++++++++++ 12 files changed, 181 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/fabric_energy_efficiency.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_fabric_energy_efficiency.py diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index b59a24ed..3b46b481 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -135,6 +135,11 @@ class CalculatorInputs: # compatibility — every cert without `has_fixed_air_conditioning` # collapses cooling to zero. space_cooling_monthly_kwh: tuple[float, ...] = (0.0,) * 12 + # SAP10.2 (109) — Fabric Energy Efficiency precomputed by cert_to_inputs + # via `fabric_energy_efficiency_kwh_per_m2_yr` from the §8/§8c results. + # Default 0.0 for backwards compatibility — synthetic CalculatorInputs + # constructions without cert_to_inputs leave it unset. + fabric_energy_efficiency_kwh_per_m2_yr: float = 0.0 @dataclass(frozen=True) @@ -167,6 +172,7 @@ class SapResult: co2_kg_per_yr: float space_heating_kwh_per_yr: float space_cooling_kwh_per_yr: float + fabric_energy_efficiency_kwh_per_m2_yr: float main_heating_fuel_kwh_per_yr: float secondary_heating_fuel_kwh_per_yr: float hot_water_kwh_per_yr: float @@ -392,6 +398,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: co2_kg_per_yr=co2, space_heating_kwh_per_yr=space_heating_kwh, space_cooling_kwh_per_yr=space_cooling_kwh, + fabric_energy_efficiency_kwh_per_m2_yr=inputs.fabric_energy_efficiency_kwh_per_m2_yr, main_heating_fuel_kwh_per_yr=main_fuel_kwh, secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, hot_water_kwh_per_yr=inputs.hot_water_kwh_per_yr, 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 677737c3..d390b404 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -72,6 +72,9 @@ from domain.sap.worksheet.mean_internal_temperature import ( mean_internal_temperature_monthly, ) from domain.sap.worksheet.solar_gains import solar_gains_from_cert +from domain.sap.worksheet.fabric_energy_efficiency import ( + fabric_energy_efficiency_kwh_per_m2_yr, +) from domain.sap.worksheet.space_cooling import space_cooling_monthly_kwh from domain.sap.worksheet.space_heating import space_heating_monthly_kwh from domain.sap.worksheet.ventilation import ( @@ -958,6 +961,16 @@ def cert_to_inputs( intermittency_factor=0.25, ) + # SAP10.2 (109) — Fabric Energy Efficiency. Spec literal: (98a) / (4) + + # (108). For corpora without Appendix H solar space heating, (98a) == (98c). + # §11 compliance would re-run with different conditions; transparency-only + # for ratings. + fee_kwh_per_m2 = fabric_energy_efficiency_kwh_per_m2_yr( + space_heating_kwh_per_yr=space_heating_result.space_heating_requirement_kwh_per_yr, + total_floor_area_m2=dim.total_floor_area_m2, + space_cooling_per_m2_kwh=space_cooling_result.space_cooling_per_m2_kwh, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -983,6 +996,8 @@ def cert_to_inputs( # SAP10.2 (107)m — space cooling kWh/month from §8c orchestrator # above (includes Jun-Aug inclusion mask + 1-kWh clamp). space_cooling_monthly_kwh=space_cooling_result.space_cooling_monthly_kwh, + # SAP10.2 (109) — Fabric Energy Efficiency precomputed above. + fabric_energy_efficiency_kwh_per_m2_yr=fee_kwh_per_m2, region=_region_index(epc.region_code), control_type=control_type_value, responsiveness=responsiveness_value, diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index f9bbf3a7..51ee0588 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -313,6 +313,28 @@ def test_no_ac_cert_round_trips_to_zero_space_cooling_on_sap_result() -> None: assert all(entry.space_cool_requirement_kwh == 0.0 for entry in result.monthly) +def test_no_ac_cert_round_trips_fee_equals_space_heating_per_m2() -> None: + """For an RdSAP cert without fixed AC, (108) = 0, so SAP 10.2 (109) Fabric + Energy Efficiency = (Σ(98a) / TFA) + 0 = annual space heating per m². No + Appendix H solar space heating means Σ(98a) == Σ(98c), so the FEE matches + `space_heating_kwh_per_yr / TFA` to float-equality.""" + # Arrange + epc = _typical_semi_detached_epc() + assert epc.sap_heating.has_fixed_air_conditioning is False + + # Act + result = Sap10Calculator().calculate(epc) + + # Assert + expected_fee = ( + result.space_heating_kwh_per_yr / result.intermediate["tfa_m2"] + ) + assert result.fabric_energy_efficiency_kwh_per_m2_yr == pytest.approx( + expected_fee, abs=1e-9 + ) + assert result.space_cooling_kwh_per_yr == 0.0 + + def test_calculator_always_uses_uk_average_weather_for_rating() -> None: # Arrange — SAP 10.2 Appendix U explicitly states: "Calculations for # ratings (SAP rating and environmental impact rating) are done with diff --git a/packages/domain/src/domain/sap/worksheet/fabric_energy_efficiency.py b/packages/domain/src/domain/sap/worksheet/fabric_energy_efficiency.py new file mode 100644 index 00000000..26cea891 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/fabric_energy_efficiency.py @@ -0,0 +1,30 @@ +"""SAP 10.2 §8f Fabric Energy Efficiency — line ref (109). + +Spec line 7898: (109) = (98a) ÷ (4) + (108). The FEE is the dwelling's +intrinsic fabric demand in kWh/m²/yr — space heating per m² (pre Appendix +H solar offset) plus space cooling per m². §11 of the spec says FEE is +"calculated only under special conditions" — new-build compliance — and +under those conditions the whole worksheet is re-run with different +ventilation / hot water / lighting / gains-column assumptions. For +existing-dwelling ratings we expose FEE as a transparency output computed +off rating-conditions (98a) and (108); a future §11 compliance slice +would invoke this function with §11-conditions upstream values. + +Reference: SAP 10.2 specification (14-03-2025) worksheet block §8f +(line 7898) and §11 (lines 2151-2164). +""" + +from __future__ import annotations + + +def fabric_energy_efficiency_kwh_per_m2_yr( + *, + space_heating_kwh_per_yr: float, + total_floor_area_m2: float, + space_cooling_per_m2_kwh: float, +) -> float: + """SAP 10.2 (109) = (98a) ÷ (4) + (108). Returns 0.0 if TFA ≤ 0 (matches + the §8 (99) / §8c (108) edge-case convention).""" + if total_floor_area_m2 <= 0: + return 0.0 + return space_heating_kwh_per_yr / total_floor_area_m2 + space_cooling_per_m2_kwh diff --git a/packages/domain/src/domain/sap/worksheet/space_heating.py b/packages/domain/src/domain/sap/worksheet/space_heating.py index fb3d1a59..2573aad2 100644 --- a/packages/domain/src/domain/sap/worksheet/space_heating.py +++ b/packages/domain/src/domain/sap/worksheet/space_heating.py @@ -66,6 +66,7 @@ class SpaceHeatingResult: space_heating_requirement_monthly_kwh: tuple[float, ...] # (98a) solar_space_heating_monthly_kwh: tuple[float, ...] # (98b) total_space_heating_monthly_kwh: tuple[float, ...] # (98c) + space_heating_requirement_kwh_per_yr: float # Σ(98a) — FEE input total_space_heating_kwh_per_yr: float # Σ(98c) space_heating_per_m2_kwh: float # (99) @@ -123,6 +124,7 @@ def space_heating_monthly_kwh( q_solar_98b.append(0.0) q_total_98c.append(q98a) + annual_98a = sum(q_heat_98a) annual_98c = sum(q_total_98c) per_m2_99 = annual_98c / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0 @@ -132,6 +134,7 @@ def space_heating_monthly_kwh( space_heating_requirement_monthly_kwh=tuple(q_heat_98a), solar_space_heating_monthly_kwh=tuple(q_solar_98b), total_space_heating_monthly_kwh=tuple(q_total_98c), + space_heating_requirement_kwh_per_yr=annual_98a, total_space_heating_kwh_per_yr=annual_98c, space_heating_per_m2_kwh=per_m2_99, ) 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 82115035..a9d2ee16 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 @@ -388,3 +388,11 @@ LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY LINE_107_ANNUAL_KWH: float = 0.0 LINE_108_PER_M2_KWH: float = 0.0 + +# ============================================================================ +# §8f Fabric Energy Efficiency (line ref (109)) +# ============================================================================ +# Spec line 7898: (109) = (98a) ÷ (4) + (108). For this fixture (98b) Appendix H +# 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 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 c4f969d6..1c1fafca 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 @@ -323,3 +323,11 @@ LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY LINE_107_ANNUAL_KWH: float = 0.0 LINE_108_PER_M2_KWH: float = 0.0 + +# ============================================================================ +# §8f Fabric Energy Efficiency (line ref (109)) +# ============================================================================ +# Spec line 7898: (109) = (98a) ÷ (4) + (108). For this fixture (98b) Appendix H +# 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 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 4253496c..75a73298 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 @@ -352,3 +352,11 @@ LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY LINE_107_ANNUAL_KWH: float = 0.0 LINE_108_PER_M2_KWH: float = 0.0 + +# ============================================================================ +# §8f Fabric Energy Efficiency (line ref (109)) +# ============================================================================ +# Spec line 7898: (109) = (98a) ÷ (4) + (108). For this fixture (98b) Appendix H +# 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 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 35899130..d7a7186f 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 @@ -369,3 +369,11 @@ LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY LINE_107_ANNUAL_KWH: float = 0.0 LINE_108_PER_M2_KWH: float = 0.0 + +# ============================================================================ +# §8f Fabric Energy Efficiency (line ref (109)) +# ============================================================================ +# Spec line 7898: (109) = (98a) ÷ (4) + (108). For this fixture (98b) Appendix H +# 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 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 ede28f76..462cac49 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 @@ -364,3 +364,11 @@ LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY LINE_107_ANNUAL_KWH: float = 0.0 LINE_108_PER_M2_KWH: float = 0.0 + +# ============================================================================ +# §8f Fabric Energy Efficiency (line ref (109)) +# ============================================================================ +# Spec line 7898: (109) = (98a) ÷ (4) + (108). For this fixture (98b) Appendix H +# 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 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 a098360a..d60d8e0e 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 @@ -336,3 +336,11 @@ LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY LINE_107_ANNUAL_KWH: float = 0.0 LINE_108_PER_M2_KWH: float = 0.0 + +# ============================================================================ +# §8f Fabric Energy Efficiency (line ref (109)) +# ============================================================================ +# Spec line 7898: (109) = (98a) ÷ (4) + (108). For this fixture (98b) Appendix H +# 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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_fabric_energy_efficiency.py b/packages/domain/src/domain/sap/worksheet/tests/test_fabric_energy_efficiency.py new file mode 100644 index 00000000..e1c959ed --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_fabric_energy_efficiency.py @@ -0,0 +1,56 @@ +"""Tests for SAP 10.2 §8f Fabric Energy Efficiency — line ref (109). + +Reference: SAP 10.2 specification (14-03-2025) worksheet block §8f +(line 7898): (109) = (98a) ÷ (4) + (108). +""" + +from __future__ import annotations + +from types import ModuleType + +import pytest + +from domain.sap.worksheet.fabric_energy_efficiency import ( + fabric_energy_efficiency_kwh_per_m2_yr, +) +from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id + + +def test_fabric_energy_efficiency_sums_heating_per_m2_and_cooling_per_m2() -> None: + """Spec line 7898: (109) = (98a) ÷ (4) + (108). With Σ(98a) = 1000 kWh + and TFA = 100 m² → heating per m² = 10 kWh/m²; adding (108) = 5 kWh/m² + cooling gives FEE = 15 kWh/m²/yr.""" + # Arrange + space_heating_kwh_per_yr = 1000.0 + total_floor_area_m2 = 100.0 + space_cooling_per_m2_kwh = 5.0 + + # Act + fee = fabric_energy_efficiency_kwh_per_m2_yr( + space_heating_kwh_per_yr=space_heating_kwh_per_yr, + total_floor_area_m2=total_floor_area_m2, + space_cooling_per_m2_kwh=space_cooling_per_m2_kwh, + ) + + # Assert + assert fee == 15.0 + + +@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES]) +def test_fabric_energy_efficiency_matches_elmhurst_worksheet_all_fixtures( + fixture: ModuleType, +) -> None: + """For Elmhurst fixtures (no AC, no Appendix H solar space heating), + (109) = LINE_99 + 0 = LINE_99 exactly. Σ(98a) = Σ(98c) per the (98b)=0 + invariant, so feeding LINE_98C_ANNUAL_KWH satisfies the spec's "(98a) + ÷ (4)" term.""" + # Arrange + # Act + fee = fabric_energy_efficiency_kwh_per_m2_yr( + space_heating_kwh_per_yr=fixture.LINE_98C_ANNUAL_KWH, + total_floor_area_m2=fixture.LINE_4_TFA_M2, + space_cooling_per_m2_kwh=fixture.LINE_108_PER_M2_KWH, + ) + + # Assert + assert fee == pytest.approx(fixture.LINE_109_FEE_KWH_PER_M2, abs=5e-3)