Model/domain/sap10_calculator/tests/test_postcode_weather.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

87 lines
2.6 KiB
Python

"""Tests for the PCDB Table 172 (postcode weather) lookup module.
The lookup parses pcdb10.dat at first use and caches it as a
`{(area, district): PostcodeClimate}` dict. Callers invoke
`postcode_climate(postcode_str)` to obtain the per-district monthly
weather (temp, wind, solar) used by the demand-side cascade for EPC
emissions / primary energy.
Reference: BRE PCDB pcdb10.dat Table 172 (Postcodes).
"""
from __future__ import annotations
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
PostcodeClimate,
postcode_climate,
)
def test_postcode_climate_returns_bd3_record() -> None:
"""Bradford district 3 (BD3) is the postcode for Elmhurst fixture 000474.
Verified against U985 Block 2 wind speed (5.2, 5.2, 5.0, ..., 4.9) which
is the EPC demand-cascade climate."""
# Arrange
# Act
climate = postcode_climate("bd3 8aq")
# Assert
assert climate is not None
assert climate.area == "BD"
assert climate.district == 3
assert climate.region == 11 # East Pennines
# Block 2 of U985-0001-000474.txt: Wind speed
# 5.2 5.2 5.0 4.4 4.3 3.9 4.0 3.8 4.1 4.4 4.6 4.9 (22)
assert climate.monthly_wind_speed_m_per_s == (
5.2, 5.2, 5.0, 4.4, 4.3, 3.9, 4.0, 3.8, 4.1, 4.4, 4.6, 4.9,
)
def test_postcode_climate_parses_mixed_case() -> None:
"""Postcode is normalised to upper-case so "bd3 8aq" and "BD3 8AQ" hit
the same record."""
# Arrange
lower = "bd4 7jr"
upper = "BD4 7JR"
# Act
a = postcode_climate(lower)
b = postcode_climate(upper)
# Assert
assert a is not None
assert b is not None
assert a == b
def test_postcode_climate_handles_two_digit_district() -> None:
"""Two-digit district numbers ("BD19") parse correctly — the digit
consumption walks past the alpha prefix and grabs all digits."""
# Arrange
# Act
climate = postcode_climate("bd19 3tf")
# Assert
assert climate is not None
assert climate.area == "BD"
assert climate.district == 19
def test_postcode_climate_returns_none_for_unknown_postcode() -> None:
"""Postcodes with no Table 172 entry (e.g. synthetic test data) yield
None so callers can fall back to UK-average climate."""
# Arrange
# Act
result = postcode_climate("ZZ99 9ZZ")
# Assert
assert result is None
def test_postcode_climate_returns_none_for_malformed() -> None:
"""Empty or letter-only postcodes return None rather than raising."""
# Arrange
# Act
# Assert
assert postcode_climate("") is None
assert postcode_climate(None) is None
assert postcode_climate("XYZ") is None