From 2be790563757a6783676d1a34b836d4d1cc0267f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 13:47:49 +0000 Subject: [PATCH] Slice 102f-prep.5: Wire N3.5 extended-heating MIT cascade (HP-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_summary_pdf_mapper_chain.py | 39 ++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 45 ++++++++++++ .../worksheet/mean_internal_temperature.py | 71 +++++++++++++++++-- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index dfb21f1e..bc7d5961 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a6614270..93957d81 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index cbd21244..8e562ed4 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -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 1–9 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)