Model/domain/sap10_calculator/worksheet/dimensions.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

169 lines
6.6 KiB
Python
Raw 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 §1 — dwelling dimensions.
Builds the typed `Dimensions` aggregate that the rest of the worksheet
reads: total floor area, volume, gross/party wall areas, ground and top
floor areas, perimeter. Geometry is summed across every entry in
`epc.sap_building_parts` (main dwelling + every extension), so a cert with
N parts produces totals over all N. Room-in-roof contributes one
additional storey per part where present (RdSAP §1.8 + §3.9).
Reference: SAP 10.2 specification (14-03-2025), §1 (pages 10-12); for
existing dwellings see RdSAP 10 §3 (areas and dimensions).
Edge cases explicitly out of scope for the first slice (see ADR-0009
Session A scope): porches, conservatories, integral garages, basements
with non-fixed staircases.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
# Room-in-roof Simplified type 1 (true RR) storey height per RdSAP 10
# §3.9.1: assumed internal height 2.2 m (lower than 2.4 m to compensate
# for sloping parts) + 0.25 m floor structure between RR and storey
# below = 2.45 m. Simplified type 2 and Detailed assessment options are
# not yet handled — see TODO at the RR sum below.
_RR_SIMPLIFIED_STOREY_HEIGHT_M: Final[float] = 2.45
@dataclass(frozen=True)
class Dimensions:
"""SAP 10.2 §1 geometric inputs to the monthly heat-balance loop."""
total_floor_area_m2: float
volume_m3: float
storey_count: int
avg_storey_height_m: float
ground_floor_area_m2: float
ground_floor_perimeter_m: float
top_floor_area_m2: float
gross_wall_area_m2: float
party_wall_area_m2: float
def _part_storey_count(part: SapBuildingPart) -> int:
return len(part.sap_floor_dimensions)
def _part_avg_storey_height_m(part: SapBuildingPart) -> float:
weighted = 0.0
area = 0.0
for fd in part.sap_floor_dimensions:
fa = fd.total_floor_area_m2 or 0.0
weighted += fa * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
area += fa
return weighted / area if area > 0 else _DEFAULT_STOREY_HEIGHT_M
def _part_ground_floor(part: SapBuildingPart):
fds = part.sap_floor_dimensions
if not fds:
return None
return next((fd for fd in fds if fd.floor == 0), fds[0])
def _part_top_floor(part: SapBuildingPart):
fds = part.sap_floor_dimensions
if not fds:
return None
return max(fds, key=lambda fd: fd.floor if fd.floor is not None else 0)
def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions:
"""Build the `Dimensions` aggregate from an EpcPropertyData.
§1 (Overall dwelling dimensions) mirrors the SAP10.2 worksheet form:
each `SapFloorDimension` is one storey row (1x), (2x), (3x) where
(3x) = (1x) × (2x). Line (4) Total floor area = Σ (1x), line (5)
Dwelling volume = Σ (3x). When no storeys are present (site-notes
baseline edge case), totals fall back to the certificate's
top-level TFA × default height — defensive, not worksheet-faithful.
"""
parts = epc.sap_building_parts or []
# §1 worksheet accumulators — these directly map to lines (4) and (5).
sum_per_storey_area_m2 = 0.0 # Σ (1x)
sum_per_storey_volume_m3 = 0.0 # Σ (3x) = Σ (1x) × (2x)
# §2/§3 inputs (gross/party wall, perimeter, ground/top floor) — kept
# in this aggregate for now; carve-out is a follow-up.
ground_area = 0.0
ground_perim = 0.0
top_area = 0.0
gross_wall = 0.0
party_wall = 0.0
# SAP §2 (9) "ns" is dwelling height (tallest part), NOT Σ across parts —
# the (10) additional-infiltration adjustment otherwise inflates by 0.1
# per spurious storey. Track per-part counts and take the max below.
part_storey_counts: list[int] = []
for part in parts:
ground = _part_ground_floor(part)
top = _part_top_floor(part)
if ground is None or top is None:
continue
part_height = _part_avg_storey_height_m(part)
part_floor_count = _part_storey_count(part)
ground_area += ground.total_floor_area_m2 or 0.0
ground_perim += ground.heat_loss_perimeter_m or 0.0
top_area += top.total_floor_area_m2 or 0.0
# SAP §3 wall area: Σ (heat_loss_perimeter_i × height_i) across each
# storey of the part. Pre-fix `ground_perim × avg_height × count`
# over-counts upper storeys whenever they have a different
# perimeter (e.g. set-back top floor, Elmhurst 000474 Main).
for fd in part.sap_floor_dimensions:
fa = fd.total_floor_area_m2 or 0.0
fh = fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M
sum_per_storey_area_m2 += fa
sum_per_storey_volume_m3 += fa * fh
gross_wall += (fd.heat_loss_perimeter_m or 0.0) * fh
party_wall += (fd.party_wall_length_m or 0.0) * fh
# Room-in-roof: counts as one additional storey per RdSAP §1.8 +
# §3.9. Both failing certs in the golden suite are Simplified
# type 1 (gable lengths only), which RdSAP §3.9.1 says uses a
# fixed 2.45 m storey height. TODO: handle Simplified type 2
# (RR with continuous common walls outside RR boundaries,
# §3.9.2) and Detailed (actual measured dimensions, §3.10 +
# Figure 4) — neither path appears in current corpus, but
# downstream calcs will silently use 2.45 m if we hit one.
rir = part.sap_room_in_roof
rir_adds_storey = 0
if rir is not None and rir.floor_area > 0:
sum_per_storey_area_m2 += rir.floor_area
sum_per_storey_volume_m3 += (
rir.floor_area * _RR_SIMPLIFIED_STOREY_HEIGHT_M
)
rir_adds_storey = 1
part_storey_counts.append(part_floor_count + rir_adds_storey)
total_storey_count = max(part_storey_counts) if part_storey_counts else 0
has_storeys = sum_per_storey_area_m2 > 0
avg_height = (
sum_per_storey_volume_m3 / sum_per_storey_area_m2
if has_storeys
else _DEFAULT_STOREY_HEIGHT_M
)
return Dimensions(
total_floor_area_m2=(
sum_per_storey_area_m2 if has_storeys else epc.total_floor_area_m2
),
volume_m3=(
sum_per_storey_volume_m3
if has_storeys
else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M
),
storey_count=total_storey_count,
avg_storey_height_m=avg_height,
ground_floor_area_m2=ground_area,
ground_floor_perimeter_m=ground_perim,
top_floor_area_m2=top_area,
gross_wall_area_m2=gross_wall,
party_wall_area_m2=party_wall,
)