From 4eacfa62965ba537937d92ef1861d2230f451bfe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 15:17:26 +0000 Subject: [PATCH] Slice 102f-prep.7: Table N4 fixed durations ("24"/"16") in HP extended-heating helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_summary_pdf_mapper_chain.py | 37 ++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 49 ++++++++++++------- 2 files changed, 68 insertions(+), 18 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 38bf18a3..5d3d8683 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,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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 93957d81..3fb0817e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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(