Slice 102f-prep.7: Table N4 fixed durations ("24"/"16") in HP extended-heating helper

SAP 10.2 Appendix N3.5 Table N4 (PDF p.107) — heat-pump packages
with fixed daily heating durations:
  - "24" → N24,9 = 365 (continuous): every day at heating temperature,
    no off period → (days_in_month, 0) per month → MIT_zone = Th.
  - "16" → N16,9 = 365 (unimodal, 0700-2300): every day with single
    8h off → (0, days_in_month) per month → MIT_zone = Th − u1(8h).
  - "9" → standard SAP schedule (bimodal 7+8 off): falls through to
    `None` so the orchestrator applies the legacy bimodal path.

Cert 9418 (Daikin Altherma EDLQ05CAV3, PCDB 102421) lodges
`heating_duration_code = "24"` — worksheet (87) MIT_living = 21.0
every month (= Th1, no off period) and (90) MIT_elsewhere collapses
to Th2 directly. Pre-fix the bimodal cascade produced MIT ~17.8-19.8
(2.04°C low at Jan) and SAP was +2.20 over worksheet 84.6305.

Post-fix cert 9418 closes to SAP Δ +0.0296 (from +2.20) — the
residual is consistent with the same ~0.05 PSR-formula drift seen
in 5/7 cohort certs sharing PCDB 104568.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 15:17:26 +00:00
parent 143d11d39f
commit 4eacfa6296
2 changed files with 68 additions and 18 deletions

View file

@ -678,6 +678,43 @@ def test_api_0380_heat_pump_no_pumps_fans_kwh_per_table_4f() -> None:
assert result.pumps_fans_kwh_per_yr == 0.0
_API_9418_JSON = (
Path(__file__).parents[3]
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
/ "9418-3062-8205-3566-7200.json"
)
def test_api_9418_daikin_24h_duration_mean_internal_temp_matches_worksheet_92() -> None:
# Arrange — cert 9418 (Daikin Altherma EDLQ05CAV3, PCDB 102421)
# lodges `heating_duration_code = "24"`. Per SAP 10.2 Table N4 (PDF
# p.107) this means N24,9 = 365 (all days operate at 24-hour
# heating, no off-period). Worksheet (87) MIT_living = 21.0 every
# month (= Th1, no off period), worksheet (90) MIT_elsewhere
# collapses to Th2 directly. Worksheet (92) blended at fLA = 0.30.
#
# Pre-slice-102f-prep.7 the helper's "V"-only gate returned None
# for this duration → bimodal cascade gave MIT ~17.8-19.8 (off by
# ~2°C). After Table N4 wiring the cascade lands at 1e-3.
doc = json.loads(_API_9418_JSON.read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — worksheet (92) "MIT" 12-tuple at 1e-3 per month.
worksheet_mit_92 = (
19.8400, 19.8445, 19.8489, 19.8697, 19.8736, 19.8920,
19.8920, 19.8954, 19.8849, 19.8736, 19.8657, 19.8574,
)
for m, (cascade, ws) in enumerate(zip(
inputs.mean_internal_temp_monthly_c, worksheet_mit_92
)):
assert abs(cascade - ws) < 1e-3, (
f"month {m + 1}: cascade={cascade:.4f} vs worksheet={ws:.4f}"
)
def test_api_0380_mean_internal_temp_matches_worksheet_92_within_1e_3() -> 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

View file

@ -2041,28 +2041,41 @@ def _heat_pump_extended_heating_days_per_month(
`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.
Per `heating_duration_code` from the PCDB record (SAP 10.2 PDF
p.105 line 6099):
- "V" (Variable, modern default per footnote 48): Table N5 PSR
interpolation + cold-first allocation via
`allocate_extended_heating_days_to_months`.
- "24": Table N4 N24,9 = 365 every day operates at 24-hour
heating (no off period), so each month's tuple is
(days_in_month, 0).
- "16": Table N4 N16,9 = 365 every day unimodal (one 8h off),
each month's tuple is (0, days_in_month).
- "9" or other: standard 9-hour schedule = no extended heating
return None so the orchestrator's bimodal fallback applies.
"""
if main is None or main.main_heating_category != 4:
return None
if hp_record is None or hp_record.heating_duration_code != "V":
if hp_record is None:
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,
)
code = hp_record.heating_duration_code
if code == "V":
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,
)
if code == "24":
return tuple((d, 0) for d in _DAYS_IN_MONTH)
if code == "16":
return tuple((0, d) for d in _DAYS_IN_MONTH)
return None
def _primary_loss_applies(