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

151 lines
6.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 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,
)