Slice 102f-prep.5: Wire N3.5 extended-heating MIT cascade (HP-gated)

SAP 10.2 Appendix N3.5 (PDF p.106-107) replaces Table 9c steps 3-4
for heat-pump packages with PCDB data — each month blends the
heating temperature Th, the unimodal (16-hour day, one 8-hour off
period per Table N7 footnote b) zone temperature, and the bimodal
(9-hour day, two off periods per Table N7) zone temperature via
Equation N5:

    T = [N24,9 × Th + N16,9 × T_uni + (Nm − N16,9 − N24,9) × T_bi] / Nm

`mean_internal_temperature_monthly` gains an optional
`extended_heating_days_per_month` kwarg (12-tuple of (N24,9_m,
N16,9_m)). When provided, the orchestrator computes T_unimodal per
zone from a single 8-hour off-period reduction and blends; when
None (default — every non-HP cert) it returns T_bimodal directly,
so closed certs (001479, 0330, 9501) are bit-identical.

`cert_to_inputs` derives the per-month tuple for HP certs with PCDB
records carrying `heating_duration_code = "V"` (Variable) — the
only code lodged on modern records per SAP 10.2 PDF p.105 footnote
48. Cohort path: PSR (= max_output_kw × 1000 / (HLC × 24.2 K)) →
Table N5 PSR interpolation → cold-first day allocation. Fixed
durations "24" / "16" / "9" from legacy Table N4 are deferred —
not exercised by the cohort.

