mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§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:
parent
c9f15a2e0e
commit
43cc16bc65
12 changed files with 181 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue