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>
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
"""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,
|
||
)
|