§4 slice 1: assumed_occupancy (worksheet line (42), Appendix J)

First slice of the §4 worksheet-driven rewrite (xlsx rows 207-304).
New module `domain/sap/worksheet/water_heating.py` lands the line-ref
mapped functions; subsequent slices append below.

`assumed_occupancy(tfa)` implements the SAP10.2 Appendix J Table 1b
piecewise formula. Validated against:
  - canonical xlsx worked example  (TFA Q23 → N U209)
  - Elmhurst U985-0001-000474       (TFA 56.79 → N 1.8896)
  - Elmhurst U985-0001-000490       (TFA 66.06 → N 2.1468)
  - boundary case TFA ≤ 13.9        (N=1 floor)

The legacy `domain.ml.demand._default_occupants_sap_j` mirror stays in
place until the §4 worksheet rewrite is complete; both sources will be
reconciled in a later slice once dependent callers move over.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 15:27:03 +00:00
parent d90827446a
commit aff678e8eb
4 changed files with 119 additions and 0 deletions

View file

@ -181,3 +181,6 @@ LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 232.1169
WINDOW_TOTAL_AREA_M2: float = 11.72
WINDOW_AVG_RAW_U_VALUE: float = 2.37
DOOR_COUNT: int = 2 # cascade default 1.85 m²/door → 3.70 m² matches worksheet
# §4 Water heating energy requirements
LINE_42_OCCUPANCY: float = 1.8896

View file

@ -169,3 +169,6 @@ LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 236.6211
WINDOW_TOTAL_AREA_M2: float = 9.03
WINDOW_AVG_RAW_U_VALUE: float = 2.8
DOOR_COUNT: int = 2 # cascade default 1.85 m²/door → 3.70 m² matches worksheet
# §4 Water heating energy requirements
LINE_42_OCCUPANCY: float = 2.1468

View file

@ -0,0 +1,69 @@
"""Tests for SAP 10.2 §4 — water heating energy requirements.
Worksheet line refs land in `domain.sap.worksheet.water_heating`. Each
test asserts a single line-ref output against the canonical xlsx worked
example and/or Elmhurst conformance fixtures.
Reference: SAP 10.2 specification §4 + Appendix J; canonical xlsx rows
207304 (sheet `NonRegionalWeather`).
"""
import pytest
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000490 as _w000490,
)
from domain.sap.worksheet.tests._xlsx_loader import load_cells
from domain.sap.worksheet.water_heating import assumed_occupancy
def test_assumed_occupancy_matches_canonical_xlsx_worked_example() -> None:
"""SAP10.2 §4 line (42), Appendix J Table 1b. Canonical xlsx cell U209
holds the worked-example occupancy for TFA=158.99 (Q23). The formula:
N = 1 + 1.76 × (1 exp(0.000349 × (TFA 13.9)²))
+ 0.0013 × (TFA 13.9) for TFA > 13.9
N = 1 for TFA 13.9
must reproduce 2.9475 W (matching the worksheet to 4 d.p.).
"""
# Arrange — TFA + expected N from the canonical worksheet.
cells = load_cells("NonRegionalWeather", ("Q23", "U209"))
tfa = cells["Q23"]
expected_n = cells["U209"]
# Act
n = assumed_occupancy(tfa)
# Assert
assert n == pytest.approx(expected_n, abs=1e-4)
def test_assumed_occupancy_matches_elmhurst_worksheet_000474() -> None:
"""Mid-terrace TFA=56.79 m² → N=1.8896 per Elmhurst U985-0001-000474
line (42)."""
# Arrange / Act
n = assumed_occupancy(_w000474.LINE_4_TFA_M2)
# Assert
assert n == pytest.approx(_w000474.LINE_42_OCCUPANCY, abs=1e-4)
def test_assumed_occupancy_matches_elmhurst_worksheet_000490() -> None:
"""End-terrace TFA=66.06 m² → N=2.1468 per Elmhurst U985-0001-000490
line (42)."""
# Arrange / Act
n = assumed_occupancy(_w000490.LINE_4_TFA_M2)
# Assert
assert n == pytest.approx(_w000490.LINE_42_OCCUPANCY, abs=1e-4)
def test_assumed_occupancy_floor_at_n_eq_1_for_small_dwellings() -> None:
"""Appendix J piecewise definition: TFA ≤ 13.9 m² → N=1 exactly. A
tiny studio flat at the boundary is the most common trigger."""
# Arrange / Act / Assert
assert assumed_occupancy(13.9) == pytest.approx(1.0, abs=1e-9)
assert assumed_occupancy(10.0) == pytest.approx(1.0, abs=1e-9)
assert assumed_occupancy(0.0) == pytest.approx(1.0, abs=1e-9)

View file

@ -0,0 +1,44 @@
"""SAP 10.2 §4 — water heating energy requirements.
Worksheet line refs (xlsx rows 207-304, sheet `NonRegionalWeather`):
(42) assumed occupancy N from Appendix J Table 1b
(42a)m monthly hot water usage for mixer showers
(42b)m monthly hot water usage for baths
(42c)m monthly hot water usage for other uses
(43) annual average hot water usage (litres/day)
(44)m daily hot water usage by month
(45)m energy content of monthly hot water demand
(46)m distribution loss = 0.15 × (45)m
(47)(56) storage volume + water storage / HIU loss
(57) dedicated solar storage adjustment
(58)(61) primary loss + combi loss
(62)m total monthly water heat requirement
(63a)(63d) WWHRS / PV-diverter / Solar / FGHRS reductions
(64)m output from water heater
(65)m heat gains from water heating
Reference: SAP 10.2 specification §4 (pages 22-31) + Appendix J (pages
84-90); canonical xlsx worked example at the repo root.
"""
from __future__ import annotations
from math import exp
from typing import Final
_OCCUPANCY_TFA_FLOOR_M2: Final[float] = 13.9
def assumed_occupancy(total_floor_area_m2: float) -> float:
"""SAP 10.2 §4 line (42) / Appendix J Table 1b.
Piecewise occupancy by total floor area:
TFA 13.9 : N = 1
TFA > 13.9 : N = 1 + 1.76 × (1 exp(0.000349 × (TFA 13.9)²))
+ 0.0013 × (TFA 13.9)
"""
if total_floor_area_m2 <= _OCCUPANCY_TFA_FLOOR_M2:
return 1.0
x = total_floor_area_m2 - _OCCUPANCY_TFA_FLOOR_M2
return 1.0 + 1.76 * (1.0 - exp(-0.000349 * x * x)) + 0.0013 * x