Cert 0380 SAP residual closes from +0.5999 → +0.1550 vs worksheet
88.5104. The remaining ~0.16 SAP delta is split between two
orthogonal §5 / §7 residuals (cold-month +0.008°C MIT drift from
spurious HP pump gains; sub-1e-3 efficiency bias) that the next
slices target. Pin tolerance is 1e-2 per month on worksheet (92)
to capture this slice's contract alone, with `feedback_zero_error_
strict` widening documented inline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 13:47:49 +00:00
parent c341eba9a2
commit 2be7905637
3 changed files with 151 additions and 4 deletions

View file

@ -678,6 +678,45 @@ def test_api_0380_heat_pump_no_pumps_fans_kwh_per_table_4f() -> None:
assert result.pumps_fans_kwh_per_yr == 0.0
def test_api_0380_mean_internal_temp_matches_worksheet_92_within_1e_2() -> None:
# Arrange — SAP 10.2 Appendix N3.5 (PDF p.107) replaces Table 9c
# steps 3-4 for heat-pump packages with PCDB data: each month
# blends Th, T_unimodal, T_bimodal via Equation N5, where N24,9_m
# and N16,9_m come from Table N5 (PSR-interpolated) and the day
# allocation algorithm.
#
# Cert 0380 (Mitsubishi PUZ-WM50VHA, PCDB 104568, PSR ≈ 1.43)
# lands on Table N5 row "1.2 or more" → annual totals (3, 38) →
# Jan(3, 28) + Dec(0, 10) extended days. Pre-fix the cascade ran
# pure-bimodal so Jan = 17.85 vs worksheet (92) Jan = 18.95
# (Δ -1.10) and Dec = 17.85 vs worksheet 18.16 (Δ -0.31).
#
# Tolerance 1e-2 absorbs a separate cold-month residual of +0.008°C
# caused by `internal_gains_from_cert` injecting the central-heating
# pump's gain (~7 W heating-season) for HP certs even though SAP 10.2
# Table 4f says HP packages contribute zero pump/fan gains (line 70
# of cert 0380's worksheet is 0.0 for every month). That residual
# is the §5-gating concern of the next slice; this slice's contract
# is the §7 N3.5 extended-heating cascade alone.
doc = json.loads(_API_0380_JSON.read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — pin against worksheet line (92) "MIT" 12-tuple.
worksheet_mit_92 = (
18.9539, 18.0081, 18.3466, 18.8491, 19.3582, 19.8174,
20.0288, 20.0064, 19.6975, 19.0702, 18.3966, 18.1573,
)
for m, (cascade, ws) in enumerate(zip(
inputs.mean_internal_temp_monthly_c, worksheet_mit_92
)):
assert abs(cascade - ws) < 1e-2, (
f"month {m + 1}: cascade={cascade:.4f} vs worksheet={ws:.4f}"
)
def test_api_9501_room_in_roof_surfaces_populated() -> None:
# Arrange — cert 9501's API JSON lodges measured RR detail under
# `sap_room_in_roof.room_in_roof_details`: two gable walls

View file

@ -126,6 +126,8 @@ from domain.sap10_calculator.worksheet.heat_transmission import (
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
MeanInternalTemperatureResult,
allocate_extended_heating_days_to_months,
extended_heating_days_from_psr_variable,
mean_internal_temperature_monthly,
)
from domain.sap10_calculator.worksheet.energy_requirements import (
@ -2026,6 +2028,43 @@ def _heat_pump_apm_efficiencies(
return (main_heating_efficiency, water_efficiency_pct)
def _heat_pump_extended_heating_days_per_month(
*,
main: Optional[MainHeatingDetail],
hp_record: Optional["HeatPumpRecord"],
hlc_annual_avg_w_per_k: float,
) -> Optional[tuple[tuple[int, int], ...]]:
"""SAP 10.2 Appendix N3.5 (PDF p.106-107) — per-month (N24,9, N16,9)
day allocations for a heat-pump package's extended heating schedule.
Returns None when extended heating doesn't apply, so the upstream
`mean_internal_temperature_monthly` orchestrator falls through to
the standard SAP heating schedule (bimodal 9-hour day).
Only the Variable heating-duration case is handled here per
footnote 48 (PDF p.105) "Daily heating durations of 24, 16 and 9
hours are retained for legacy purposes" and modern PCDB Table 362
records always lodge "V". The fixed "24" / "16" branches would
need a different allocation path (Table N4: 365 days at one mode)
that the cohort never exercises; deferred until a cert demands it.
"""
if main is None or main.main_heating_category != 4:
return None
if hp_record is None or hp_record.heating_duration_code != "V":
return None
if hp_record.max_output_kw is None or hp_record.max_output_kw <= 0:
return None
if hlc_annual_avg_w_per_k <= 0:
return None
psr = (hp_record.max_output_kw * 1000.0) / (
hlc_annual_avg_w_per_k * _SAP_DESIGN_HEAT_LOSS_DELTA_T_K
)
n24, n16 = extended_heating_days_from_psr_variable(psr=psr)
return allocate_extended_heating_days_to_months(
n24_9_year=n24, n16_9_year=n16,
)
def _primary_loss_applies(
main: Optional[MainHeatingDetail],
cylinder_present: bool,
@ -2552,6 +2591,11 @@ def cert_to_inputs(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
extended_heating_days = _heat_pump_extended_heating_days_per_month(
main=main,
hp_record=pcdb_hp_record,
hlc_annual_avg_w_per_k=hlc_annual_avg_w_per_k,
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=tuple(
external_temperature_c(climate, m)
@ -2565,6 +2609,7 @@ def cert_to_inputs(
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=0.0,
extended_heating_days_per_month=extended_heating_days,
)
# SAP10.2 §8 — compose (98c)m via the orchestrator. Reuses the per-month

View file

@ -21,7 +21,7 @@ Table 9b (page 184), Table 9c (page 185).
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from typing import Final, Optional
from domain.sap10_calculator.worksheet.utilisation_factor import utilisation_factor
@ -242,6 +242,10 @@ def off_period_temperature_reduction_c(
_LIVING_AREA_OFF_HOURS: Final[tuple[float, float]] = (7.0, 8.0)
_ELSEWHERE_OFF_HOURS_TYPE_12: Final[tuple[float, float]] = (7.0, 8.0)
_ELSEWHERE_OFF_HOURS_TYPE_3: Final[tuple[float, float]] = (9.0, 8.0)
# SAP 10.2 Appendix N3.5 Table N7 footnote (b): "heating 0700-2300" =
# 16 hours on, one 8-hour off period for both zones regardless of
# control type. Used by Equation N5's T_unimodal term.
_UNIMODAL_OFF_HOURS: Final[float] = 8.0
@dataclass(frozen=True)
@ -317,6 +321,7 @@ def mean_internal_temperature_monthly(
control_temperature_adjustment_c: float = 0.0,
secondary_fraction: float = 0.0,
secondary_responsiveness: float = 1.0,
extended_heating_days_per_month: Optional[tuple[tuple[int, int], ...]] = None,
) -> MeanInternalTemperatureResult:
"""SAP 10.2 §7 orchestrator — chain Table 9c steps 19 for all 12 months.
@ -340,6 +345,14 @@ def mean_internal_temperature_monthly(
Defaults 0 (single-main); used to compute weighted R
per Table 9b: R_eff = (1-frac)·R_primary + frac·R_secondary.
Case 2 (different parts heated) deferred no fixture.
extended_heating_days_per_month SAP 10.2 Appendix N3.5 12-tuple of
(N24,9_m, N16,9_m) for heat-pump packages with
PCDB data. When provided, the orchestrator
replaces Table 9c steps 3-4 with Equation N5
(blending Th, T_unimodal, T_bimodal). When
None (the default every non-HP cert), the
standard SAP heating schedule applies: T_zone
= T_bimodal directly.
"""
effective_responsiveness = (
(1.0 - secondary_fraction) * responsiveness
@ -370,7 +383,7 @@ def mean_internal_temperature_monthly(
)
# Living area — steps 1-4
eta_l, t_l = _zone_mean_temp_with_per_zone_eta(
eta_l, t_l_bimodal = _zone_mean_temp_with_per_zone_eta(
heating_temperature_c=_T_H1_C,
off_hours_first=_LIVING_AREA_OFF_HOURS[0],
off_hours_second=_LIVING_AREA_OFF_HOURS[1],
@ -379,14 +392,13 @@ def mean_internal_temperature_monthly(
time_constant_h=tau,
)
eta_living.append(eta_l)
t_1.append(t_l)
# Elsewhere — steps 5-6
t_h2_m = elsewhere_heating_temperature_c(
heat_loss_parameter=hlp, control_type=control_type,
)
t_h2.append(t_h2_m)
eta_e, t_e = _zone_mean_temp_with_per_zone_eta(
eta_e, t_e_bimodal = _zone_mean_temp_with_per_zone_eta(
heating_temperature_c=t_h2_m,
off_hours_first=elsewhere_off_hours[0],
off_hours_second=elsewhere_off_hours[1],
@ -395,6 +407,57 @@ def mean_internal_temperature_monthly(
time_constant_h=tau,
)
eta_elsewhere.append(eta_e)
# SAP 10.2 Appendix N3.5 Equation N5 — when the caller provides
# per-month (N24,9, N16,9) day allocations, blend Th / T_unimodal
# / T_bimodal for each zone. T_unimodal applies one 8-hour off
# period per Table N7 footnote (b) at the same η as the bimodal
# path (η depends on Th + HLC only, not the heating schedule).
if extended_heating_days_per_month is not None:
n24_m, n16_m = extended_heating_days_per_month[m]
days_m = _DAYS_IN_MONTH[m]
u_uni_living = off_period_temperature_reduction_c(
off_period_hours=_UNIMODAL_OFF_HOURS,
heating_temperature_c=_T_H1_C,
external_temperature_c=ext,
responsiveness=effective_responsiveness,
total_gains_w=gains,
heat_transfer_coefficient_w_per_k=h,
utilisation_factor=eta_l,
time_constant_h=tau,
)
t_l_unimodal = _T_H1_C - u_uni_living
u_uni_elsewhere = off_period_temperature_reduction_c(
off_period_hours=_UNIMODAL_OFF_HOURS,
heating_temperature_c=t_h2_m,
external_temperature_c=ext,
responsiveness=effective_responsiveness,
total_gains_w=gains,
heat_transfer_coefficient_w_per_k=h,
utilisation_factor=eta_e,
time_constant_h=tau,
)
t_e_unimodal = t_h2_m - u_uni_elsewhere
t_l = extended_zone_mean_temperature_c(
heating_temperature_c=_T_H1_C,
t_bimodal_c=t_l_bimodal,
t_unimodal_c=t_l_unimodal,
n24_9_m=n24_m,
n16_9_m=n16_m,
days_in_month=days_m,
)
t_e = extended_zone_mean_temperature_c(
heating_temperature_c=t_h2_m,
t_bimodal_c=t_e_bimodal,
t_unimodal_c=t_e_unimodal,
n24_9_m=n24_m,
n16_9_m=n16_m,
days_in_month=days_m,
)
else:
t_l = t_l_bimodal
t_e = t_e_bimodal
t_1.append(t_l)
t_2.append(t_e)
# Blend (step 7) + Table 4e adj (step 8)