mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Migration of the SAP 10.2 calculator package from the uv-workspace
src-layout (`packages/domain/src/domain/sap`) to the root-level layout
(`domain/sap10_calculator`), matching the pattern already used by
`domain.addresses` / `domain.tasks` / `domain.postcode`.
Changes:
- `git mv packages/domain/src/domain/sap → domain/sap10_calculator`
(92 files; git auto-detected all as renames so blame/history is
preserved).
- Subpackage rename: `domain.sap` → `domain.sap10_calculator`. 48
Python files rewritten (`from domain.sap.X` → `from domain.sap10_
calculator.X`); zero remaining `domain.sap` refs after the sed pass.
- Path-string updates: 3 .py files (test fixtures + xlsx loader) +
6 markdown docs (CONTEXT.md, 2 ADRs, 3 sap-spec docs, sap10_
calculator/README.md) had hard-coded `packages/domain/src/domain/
sap/...` paths rewritten to `domain/sap10_calculator/...`.
- `Path(__file__).parents[N]` rebasing: the old tree was 3 levels
deeper than the new one (`packages/domain/src/`), so 4× `parents[7]`
became `parents[4]` and 1× `parents[6]` became `parents[3]` across
`tables/pcdb/{__init__.py, postcode_weather.py, etl.py}`,
`worksheet/tests/_xlsx_loader.py`, and `tests/test_pcdb_etl.py`.
- PEP 420 namespace package: deleted both `domain/__init__.py`
(root + workspace, both load-bearing only as empty/docstring) so
Python combines `domain.sap10_calculator` (root) and `domain.ml`
(workspace) into one namespace package. Confirmed via
`domain.__path__ == ['/workspaces/model/domain',
'/workspaces/model/packages/domain/src/domain']`. Without this,
the root `domain/__init__.py` shadowed the workspace one and
`domain.ml` was unreachable.
Verified:
- Full sweep (`backend/documents_parser/tests/test_summary_pdf_
mapper_chain.py + domain/sap10_calculator/worksheet/tests/test_
e2e_elmhurst_sap_score.py + domain/sap10_calculator/rdsap/tests/
test_golden_fixtures.py`): 99 passed / 19 failed — exact same
counts as pre-refactor. All 19 failures pre-existing (9 hand-built
001479 + 6 cohort diff + 4 cohort chain non-spec).
- Wider sweep (all sap10_calculator + domain.ml): 1654 passed /
20 failed (the +1 vs the focused sweep is the pre-existing
`test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_
section_5_11_4` which was already failing on the previous baseline).
- Pyright net-zero on the three load-bearing baselines:
`heat_transmission.py` 13, `cert_to_inputs.py` 35, `mapper.py` 33.
Lift-and-shift only — no semantic renames (`Sap10Calculator` stays
`Sap10Calculator`), no testpaths edits in pytest.ini (sap tests
continue to be invoked by explicit pytest paths).
Note: `domain.ml` still lives at `packages/domain/src/domain/ml/`.
Migrating it would close out the dual-`domain/` layout but is
out of scope for this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
180 lines
9.9 KiB
Python
180 lines
9.9 KiB
Python
"""SAP 10.2 Appendix U — climate data lookups.
|
||
|
||
Source: BRE, *The Government's Standard Assessment Procedure for Energy
|
||
Rating of Dwellings, SAP 10.2* (14-03-2025), 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.2 region
|
||
names; lookup helpers raise `ValueError` on out-of-range inputs so callers
|
||
can fail fast.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Final
|
||
|
||
from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate
|
||
|
||
|
||
# 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_or_climate: "int | PostcodeClimate", month: int
|
||
) -> float:
|
||
"""Mean external temperature (°C) per month. Accepts either a SAP region
|
||
index (0..21) for the Appendix U fallback tables, or a `PostcodeClimate`
|
||
record for postcode-specific demand-cascade values from PCDB Table 172."""
|
||
if isinstance(region_or_climate, PostcodeClimate):
|
||
_validate_month(month)
|
||
return region_or_climate.monthly_external_temp_c[month - 1]
|
||
_validate(region_or_climate, month)
|
||
return _TABLE_U1[region_or_climate][month - 1]
|
||
|
||
|
||
def wind_speed_m_per_s(
|
||
region_or_climate: "int | PostcodeClimate", month: int
|
||
) -> float:
|
||
"""Mean wind speed (m/s) per month. Accepts either a SAP region index
|
||
(0..21) or a `PostcodeClimate` record."""
|
||
if isinstance(region_or_climate, PostcodeClimate):
|
||
_validate_month(month)
|
||
return region_or_climate.monthly_wind_speed_m_per_s[month - 1]
|
||
_validate(region_or_climate, month)
|
||
return _TABLE_U2[region_or_climate][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.2 §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_or_climate: "int | PostcodeClimate", month: int,
|
||
) -> float:
|
||
"""Mean global solar irradiance on a horizontal plane (W/m²). Accepts
|
||
either a SAP region index (0..21) or a `PostcodeClimate` record. The
|
||
starting point for the per-orientation surface-flux calculation in
|
||
SAP 10.2 §6.1."""
|
||
if isinstance(region_or_climate, PostcodeClimate):
|
||
_validate_month(month)
|
||
return region_or_climate.monthly_horizontal_solar_w_per_m2[month - 1]
|
||
_validate(region_or_climate, month)
|
||
return float(_TABLE_U3[region_or_climate][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.2 Appendix U
|
||
Table U3 footer — independent of region."""
|
||
_validate_month(month)
|
||
return _SOLAR_DECLINATION[month - 1]
|