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

76 lines
2.8 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 §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