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>
76 lines
2.8 KiB
Python
76 lines
2.8 KiB
Python
"""SAP 10.2 §13 Energy Cost Rating + §14 Environmental Impact Rating.
|
||
|
||
The Energy Cost Factor (ECF) blends total annual fuel cost with the
|
||
dwelling's floor area; the SAP rating maps ECF onto a 1-100+ scale that
|
||
rewards low cost per square metre. The EI rating is the same shape applied
|
||
to annual CO2 emissions.
|
||
|
||
Constants taken from SAP 10.2 Table 12 (page 191) per ADR-0010 (active
|
||
spec target is SAP 10.2, 14-03-2025):
|
||
- Energy Cost Deflator = 0.42
|
||
- Linear branch (ECF < 3.5): SAP = 100 − 13.95 × ECF
|
||
- Log branch (ECF ≥ 3.5): SAP = 117 − 121 × log10(ECF)
|
||
|
||
(SAP 10.3 widens these to 0.36 / 16.21 / 108.8 / 120.5 — apply when the
|
||
spec target moves per a follow-up ADR amendment.)
|
||
|
||
Reference: SAP 10.2 specification (14-03-2025) §13 + §14 (pages 38-39),
|
||
Table 12 (page 191).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from math import log10
|
||
from typing import Final
|
||
|
||
|
||
ENERGY_COST_DEFLATOR: Final[float] = 0.42
|
||
FLOOR_AREA_OFFSET_M2: Final[float] = 45.0
|
||
ECF_LOG_THRESHOLD: Final[float] = 3.5
|
||
_SAP_LINEAR_INTERCEPT: Final[float] = 100.0
|
||
_SAP_LINEAR_SLOPE: Final[float] = 13.95
|
||
_SAP_LOG_INTERCEPT: Final[float] = 117.0
|
||
_SAP_LOG_SLOPE: Final[float] = 121.0
|
||
_CF_LOG_THRESHOLD: Final[float] = 28.3
|
||
_EI_LINEAR_INTERCEPT: Final[float] = 100.0
|
||
_EI_LINEAR_SLOPE: Final[float] = 1.34
|
||
_EI_LOG_INTERCEPT: Final[float] = 200.0
|
||
_EI_LOG_SLOPE: Final[float] = 95.0
|
||
|
||
|
||
def energy_cost_factor(
|
||
*,
|
||
total_cost_gbp: float,
|
||
total_floor_area_m2: float,
|
||
) -> float:
|
||
"""SAP 10.2 §13 equation (7): ECF = 0.36 × cost / (TFA + 45)."""
|
||
return ENERGY_COST_DEFLATOR * total_cost_gbp / (total_floor_area_m2 + FLOOR_AREA_OFFSET_M2)
|
||
|
||
|
||
def sap_rating(*, ecf: float) -> float:
|
||
"""SAP 10.2 §13 equations (8)/(9). Un-rounded result so callers can
|
||
inspect the continuous value; `sap_rating_integer` rounds and clamps."""
|
||
if ecf >= ECF_LOG_THRESHOLD:
|
||
return _SAP_LOG_INTERCEPT - _SAP_LOG_SLOPE * log10(ecf)
|
||
return _SAP_LINEAR_INTERCEPT - _SAP_LINEAR_SLOPE * ecf
|
||
|
||
|
||
def sap_rating_integer(*, ecf: float) -> int:
|
||
"""SAP 10.2 §13: round the continuous SAP rating to the nearest integer
|
||
and clamp to a minimum of 1 ("if the result of the calculation is less
|
||
than 1 the rating should be quoted as 1"). The integer value is the
|
||
one published on the EPC."""
|
||
return max(1, round(sap_rating(ecf=ecf)))
|
||
|
||
|
||
def environmental_impact_rating(
|
||
*,
|
||
co2_emissions_kg_per_yr: float,
|
||
total_floor_area_m2: float,
|
||
) -> float:
|
||
"""SAP 10.2 §14 equations (10)-(12). Un-rounded EI rating; mirrors the
|
||
SAP rating curve but uses CO2 emissions per (TFA + 45) as the input."""
|
||
cf = co2_emissions_kg_per_yr / (total_floor_area_m2 + FLOOR_AREA_OFFSET_M2)
|
||
if cf >= _CF_LOG_THRESHOLD:
|
||
return _EI_LOG_INTERCEPT - _EI_LOG_SLOPE * log10(cf)
|
||
return _EI_LINEAR_INTERCEPT - _EI_LINEAR_SLOPE * cf
|