Slice S0380.145: Table 4e temperature adjustment — apply (92)m → (93)m offset per Table 9c step 8

SAP 10.2 Table 4e (PDF p.170-173) "Heating system controls":

  3. The 'Temperature adjustment' modifies the mean internal
     temperature and is added to worksheet (92)m.

SAP 10.2 Table 9c step 8 (PDF p.184): "Apply adjustment to the mean
internal temperature from Table 4e, where appropriate".

Pre-slice the cascade hardcoded `control_temperature_adjustment_c
=0.0` at all three call sites of `mean_internal_temperature_monthly`
and `space_heating_section_with_results`. The §8 heat loss calc
therefore drove off (92)m unchanged → §8 SH demand under-counted on
every cert whose `main_heating_control` lodges a non-zero adjustment.

Table 4e adjustments by code (full p.170-173 coverage):

  Group 0 — No heating system:
    2699: +0.3
  Group 1 — Boilers with radiators/UFH (+ micro-CHP):
    2101, 2102: +0.6   (no thermo / programmer-only)
    2103..2113: 0
  Group 2 — Heat pumps:
    2201, 2202: +0.3
    2203..2210: 0
  Group 3 — Heat networks:
    2301, 2302: +0.3
    2303..2314: 0
  Group 4 — Electric storage:
    2401 (Manual charge):                  +0.7
    2402 (Automatic charge):               +0.4
    2403 (Celect):                         +0.4
    2404 (HHR controls):                    0
  Group 5 — Warm air:
    2501, 2502: +0.3
    2503..2506: 0
  Group 6 — Room heaters:
    2601: +0.3
    2602..2605: 0
  Group 7 — Other systems:
    2701, 2702: +0.3
    2703..2706: 0

New `_control_temperature_adjustment_c(main)` helper consults
`_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE` (52 entries, full Table 4e
coverage). Strict-raises `UnmappedSapCode` on present-but-unmapped
codes per [[reference-unmapped-sap-code]] so spec-coverage gaps
surface at test time. The helper is wired to all three call sites
of the MIT/SH orchestrators in cert_to_inputs.

Corpus impact — closes the +2.5 SAP cluster substantially:

  Variant | control |  pre  →  post  | delta
  ------- | ------- | -------------- | -----
  e3 (401)|  2401   | +2.55 → -0.09  | -2.46  (massive close)
  e6 (404)|  2402   | +1.33 → -0.17  | -1.50
  e7 (408)|  2402   | +1.29 → -0.20  | -1.49
  e2 (524)|  2502   | +0.47 → -0.18  | -0.65
  e5 (402)|  2402   | +0.07 → -1.43  | -1.50  (regressed —
                                              previously net-zero
                                              from offsetting bugs)

Cumulative |ΔSAP| across these 5: 5.71 → 2.07 (-3.64 pts closed).
electric 3 / 6 / 7 / 8 / 9 now all within 0.20 SAP of worksheet.
Golden fixtures unchanged (API certs in those tests don't lodge
non-zero-adjustment control codes; suite stays 888 pass).

Extended handover suite: 888 pass, 0 fail (was 887 + 1 new AAA test).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 21:50:10 +00:00
parent ec6661cbb6
commit b1478cff63
3 changed files with 202 additions and 8 deletions

View file

@ -221,11 +221,11 @@ class _CorpusExpectation:
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.4737, expected_cost_resid_gbp=-10.9153, expected_co2_resid_kg=+10.9544, expected_pe_resid_kwh=+100.9401),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-108.8821, expected_pe_resid_kwh=-1093.1815),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-2.4846, expected_pe_resid_kwh=-133.3636),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.3278, expected_cost_resid_gbp=-30.5954, expected_co2_resid_kg=-56.1047, expected_pe_resid_kwh=-562.5298),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-53.5730, expected_pe_resid_kwh=-549.3654),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1842, expected_cost_resid_gbp=+4.2439, expected_co2_resid_kg=+38.7768, expected_pe_resid_kwh=+392.8379),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0874, expected_cost_resid_gbp=+2.0136, expected_co2_resid_kg=+4.2088, expected_pe_resid_kwh=+81.9107),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.4255, expected_cost_resid_gbp=+32.8452, expected_co2_resid_kg=+61.9651, expected_pe_resid_kwh=+535.1955),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.1698, expected_cost_resid_gbp=+3.9118, expected_co2_resid_kg=+7.8972, expected_pe_resid_kwh=+103.4643),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-0.2044, expected_cost_resid_gbp=+4.7098, expected_co2_resid_kg=+9.6362, expected_pe_resid_kwh=+112.7064),
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),

View file

