§8f slice 1: fabric_energy_efficiency_kwh_per_m2_yr + 6-fixture conformance + atomic wiring

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 08:12:45 +00:00
parent c9f15a2e0e
commit 43cc16bc65
12 changed files with 181 additions and 0 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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 . 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

View file

@ -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//yr space heating per (pre Appendix
H solar offset) plus space cooling per . §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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 heating per = 10 kWh/; adding (108) = 5 kWh/
cooling gives FEE = 15 kWh//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)