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>
178 lines
7.2 KiB
Python
178 lines
7.2 KiB
Python
"""SAP 10.2 §8c Space cooling — Tables 10a (η_loss) + 10b (Q_cool).
|
||
|
||
Spec lines 7864–7893 (worksheet block §8c) and 10274–10328 (Table 10a/10b
|
||
algebra). All cooling lines are 0 for dwellings without a fixed air-
|
||
conditioning system (cooled-area fraction f_C = 0 collapses Q_cool to 0).
|
||
Inclusion rule: only June, July, August contribute; other months zeroed.
|
||
|
||
Reference: SAP 10.2 specification (14-03-2025) Tables 10a/10b (page 186).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Final
|
||
|
||
|
||
_COOLING_INTERNAL_TEMP_C: Final[float] = 24.0 # spec line 10281 — fixed
|
||
_MIN_KWH_PER_MONTH: Final[float] = 1.0
|
||
_WH_TO_KWH_PER_DAY: Final[float] = 0.024
|
||
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||
# Spec line 10325: include Jun..Aug, disregard Sep..May. Zero-based indices.
|
||
_COOLING_MONTH_INDICES: Final[frozenset[int]] = frozenset({5, 6, 7})
|
||
|
||
|
||
def utilisation_factor_loss(
|
||
*,
|
||
total_gains_w: float,
|
||
heat_loss_rate_w: float,
|
||
time_constant_h: float,
|
||
) -> float:
|
||
"""SAP 10.2 Table 10a — cooling utilisation factor η_loss.
|
||
|
||
Inverse of Table 9a heating η: γ = G/L (same definition) but exponents
|
||
flip sign because Table 10a credits the LOSSES (cooling demand offset)
|
||
rather than the gains (heating demand offset). η_loss ∈ (0, 1]; returns
|
||
1 when γ ≤ 0 (losses negative or zero → utilisation of losses
|
||
undefined).
|
||
"""
|
||
if heat_loss_rate_w == 0.0:
|
||
gamma = 1_000_000.0 # spec Table 10a L=0 sentinel (formula → ≈1)
|
||
else:
|
||
gamma = total_gains_w / heat_loss_rate_w
|
||
gamma = round(gamma, 8)
|
||
a = 1.0 + time_constant_h / 15.0
|
||
if gamma <= 0.0:
|
||
return 1.0
|
||
if gamma == 1.0:
|
||
return a / (a + 1.0)
|
||
return (1.0 - gamma ** -a) / (1.0 - gamma ** -(a + 1.0))
|
||
|
||
|
||
def monthly_cool_requirement_kwh(
|
||
*,
|
||
heat_transfer_coefficient_w_per_k: float,
|
||
external_temperature_c: float,
|
||
total_gains_w: float,
|
||
time_constant_h: float,
|
||
days_in_month: int,
|
||
) -> tuple[float, float, float, float]:
|
||
"""SAP 10.2 Table 10b — Q_whole single month, pre f_C × f_intermittent.
|
||
|
||
Returns the (100)..(104) line refs as a 4-tuple: (L_m, η_loss, useful
|
||
loss = η×L, Q_whole). Q_whole is the signed (104)m value — spec line
|
||
10321 clamps the final Q_cool (107), not (104). Inclusion mask
|
||
(Jun-Aug only) + the f_C × f_intermittent multiplication + the negative-
|
||
or-sub-1-kWh clamp all live in the orchestrator.
|
||
"""
|
||
l_m = heat_transfer_coefficient_w_per_k * (_COOLING_INTERNAL_TEMP_C - external_temperature_c)
|
||
eta = utilisation_factor_loss(
|
||
total_gains_w=total_gains_w,
|
||
heat_loss_rate_w=l_m,
|
||
time_constant_h=time_constant_h,
|
||
)
|
||
useful_loss_w = eta * l_m
|
||
q_whole = _WH_TO_KWH_PER_DAY * (total_gains_w - useful_loss_w) * days_in_month
|
||
return l_m, eta, useful_loss_w, q_whole
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SpaceCoolingResult:
|
||
"""SAP 10.2 §8c worksheet line refs (100)..(108).
|
||
|
||
Returned by `space_cooling_monthly_kwh`. Downstream calculator consumes
|
||
`space_cooling_monthly_kwh` (107) directly; per-line tuples are
|
||
exposed for worksheet conformance + audit. Field names mirror the line
|
||
refs.
|
||
"""
|
||
|
||
heat_loss_rate_monthly_w: tuple[float, ...] # (100)
|
||
utilisation_factor_loss_monthly: tuple[float, ...] # (101)
|
||
useful_loss_monthly_w: tuple[float, ...] # (102)
|
||
cooling_gains_monthly_w: tuple[float, ...] # (103)
|
||
cooling_requirement_monthly_kwh: tuple[float, ...] # (104)
|
||
cooling_requirement_kwh_per_yr: float # Σ(104)
|
||
cooled_area_fraction: float # (105)
|
||
intermittency_factor_monthly: tuple[float, ...] # (106)
|
||
space_cooling_monthly_kwh: tuple[float, ...] # (107)
|
||
space_cooling_kwh_per_yr: float # Σ(107)
|
||
space_cooling_per_m2_kwh: float # (108)
|
||
|
||
|
||
def space_cooling_monthly_kwh(
|
||
*,
|
||
monthly_heat_transfer_coefficient_w_per_k: tuple[float, ...],
|
||
monthly_external_temperature_c: tuple[float, ...],
|
||
monthly_total_gains_w: tuple[float, ...],
|
||
total_floor_area_m2: float,
|
||
thermal_mass_parameter_kj_per_m2_k: float,
|
||
cooled_area_fraction: float = 0.0,
|
||
intermittency_factor: float = 0.25,
|
||
) -> SpaceCoolingResult:
|
||
"""SAP 10.2 §8c orchestrator — produce (100)..(108) line refs.
|
||
|
||
Inputs are length-12 Jan..Dec tuples; `monthly_total_gains_w` is the
|
||
cooling-specific gains tuple (Table 5a items already excluded by
|
||
caller — see `cert_to_inputs`). Internal temperature is fixed at
|
||
24 °C per Table 10a; not a parameter.
|
||
|
||
Inclusion rule (spec line 10325): only Jun-Aug contribute to (104)
|
||
and (107). Other months return 0 at those lines regardless of
|
||
computed value. (100)..(103) and (106) follow worksheet shape — see
|
||
Q4/Q7 grilling decisions for intermittency tuple semantics.
|
||
"""
|
||
l_list: list[float] = []
|
||
eta_list: list[float] = []
|
||
useful_loss_list: list[float] = []
|
||
q104_list: list[float] = []
|
||
intermittency_list: list[float] = []
|
||
q107_list: list[float] = []
|
||
|
||
hlp_m_per_m2_k = tuple(
|
||
h / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
|
||
for h in monthly_heat_transfer_coefficient_w_per_k
|
||
)
|
||
tau_m_h = tuple(
|
||
thermal_mass_parameter_kj_per_m2_k / (3.6 * hlp) if hlp > 0 else 0.0
|
||
for hlp in hlp_m_per_m2_k
|
||
)
|
||
|
||
for m in range(12):
|
||
l_m, eta_m, useful_loss_m, q_whole_m = monthly_cool_requirement_kwh(
|
||
heat_transfer_coefficient_w_per_k=monthly_heat_transfer_coefficient_w_per_k[m],
|
||
external_temperature_c=monthly_external_temperature_c[m],
|
||
total_gains_w=monthly_total_gains_w[m],
|
||
time_constant_h=tau_m_h[m],
|
||
days_in_month=_DAYS_IN_MONTH[m],
|
||
)
|
||
l_list.append(l_m)
|
||
eta_list.append(eta_m)
|
||
useful_loss_list.append(useful_loss_m)
|
||
if m in _COOLING_MONTH_INDICES:
|
||
q104_list.append(q_whole_m)
|
||
intermittency_list.append(intermittency_factor)
|
||
q_cool_m = q_whole_m * cooled_area_fraction * intermittency_factor
|
||
# Spec Table 10b: "Set Qcool to zero if negative or less than 1 kWh."
|
||
q107_list.append(q_cool_m if q_cool_m >= _MIN_KWH_PER_MONTH else 0.0)
|
||
else:
|
||
q104_list.append(0.0)
|
||
intermittency_list.append(0.0)
|
||
q107_list.append(0.0)
|
||
|
||
annual_104 = sum(q104_list)
|
||
annual_107 = sum(q107_list)
|
||
per_m2 = annual_107 / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
|
||
|
||
return SpaceCoolingResult(
|
||
heat_loss_rate_monthly_w=tuple(l_list),
|
||
utilisation_factor_loss_monthly=tuple(eta_list),
|
||
useful_loss_monthly_w=tuple(useful_loss_list),
|
||
cooling_gains_monthly_w=tuple(monthly_total_gains_w),
|
||
cooling_requirement_monthly_kwh=tuple(q104_list),
|
||
cooling_requirement_kwh_per_yr=annual_104,
|
||
cooled_area_fraction=cooled_area_fraction,
|
||
intermittency_factor_monthly=tuple(intermittency_list),
|
||
space_cooling_monthly_kwh=tuple(q107_list),
|
||
space_cooling_kwh_per_yr=annual_107,
|
||
space_cooling_per_m2_kwh=per_m2,
|
||
)
|