@ -740,6 +740,75 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
}
# SAP 10.2 Table 4e (PDF p.171-173) — "Temperature adjustment, °C"
# column. Spec verbatim (p.170): "3. The 'Temperature adjustment'
# modifies the mean internal temperature and is added to worksheet
# (92)m." Table 9c step 8: "Apply adjustment to the mean internal
# temperature from Table 4e, where appropriate".
#
# Pre-S0380.145 the cascade hardcoded `control_temperature_adjustment
# _c=0.0` at all three call sites of `mean_internal_temperature_
# monthly`. The non-zero adjustments are concentrated on systems
# without thermostatic control (which run permanently at setpoint
# during their heating periods, raising MIT) and on Group 4 electric
# storage where the storage charging strategy raises the maintained
# mean (Manual charge +0.7, Automatic charge +0.4, Celect +0.4,
# HHR-specific controls 0).
_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE: Final[dict[int, float]] = {
# Group 0 — NO HEATING SYSTEM PRESENT
2699: +0.3,
# Group 1 — BOILER SYSTEMS WITH RADIATORS / UFH (and micro-CHP)
# 2100 = "Not applicable (boiler DHW only)" — no MIT contribution
# from this main; treat as 0.
2100: 0.0,
2101: +0.6, 2102: +0.6,
2103: 0.0, 2104: 0.0, 2105: 0.0, 2106: 0.0, 2107: 0.0, 2108: 0.0,
2109: 0.0, 2110: 0.0, 2111: 0.0, 2112: 0.0, 2113: 0.0,
# Group 2 — HEAT PUMPS WITH RADIATORS / UFH
2201: +0.3, 2202: +0.3,
2203: 0.0, 2204: 0.0, 2205: 0.0, 2206: 0.0,
2207: 0.0, 2208: 0.0, 2209: 0.0, 2210: 0.0,
# Group 3 — HEAT NETWORKS
2301: +0.3, 2302: +0.3,
2303: 0.0, 2304: 0.0, 2305: 0.0, 2306: 0.0, 2307: 0.0, 2308: 0.0,
2309: 0.0, 2310: 0.0, 2311: 0.0, 2312: 0.0, 2313: 0.0, 2314: 0.0,
# Group 4 — ELECTRIC STORAGE SYSTEMS
2401: +0.7, 2402: +0.4, 2403: +0.4, 2404: 0.0,
# Group 5 — WARM AIR SYSTEMS (incl. HP with warm air distribution)
2501: +0.3, 2502: +0.3,
2503: 0.0, 2504: 0.0, 2505: 0.0, 2506: 0.0,
# Group 6 — ROOM HEATER SYSTEMS
2601: +0.3,
2602: 0.0, 2603: 0.0, 2604: 0.0, 2605: 0.0,
# Group 7 — OTHER SYSTEMS
2701: +0.3, 2702: +0.3,
2703: 0.0, 2704: 0.0, 2705: 0.0, 2706: 0.0,
}
def _control_temperature_adjustment_c(
main: Optional[MainHeatingDetail],
) -> float:
"""SAP 10.2 Table 4e (PDF p.171-173) "Temperature adjustment, °C"
per Table 9c step 8 (PDF p.184). The adjustment is added to (92)m
to produce (93)m, which feeds the §8 heat loss rate calc and the
Table 9c step 9 re-calculated utilisation factor.
Returns 0.0 when no main is lodged or the cert's
`main_heating_control` is not an int. Raises `UnmappedSapCode`
for present-but-unmapped codes per [[reference-unmapped-sap-code]]
so spec-coverage gaps surface at test time.
"""
if main is None:
return 0.0
code = main.main_heating_control
if not isinstance(code, int):
return 0.0
if code in _CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE:
return _CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE[code]
raise UnmappedSapCode("main_heating_control_temperature_adjustment", code)
from domain.sap10_calculator.exceptions import (
MissingMainFuelType,
UnmappedSapCode,
@ -2607,7 +2676,7 @@ def mean_internal_temperature_section_from_cert(
living_area_fraction=_living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
),
control_temperature_adjustment_c=0.0,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
)
@ -4615,7 +4684,7 @@ def cert_to_inputs(
control_type=control_type_value,
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=0.0,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
extended_heating_days_per_month=extended_heating_days,
)
@ -4809,7 +4878,7 @@ def cert_to_inputs(
control_type=control_type_value,
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=0.0,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
main_heating_efficiency=eff,
hot_water_kwh_per_yr=hw_kwh,

View file

@ -3095,6 +3095,131 @@ def test_sap_10_2_table_11_electric_storage_secondary_fraction_dispatches_per_ta
)
def test_sap_10_2_table_4e_temperature_adjustment_applied_to_adjusted_mit_per_table_9c_step_8() -> None:
"""SAP 10.2 Table 4e (PDF p.171-173) "Heating system controls":
3. The 'Temperature adjustment' modifies the mean internal
temperature and is added to worksheet (92)m.
Per Table 9c step 8: "Apply adjustment to the mean internal
temperature from Table 4e, where appropriate". The adjusted
(93)m drives the §8 heat loss rate calc SH demand.
Table 4e adjustments per control code:
Group 0 No heating system:
2699: +0.3
Group 1 Boilers with radiators/UFH:
2101, 2102: +0.6 (No thermo / programmer-only)
2103..2113: 0
Group 2 Heat pumps with radiators/UFH:
2201, 2202: +0.3
2203..2210: 0
Group 3 Heat networks:
2301, 2302: +0.3
2303..2314: 0
Group 4 Electric storage:
2401 (Manual charge): +0.7
2402 (Automatic charge): +0.4
2403 (Celect): +0.4
2404 (HHR controls): 0
Group 5 Warm air:
2501, 2502: +0.3
2503..2506: 0
Group 6 Room heaters:
2601: +0.3
2602..2605: 0
Group 7 Other systems:
2701, 2702: +0.3
2703..2706: 0
Pre-slice the cascade hardcoded `control_temperature_adjustment_c
=0.0` at all three call sites of `mean_internal_temperature_
monthly` and `space_heating_section_with_results`. Cert electric 3
(corpus 001431, SAP code 401 + main_heating_control 2401 "Manual
charge control") exposes the gap: worksheet (93) MIT_adjusted Jan
= 19.8897 = (92) MIT 19.1897 + 0.7000; cascade (93) Jan = 19.21
(= (92) with no adjustment). The missing +0.7 K on internal
temperature drives a ~10% undercount in §8 SH demand
(cascade 10083 kWh vs worksheet 11088 kWh annual).
"""
# Arrange — route corpus electric variants through the Elmhurst
# extractor → mapper chain and exercise the cascade's
# `_control_temperature_adjustment_c` dispatch via the public
# `cert_to_inputs` interface. Each variant lodges a distinct
# `main_heating_control` (Table 4e code) so the adjustment
# dispatch is observable end-to-end.
import re
import subprocess
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_control_temperature_adjustment_c, # pyright: ignore[reportPrivateUsage]
)
def _epc_for(variant: str):
corpus = (
Path(__file__).parents[4]
/ "sap worksheets/heating systems examples"
/ variant
)
summary_pdf = next(corpus.glob("Summary_*.pdf"))
info = subprocess.run(
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
).stdout
pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr]
pages: list[str] = []
for i in range(1, pc + 1):
layout = subprocess.run(
["pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(summary_pdf), "-"],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
epc_e3 = _epc_for("electric 3") # control 2401 → +0.7
epc_e5 = _epc_for("electric 5") # control 2402 → +0.4
epc_e8 = _epc_for("electric 8") # control 2404 → 0
epc_pcdb1 = _epc_for("pcdb 1") # control 2105 → 0
# Act
adj_e3 = _control_temperature_adjustment_c(epc_e3.sap_heating.main_heating_details[0])
adj_e5 = _control_temperature_adjustment_c(epc_e5.sap_heating.main_heating_details[0])
adj_e8 = _control_temperature_adjustment_c(epc_e8.sap_heating.main_heating_details[0])
adj_pcdb1 = _control_temperature_adjustment_c(epc_pcdb1.sap_heating.main_heating_details[0])
# Assert
assert abs(adj_e3 - 0.7) < 1e-9, (
f"electric 3 main_heating_control=2401 (Manual charge control): "
f"got {adj_e3!r}, want +0.7 per SAP 10.2 Table 4e Group 4"
)
assert abs(adj_e5 - 0.4) < 1e-9, (
f"electric 5 main_heating_control=2402 (Automatic charge control): "
f"got {adj_e5!r}, want +0.4 per SAP 10.2 Table 4e Group 4"
)
assert adj_e8 == 0.0, (
f"electric 8 main_heating_control=2404 (HHR controls): "
f"got {adj_e8!r}, want 0 per SAP 10.2 Table 4e Group 4"
)
assert adj_pcdb1 == 0.0, (
f"pcdb 1 main_heating_control=2105 (Programmer + ≥2 room "
f"thermostats): got {adj_pcdb1!r}, want 0 per SAP 10.2 "
f"Table 4e Group 1"
)
def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None:
"""SAP 10.2 §4 line 7693 (PDF p.137):