mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice S-A1: Appendix U climate tables (U1/U2/U3)
First slice of the SAP10 Calculator Session A (ADR-0009). Ships the three SAP 10.3 Appendix U monthly tables across 22 climate regions (region 0 = UK average; 1-21 named per spec) as a pure-data module under the new domain/sap/ package: - Table U1: mean external temperature (°C) - Table U2: wind speed (m/s) - Table U3: mean global solar irradiance on horizontal plane (W/m²) - Table U3 footer: monthly solar declination (°, region-independent) Lookups validate region (0..21) and month (1..12) and raise ValueError on out-of-range inputs. 11 AAA tests cover happy-path lookups across multiple regions/months plus boundary and error cases.
This commit is contained in:
parent
8dbe873daf
commit
2661481625
5 changed files with 307 additions and 0 deletions
0
packages/domain/src/domain/sap/__init__.py
Normal file
0
packages/domain/src/domain/sap/__init__.py
Normal file
0
packages/domain/src/domain/sap/climate/__init__.py
Normal file
0
packages/domain/src/domain/sap/climate/__init__.py
Normal file
159
packages/domain/src/domain/sap/climate/appendix_u.py
Normal file
159
packages/domain/src/domain/sap/climate/appendix_u.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""SAP 10.3 Appendix U — climate data lookups.
|
||||
|
||||
Source: BRE, *The Government's Standard Assessment Procedure for Energy
|
||||
Rating of Dwellings, SAP 10.3* (13-01-2026), Appendix U.
|
||||
|
||||
Three monthly tables across 22 SAP climate regions (index 0 = UK average,
|
||||
1-21 = named regions per Table U6 postcode mapping):
|
||||
|
||||
- Table U1: Mean external temperature (°C)
|
||||
- Table U2: Wind speed (m/s)
|
||||
- Table U3: Mean global solar irradiance on a horizontal plane (W/m²)
|
||||
plus monthly solar declination (°)
|
||||
|
||||
Month is 1-12 (January = 1). Region indices map to the SAP 10.3 region
|
||||
names; lookup helpers raise `ValueError` on out-of-range inputs so callers
|
||||
can fail fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
# Table U1 — Mean external temperature (°C), 22 regions × 12 months.
|
||||
# Row order: region 0 (UK average) first, then regions 1-21 in spec order.
|
||||
_TABLE_U1: Final[tuple[tuple[float, ...], ...]] = (
|
||||
(4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2), # 0 UK average
|
||||
(5.1, 5.6, 7.4, 9.9, 13.0, 16.0, 17.9, 17.8, 15.2, 11.6, 8.0, 5.1), # 1 Thames
|
||||
(5.0, 5.4, 7.1, 9.5, 12.6, 15.4, 17.4, 17.5, 15.0, 11.7, 8.1, 5.2), # 2 South East England
|
||||
(5.4, 5.7, 7.3, 9.6, 12.6, 15.4, 17.3, 17.3, 15.0, 11.8, 8.4, 5.5), # 3 Southern England
|
||||
(6.1, 6.4, 7.5, 9.3, 11.9, 14.5, 16.2, 16.3, 14.6, 11.8, 9.0, 6.4), # 4 South West England
|
||||
(4.9, 5.3, 7.0, 9.3, 12.2, 15.0, 16.7, 16.7, 14.4, 11.1, 7.8, 4.9), # 5 Severn Wales / Severn England
|
||||
(4.3, 4.8, 6.6, 9.0, 11.8, 14.8, 16.6, 16.5, 14.0, 10.5, 7.1, 4.2), # 6 Midlands
|
||||
(4.7, 5.2, 6.7, 9.1, 12.0, 14.7, 16.4, 16.3, 14.1, 10.7, 7.5, 4.6), # 7 West Pennines Wales / West Pennines England
|
||||
(3.9, 4.3, 5.6, 7.9, 10.7, 13.2, 14.9, 14.8, 12.8, 9.7, 6.6, 3.7), # 8 North West England / South West Scotland
|
||||
(4.0, 4.5, 5.8, 7.9, 10.4, 13.3, 15.2, 15.1, 13.1, 9.7, 6.6, 3.7), # 9 Borders Scotland / Borders England
|
||||
(4.0, 4.6, 6.1, 8.3, 10.9, 13.8, 15.8, 15.6, 13.5, 10.1, 6.7, 3.8), # 10 North East England
|
||||
(4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2), # 11 East Pennines
|
||||
(4.7, 5.2, 7.0, 9.5, 12.5, 15.4, 17.6, 17.6, 15.0, 11.4, 7.7, 4.7), # 12 East Anglia
|
||||
(5.0, 5.3, 6.5, 8.3, 11.2, 13.7, 15.3, 15.3, 13.5, 10.7, 7.8, 5.2), # 13 Wales
|
||||
(4.0, 4.4, 5.6, 7.9, 10.4, 13.0, 14.5, 14.4, 12.5, 9.3, 6.5, 3.8), # 14 West Scotland
|
||||
(3.6, 4.0, 5.4, 7.7, 10.1, 12.9, 14.6, 14.5, 12.5, 9.2, 6.1, 3.2), # 15 East Scotland
|
||||
(3.3, 3.6, 5.0, 7.1, 9.3, 12.2, 14.0, 13.9, 12.0, 8.8, 5.7, 2.9), # 16 North East Scotland
|
||||
(3.1, 3.2, 4.4, 6.6, 8.9, 11.4, 13.2, 13.1, 11.3, 8.2, 5.4, 2.7), # 17 Highland
|
||||
(5.2, 5.0, 5.8, 7.6, 9.7, 11.8, 13.4, 13.6, 12.1, 9.6, 7.3, 5.2), # 18 Western Isles
|
||||
(4.4, 4.2, 5.0, 7.0, 8.9, 11.2, 13.1, 13.2, 11.7, 9.1, 6.6, 4.3), # 19 Orkney
|
||||
(4.6, 4.1, 4.7, 6.5, 8.3, 10.5, 12.4, 12.8, 11.4, 8.8, 6.5, 4.6), # 20 Shetland
|
||||
(4.8, 5.2, 6.4, 8.4, 10.9, 13.5, 15.0, 14.9, 13.1, 10.0, 7.2, 4.7), # 21 Northern Ireland
|
||||
)
|
||||
|
||||
|
||||
# Table U2 — Wind speed (m/s), 22 regions × 12 months.
|
||||
_TABLE_U2: Final[tuple[tuple[float, ...], ...]] = (
|
||||
(5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7), # 0 UK average
|
||||
(4.2, 4.0, 4.0, 3.7, 3.7, 3.3, 3.4, 3.2, 3.3, 3.5, 3.5, 3.8), # 1 Thames
|
||||
(4.8, 4.5, 4.4, 3.9, 3.9, 3.6, 3.7, 3.5, 3.7, 4.0, 4.1, 4.4), # 2 South East England
|
||||
(5.1, 4.7, 4.6, 4.3, 4.3, 4.0, 4.0, 3.9, 4.0, 4.5, 4.4, 4.7), # 3 Southern England
|
||||
(6.0, 5.6, 5.6, 5.0, 5.0, 4.4, 4.4, 4.3, 4.7, 5.4, 5.5, 5.9), # 4 South West England
|
||||
(4.9, 4.6, 4.7, 4.3, 4.3, 3.8, 3.8, 3.7, 3.8, 4.3, 4.3, 4.6), # 5 Severn Wales / Severn England
|
||||
(4.5, 4.5, 4.4, 3.9, 3.8, 3.4, 3.3, 3.3, 3.5, 3.8, 3.9, 4.1), # 6 Midlands
|
||||
(4.8, 4.7, 4.6, 4.2, 4.1, 3.7, 3.7, 3.7, 3.7, 4.2, 4.3, 4.5), # 7 West Pennines Wales / West Pennines England
|
||||
(5.2, 5.2, 5.0, 4.4, 4.3, 3.9, 3.7, 3.7, 4.1, 4.6, 4.8, 4.7), # 8 North West England / South West Scotland
|
||||
(5.2, 5.2, 5.0, 4.4, 4.1, 3.8, 3.5, 3.5, 3.9, 4.2, 4.6, 4.7), # 9 Borders Scotland / Borders England
|
||||
(5.3, 5.2, 5.0, 4.3, 4.2, 3.9, 3.6, 3.6, 4.1, 4.3, 4.6, 4.8), # 10 North East England
|
||||
(5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7), # 11 East Pennines
|
||||
(4.9, 4.8, 4.7, 4.2, 4.2, 3.7, 3.8, 3.8, 4.0, 4.2, 4.3, 4.5), # 12 East Anglia
|
||||
(6.5, 6.2, 5.9, 5.2, 5.1, 4.7, 4.5, 4.5, 5.0, 5.7, 6.0, 6.0), # 13 Wales
|
||||
(6.2, 6.2, 5.9, 5.2, 4.9, 4.7, 4.3, 4.3, 4.9, 5.4, 5.7, 5.4), # 14 West Scotland
|
||||
(5.7, 5.8, 5.7, 5.0, 4.8, 4.6, 4.1, 4.1, 4.7, 5.0, 5.2, 5.0), # 15 East Scotland
|
||||
(5.7, 5.8, 5.7, 5.0, 4.6, 4.4, 4.0, 4.1, 4.6, 5.2, 5.3, 5.1), # 16 North East Scotland
|
||||
(6.5, 6.8, 6.4, 5.7, 5.1, 5.1, 4.6, 4.5, 5.3, 5.8, 6.1, 5.7), # 17 Highland
|
||||
(8.3, 8.4, 7.9, 6.6, 6.1, 5.6, 5.6, 5.6, 6.3, 7.3, 7.7, 7.5), # 18 Western Isles
|
||||
(7.9, 8.3, 7.9, 7.1, 6.2, 6.1, 5.5, 5.6, 6.4, 7.3, 7.8, 7.3), # 19 Orkney
|
||||
(9.5, 9.4, 8.7, 7.5, 6.6, 6.4, 5.7, 6.0, 7.2, 8.5, 8.9, 8.5), # 20 Shetland
|
||||
(5.4, 5.3, 5.0, 4.7, 4.5, 4.1, 3.9, 3.7, 4.2, 4.6, 5.0, 5.0), # 21 Northern Ireland
|
||||
)
|
||||
|
||||
|
||||
_REGION_COUNT: Final[int] = 22
|
||||
_MONTHS_PER_YEAR: Final[int] = 12
|
||||
|
||||
|
||||
def _validate_month(month: int) -> None:
|
||||
if not 1 <= month <= _MONTHS_PER_YEAR:
|
||||
raise ValueError(f"month must be 1..12 (January = 1), got {month}")
|
||||
|
||||
|
||||
def _validate(region: int, month: int) -> None:
|
||||
if not 0 <= region < _REGION_COUNT:
|
||||
raise ValueError(
|
||||
f"region must be 0..{_REGION_COUNT - 1} (SAP climate region; "
|
||||
f"0 = UK average), got {region}"
|
||||
)
|
||||
_validate_month(month)
|
||||
|
||||
|
||||
def external_temperature_c(region: int, month: int) -> float:
|
||||
"""Mean external temperature (°C) for a SAP climate region in a month."""
|
||||
_validate(region, month)
|
||||
return _TABLE_U1[region][month - 1]
|
||||
|
||||
|
||||
def wind_speed_m_per_s(region: int, month: int) -> float:
|
||||
"""Mean wind speed (m/s) for a SAP climate region in a month."""
|
||||
_validate(region, month)
|
||||
return _TABLE_U2[region][month - 1]
|
||||
|
||||
|
||||
# Table U3 — Mean global solar irradiance on a horizontal plane (W/m²),
|
||||
# 22 regions × 12 months. Used (with Table U3 declination + per-window
|
||||
# orientation/pitch) to derive surface flux for solar-gains calculation
|
||||
# (SAP 10.3 §6.1).
|
||||
_TABLE_U3: Final[tuple[tuple[float, ...], ...]] = (
|
||||
(26, 54, 96, 150, 192, 200, 189, 157, 115, 66, 33, 21), # 0 UK average
|
||||
(30, 56, 98, 157, 195, 217, 203, 173, 127, 73, 39, 24), # 1 Thames
|
||||
(32, 59, 104, 170, 208, 231, 216, 182, 133, 77, 41, 25), # 2 South East England
|
||||
(35, 62, 109, 172, 209, 235, 217, 185, 138, 80, 44, 27), # 3 Southern England
|
||||
(36, 63, 111, 174, 210, 233, 204, 182, 136, 78, 44, 28), # 4 South West England
|
||||
(32, 59, 105, 167, 201, 226, 206, 175, 130, 74, 40, 25), # 5 Severn Wales / Severn England
|
||||
(28, 55, 97, 153, 191, 208, 194, 163, 121, 69, 35, 23), # 6 Midlands
|
||||
(24, 51, 95, 152, 191, 203, 186, 152, 115, 65, 31, 20), # 7 West Pennines Wales / West Pennines England
|
||||
(23, 51, 95, 157, 200, 203, 194, 156, 113, 62, 30, 19), # 8 North West England / South West Scotland
|
||||
(23, 50, 92, 151, 200, 196, 187, 153, 111, 61, 30, 18), # 9 Borders Scotland / Borders England
|
||||
(25, 51, 95, 152, 196, 198, 190, 156, 115, 64, 32, 20), # 10 North East England
|
||||
(26, 54, 96, 150, 192, 200, 189, 157, 115, 66, 33, 21), # 11 East Pennines
|
||||
(30, 58, 101, 165, 203, 220, 206, 173, 128, 74, 39, 24), # 12 East Anglia
|
||||
(29, 57, 104, 164, 205, 220, 199, 167, 120, 68, 35, 22), # 13 Wales
|
||||
(19, 46, 88, 148, 196, 193, 185, 150, 101, 55, 25, 15), # 14 West Scotland
|
||||
(21, 46, 89, 146, 198, 191, 183, 150, 106, 57, 27, 15), # 15 East Scotland
|
||||
(19, 45, 89, 143, 194, 188, 177, 144, 101, 54, 25, 14), # 16 North East Scotland
|
||||
(17, 43, 85, 145, 189, 185, 170, 139, 98, 51, 22, 12), # 17 Highland
|
||||
(16, 41, 87, 155, 205, 206, 185, 148, 101, 51, 21, 11), # 18 Western Isles
|
||||
(14, 39, 84, 143, 205, 201, 178, 145, 100, 50, 19, 9), # 19 Orkney
|
||||
(12, 34, 79, 135, 196, 190, 168, 144, 90, 46, 16, 7), # 20 Shetland
|
||||
(24, 52, 96, 155, 201, 198, 183, 150, 107, 61, 30, 18), # 21 Northern Ireland
|
||||
)
|
||||
|
||||
|
||||
def horizontal_solar_irradiance_w_per_m2(region: int, month: int) -> float:
|
||||
"""Mean global solar irradiance on a horizontal plane (W/m²) for a SAP
|
||||
climate region in a month. The starting point for the per-orientation
|
||||
surface-flux calculation in SAP 10.3 §6.1."""
|
||||
_validate(region, month)
|
||||
return float(_TABLE_U3[region][month - 1])
|
||||
|
||||
|
||||
# Table U3 footer — Solar declination (°), region-independent (function of
|
||||
# month only). Used together with site latitude and the surface tilt to
|
||||
# convert horizontal irradiance to per-orientation surface flux.
|
||||
_SOLAR_DECLINATION: Final[tuple[float, ...]] = (
|
||||
-20.7, -12.8, -1.8, 9.8, 18.8, 23.1, 21.2, 13.7, 2.9, -8.7, -18.4, -23.0,
|
||||
)
|
||||
|
||||
|
||||
def solar_declination_deg(month: int) -> float:
|
||||
"""Solar declination angle (°) for the given month. SAP 10.3 Appendix U
|
||||
Table U3 footer — independent of region."""
|
||||
_validate_month(month)
|
||||
return _SOLAR_DECLINATION[month - 1]
|
||||
0
packages/domain/src/domain/sap/climate/tests/__init__.py
Normal file
0
packages/domain/src/domain/sap/climate/tests/__init__.py
Normal file
148
packages/domain/src/domain/sap/climate/tests/test_appendix_u.py
Normal file
148
packages/domain/src/domain/sap/climate/tests/test_appendix_u.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Tests for SAP 10.3 Appendix U climate-data lookups.
|
||||
|
||||
Reference: SAP 10.3 specification (DESNZ/BRE, 13-01-2026), Appendix U:
|
||||
Table U1 mean external temperature, Table U2 wind speed, Table U3 mean
|
||||
global solar irradiance on a horizontal plane and monthly solar declination.
|
||||
22 regions (0 = UK average, 1-21 = SAP climate regions) by 12 months.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap.climate.appendix_u import (
|
||||
external_temperature_c,
|
||||
horizontal_solar_irradiance_w_per_m2,
|
||||
solar_declination_deg,
|
||||
wind_speed_m_per_s,
|
||||
)
|
||||
|
||||
|
||||
def test_external_temperature_uk_average_january_returns_table_u1_value() -> None:
|
||||
# Arrange — SAP 10.3 Appendix U Table U1: Region 0 (UK average), January.
|
||||
|
||||
# Act
|
||||
result = external_temperature_c(region=0, month=1)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(4.3, abs=0.05)
|
||||
|
||||
|
||||
def test_external_temperature_thames_july_returns_named_region_value() -> None:
|
||||
# Arrange — Table U1: Region 1 (Thames), July. Hotter than the UK average
|
||||
# in summer — sanity check that named regions diverge from region 0.
|
||||
|
||||
# Act
|
||||
result = external_temperature_c(region=1, month=7)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(17.9, abs=0.05)
|
||||
|
||||
|
||||
def test_wind_speed_uk_average_january_returns_table_u2_value() -> None:
|
||||
# Arrange — Table U2 row 0 (UK average) column Jan -> 5.1 m/s. Used by the
|
||||
# SAP infiltration calc (worksheet lines 9-16).
|
||||
|
||||
# Act
|
||||
result = wind_speed_m_per_s(region=0, month=1)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(5.1, abs=0.05)
|
||||
|
||||
|
||||
def test_horizontal_solar_irradiance_uk_average_july_returns_table_u3_value() -> None:
|
||||
# Arrange — Table U3 row 0 (UK average) column Jul -> 189 W/m². Peak month
|
||||
# for global horizontal irradiance in the UK.
|
||||
|
||||
# Act
|
||||
result = horizontal_solar_irradiance_w_per_m2(region=0, month=7)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(189.0, abs=0.5)
|
||||
|
||||
|
||||
def test_horizontal_solar_irradiance_southern_england_brighter_than_shetland() -> None:
|
||||
# Arrange — Table U3 row 3 (Southern England) Jun -> 235, row 20 (Shetland)
|
||||
# Jun -> 190. Higher-latitude regions get less June irradiance.
|
||||
|
||||
# Act
|
||||
south = horizontal_solar_irradiance_w_per_m2(region=3, month=6)
|
||||
shetland = horizontal_solar_irradiance_w_per_m2(region=20, month=6)
|
||||
|
||||
# Assert
|
||||
assert south == pytest.approx(235.0, abs=0.5)
|
||||
assert shetland == pytest.approx(190.0, abs=0.5)
|
||||
assert south > shetland
|
||||
|
||||
|
||||
def test_solar_declination_winter_solstice_returns_table_u3_value() -> None:
|
||||
# Arrange — Table U3 footer "Solar declination" row: December = -23.0°.
|
||||
# Declination is region-independent (function only of month).
|
||||
|
||||
# Act
|
||||
result = solar_declination_deg(month=12)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(-23.0, abs=0.05)
|
||||
|
||||
|
||||
def test_solar_declination_summer_solstice_positive_value() -> None:
|
||||
# Arrange — Table U3 footer: June declination = +23.1°.
|
||||
|
||||
# Act
|
||||
result = solar_declination_deg(month=6)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(23.1, abs=0.05)
|
||||
|
||||
|
||||
def test_external_temperature_out_of_range_region_raises_value_error() -> None:
|
||||
# Arrange — there are 22 regions (0-21); 22 is the first invalid index.
|
||||
# The callers (postcode resolver in particular) should fail fast on a
|
||||
# bad region rather than silently aliasing to row 0 or wrapping around.
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="region"):
|
||||
external_temperature_c(region=22, month=1)
|
||||
with pytest.raises(ValueError, match="region"):
|
||||
external_temperature_c(region=-1, month=1)
|
||||
|
||||
|
||||
def test_region_21_northern_ireland_returns_table_u1_value() -> None:
|
||||
# Arrange — region 21 (Northern Ireland) is the last valid region. Catches
|
||||
# off-by-one errors in the region-bound check (would otherwise reject 21).
|
||||
# Table U1 row 21 July -> 15.0 °C.
|
||||
|
||||
# Act
|
||||
result = external_temperature_c(region=21, month=7)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(15.0, abs=0.05)
|
||||
|
||||
|
||||
def test_out_of_range_month_raises_value_error_on_every_lookup() -> None:
|
||||
# Arrange — months are 1..12. Month 0 and month 13 must reject across
|
||||
# all four climate lookups, including the region-independent declination.
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
external_temperature_c(region=0, month=0)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
wind_speed_m_per_s(region=0, month=13)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
horizontal_solar_irradiance_w_per_m2(region=0, month=0)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
solar_declination_deg(month=13)
|
||||
|
||||
|
||||
def test_wind_speed_shetland_january_higher_than_thames() -> None:
|
||||
# Arrange — Table U2 row 20 (Shetland), the windiest UK region by a wide
|
||||
# margin: 9.5 m/s in January vs Thames 4.2 m/s. Sanity check the table is
|
||||
# populated for the upper region indices, not silently aliasing to row 0.
|
||||
|
||||
# Act
|
||||
shetland = wind_speed_m_per_s(region=20, month=1)
|
||||
thames = wind_speed_m_per_s(region=1, month=1)
|
||||
|
||||
# Assert
|
||||
assert shetland == pytest.approx(9.5, abs=0.05)
|
||||
assert thames == pytest.approx(4.2, abs=0.05)
|
||||
assert shetland > thames
|
||||
Loading…
Add table
Reference in a new issue