From 26614816259ef49017154db625481240c9911706 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 21:43:09 +0000 Subject: [PATCH] slice S-A1: Appendix U climate tables (U1/U2/U3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/domain/src/domain/sap/__init__.py | 0 .../domain/src/domain/sap/climate/__init__.py | 0 .../src/domain/sap/climate/appendix_u.py | 159 ++++++++++++++++++ .../src/domain/sap/climate/tests/__init__.py | 0 .../sap/climate/tests/test_appendix_u.py | 148 ++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 packages/domain/src/domain/sap/__init__.py create mode 100644 packages/domain/src/domain/sap/climate/__init__.py create mode 100644 packages/domain/src/domain/sap/climate/appendix_u.py create mode 100644 packages/domain/src/domain/sap/climate/tests/__init__.py create mode 100644 packages/domain/src/domain/sap/climate/tests/test_appendix_u.py diff --git a/packages/domain/src/domain/sap/__init__.py b/packages/domain/src/domain/sap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/climate/__init__.py b/packages/domain/src/domain/sap/climate/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/climate/appendix_u.py b/packages/domain/src/domain/sap/climate/appendix_u.py new file mode 100644 index 00000000..4811c081 --- /dev/null +++ b/packages/domain/src/domain/sap/climate/appendix_u.py @@ -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] diff --git a/packages/domain/src/domain/sap/climate/tests/__init__.py b/packages/domain/src/domain/sap/climate/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/climate/tests/test_appendix_u.py b/packages/domain/src/domain/sap/climate/tests/test_appendix_u.py new file mode 100644 index 00000000..aba5a8a9 --- /dev/null +++ b/packages/domain/src/domain/sap/climate/tests/test_appendix_u.py @@ -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