Model/domain/sap10_calculator/climate/appendix_u.py
Khalim Conn-Kowlessar 29ac35ccbe refactor: lift-and-shift packages/domain/src/domain/sap → domain/sap10_calculator
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>
2026-05-26 12:22:37 +00:00

180 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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]