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>
148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
"""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.sap10_calculator.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
|