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>
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""SAP 10.2 Table 9c step 10 — monthly space-heating requirement.
|
||
|
||
Final step of the heating worksheet: the heat the heating system has to
|
||
deliver to maintain the mean internal temperature, given the loss rate to
|
||
the outside and the gains the dwelling has already accumulated.
|
||
|
||
L_m = H × (T_i,m − T_e,m) (W)
|
||
Q_heat,m = 0.024 × (L_m − η_m × G_m) × n_m (kWh)
|
||
|
||
If Q_heat would be negative or below 1 kWh in any month, set it to 0 per
|
||
the Table 9c clamp.
|
||
|
||
Reference: SAP 10.2 specification (14-03-2025) Table 9c (page 185).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Final
|
||
|
||
from domain.sap10_calculator.worksheet.heat_transmission import _round_half_up
|
||
|
||
|
||
_MIN_KWH_PER_MONTH: Final[float] = 1.0
|
||
_WH_TO_KWH_PER_DAY: Final[float] = 0.024 # 24 h / 1000
|
||
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||
_WORKSHEET_DISPLAY_DP: Final[int] = 4
|
||
# SAP10.2 Table 9c step 10: "Include the heating requirement for each month
|
||
# from October to May (disregarding June to September)." Set Q_heat to zero
|
||
# in Jun..Sep regardless of computed value. Indices 5..8 inclusive (zero-based).
|
||
_SUMMER_MONTH_INDICES: Final[frozenset[int]] = frozenset({5, 6, 7, 8})
|
||
|
||
|
||
def monthly_heat_requirement_kwh(
|
||
*,
|
||
heat_transfer_coefficient_w_per_k: float,
|
||
internal_temperature_c: float,
|
||
external_temperature_c: float,
|
||
utilisation_factor: float,
|
||
total_gains_w: float,
|
||
days_in_month: int,
|
||
) -> float:
|
||
"""SAP 10.2 Table 9c step 10. Returns delivered kWh required for the
|
||
month; clamps to 0 when below 1 kWh or negative."""
|
||
loss_rate_w = heat_transfer_coefficient_w_per_k * (
|
||
internal_temperature_c - external_temperature_c
|
||
)
|
||
useful_loss_w = loss_rate_w - utilisation_factor * total_gains_w
|
||
if useful_loss_w <= 0:
|
||
return 0.0
|
||
q_heat = _WH_TO_KWH_PER_DAY * useful_loss_w * days_in_month
|
||
if q_heat < _MIN_KWH_PER_MONTH:
|
||
return 0.0
|
||
return q_heat
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SpaceHeatingResult:
|
||
"""SAP 10.2 §8 worksheet line refs (95)..(99).
|
||
|
||
Returned by `space_heating_monthly_kwh`. Downstream calculator consumes
|
||
`total_space_heating_monthly_kwh` (98c) directly to drive fuel-cost +
|
||
rating chains; per-line tuples are exposed for worksheet conformance +
|
||
audit. Field names mirror the SAP 10.2 line refs.
|
||
"""
|
||
|
||
useful_gains_monthly_w: tuple[float, ...] # (95)
|
||
heat_loss_rate_monthly_w: tuple[float, ...] # (97)
|
||
space_heating_requirement_monthly_kwh: tuple[float, ...] # (98a)
|
||
solar_space_heating_monthly_kwh: tuple[float, ...] # (98b)
|
||
total_space_heating_monthly_kwh: tuple[float, ...] # (98c)
|
||
space_heating_requirement_kwh_per_yr: float # Σ(98a) — FEE input
|
||
total_space_heating_kwh_per_yr: float # Σ(98c)
|
||
space_heating_per_m2_kwh: float # (99)
|
||
|
||
|
||
def space_heating_monthly_kwh(
|
||
*,
|
||
monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...],
|
||
monthly_internal_temperature_c: tuple[float, ...],
|
||
monthly_external_temperature_c: tuple[float, ...],
|
||
monthly_utilisation_factor: tuple[float, ...],
|
||
monthly_total_gains_w: tuple[float, ...],
|
||
total_floor_area_m2: float,
|
||
) -> SpaceHeatingResult:
|
||
"""SAP 10.2 §8 orchestrator — produce (95)..(99) line refs for all months.
|
||
|
||
Composes the existing single-month leaf with the spec inclusion rule:
|
||
Jun..Sep are zeroed (Table 9c step 10) regardless of computed value,
|
||
on top of the per-month value clamp (< 1 kWh or negative).
|
||
|
||
Solar space heating (98b) — Appendix H — is always 0 in this slice; no
|
||
Elmhurst fixture lodges a solar space heating system. (98c) = (98a) for
|
||
the current corpus.
|
||
|
||
Inputs are length-12 Jan..Dec tuples. `total_floor_area_m2` only drives
|
||
the (99) per-m² aggregate; everything else is per-month physics.
|
||
"""
|
||
useful_gains: list[float] = []
|
||
heat_loss_rate: list[float] = []
|
||
q_heat_98a: list[float] = []
|
||
q_solar_98b: list[float] = []
|
||
q_total_98c: list[float] = []
|
||
|
||
for m in range(12):
|
||
h = monthly_heat_transfer_coefficient_w_per_k[m]
|
||
t_i = monthly_internal_temperature_c[m]
|
||
t_e = monthly_external_temperature_c[m]
|
||
eta = monthly_utilisation_factor[m]
|
||
gains = monthly_total_gains_w[m]
|
||
|
||
useful_gains.append(eta * gains)
|
||
heat_loss_rate.append(h * (t_i - t_e))
|
||
|
||
q98a = monthly_heat_requirement_kwh(
|
||
heat_transfer_coefficient_w_per_k=h,
|
||
internal_temperature_c=t_i,
|
||
external_temperature_c=t_e,
|
||
utilisation_factor=eta,
|
||
total_gains_w=gains,
|
||
days_in_month=_DAYS_IN_MONTH[m],
|
||
)
|
||
# Spec inclusion rule: Jun..Sep do not contribute regardless of value.
|
||
if m in _SUMMER_MONTH_INDICES:
|
||
q98a = 0.0
|
||
q_heat_98a.append(q98a)
|
||
q_solar_98b.append(0.0)
|
||
q_total_98c.append(q98a)
|
||
|
||
# U985 worksheet lodges (98a)_m / (98c)_m at 4 d.p. half-up and reports
|
||
# the annual as the Σ of those displayed monthlies. The full-precision Σ
|
||
# diverges from the lodged annual by up to ~1.4e-4 (accumulated 4-d.p.
|
||
# rounding over 8 heating months) — e.g. 000490 = 0.000132. Rounding
|
||
# each monthly to 4 d.p. before summing reproduces the lodged annual
|
||
# exactly for all 6 fixtures. SAP10.2 Table 9c step 10 (p.184) defines
|
||
# (98a)_m without an explicit annual rounding rule; this matches the
|
||
# worksheet display convention.
|
||
annual_98a = sum(_round_half_up(q, _WORKSHEET_DISPLAY_DP) for q in q_heat_98a)
|
||
annual_98c = sum(_round_half_up(q, _WORKSHEET_DISPLAY_DP) for q in q_total_98c)
|
||
per_m2_99 = annual_98c / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
|
||
|
||
return SpaceHeatingResult(
|
||
useful_gains_monthly_w=tuple(useful_gains),
|
||
heat_loss_rate_monthly_w=tuple(heat_loss_rate),
|
||
space_heating_requirement_monthly_kwh=tuple(q_heat_98a),
|
||
solar_space_heating_monthly_kwh=tuple(q_solar_98b),
|
||
total_space_heating_monthly_kwh=tuple(q_total_98c),
|
||
space_heating_requirement_kwh_per_yr=annual_98a,
|
||
total_space_heating_kwh_per_yr=annual_98c,
|
||
space_heating_per_m2_kwh=per_m2_99,
|
||
)
|