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

178 lines
7.2 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 §8c Space cooling — Tables 10a (η_loss) + 10b (Q_cool).
Spec lines 78647893 (worksheet block §8c) and 1027410328 (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,
)