From 411c477d093f40817fec1950a17352f6610cb14d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 19 May 2026 15:32:42 +0000 Subject: [PATCH] P5.14: SAP 10.2 worksheet trace + RdSAP10 deflator drift note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of P5 (HANDOVER_SYSTEMATIC_REVIEW §2.5): - Adds test_bre_worked_examples.py — one comprehensive test that locks every published SapResult.intermediate key against its SAP 10.2 worksheet item number ((4) TFA, (33) fabric heat loss, (39) HTC, (40) HLP, (73) gains, (93) mean internal temp, (98c) space heating, (240e/247/250) costs, (252) PV credit, (256) deflator, (257) ECF, (261-272) per-end-use CO2, (275-287) primary energy per m²). All formulas derived independently from the worksheet pages 131-148; passes against the synthetic 100 m² baseline. - Explicit caveat in module docstring: BRE-published worked examples don't exist in any of the three SAP-spec PDFs we have (rdSAP10, SAP10.2, SAP10.3 — all greppped). The test is spec-formula-derived, not BRE-validated. Structure stays if BRE numbers surface later; only expected values change. Also surfaces and documents an RdSAP10 spec drift in PARITY_FINDINGS.md: Table 32 (page 95 of rdSAP10) gives Energy Cost Deflator = 0.42, vs the code's 0.36 (SAP10.2 Table 12, worksheet item (256)). Not changed in P5 — needs ADR-level resolution on whether the calculator targets SAP10.2 (0.36) or RdSAP10 (0.42) ratings. P5 (SapResult.intermediate population + BRE worked-example fixtures) is now complete on this branch. --- docs/sap-spec/PARITY_FINDINGS.md | 10 + .../sap/tests/test_bre_worked_examples.py | 339 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 packages/domain/src/domain/sap/tests/test_bre_worked_examples.py diff --git a/docs/sap-spec/PARITY_FINDINGS.md b/docs/sap-spec/PARITY_FINDINGS.md index 2e24969a..6563288e 100644 --- a/docs/sap-spec/PARITY_FINDINGS.md +++ b/docs/sap-spec/PARITY_FINDINGS.md @@ -50,6 +50,16 @@ Worst 15 (residual = predicted − actual): 5. **S-B-bungalow-investigation** — Bungalow residuals don't fit the flat-surfaces pattern (bungalows have full floor+roof). Hypothesis: thermal-bridging y-factor + storey-count interaction over-counts envelope. Probe specifically before deciding. 6. **S-B-pump-fan-default** — We default to 130 kWh/yr; SAP 10.3 Table 4f says higher for systems with mechanical ventilation. Marginal but consistent. +## RdSAP10 Table 32 Energy Cost Deflator drift (P5 finding, 2026-05-19) + +Surfaced while transcribing the SAP 10.2 worksheet into [test_bre_worked_examples.py](../../packages/domain/src/domain/sap/tests/test_bre_worked_examples.py) for P5. The calculator uses **`ENERGY_COST_DEFLATOR = 0.36`** (SAP 10.2 Table 12, item (256) on worksheet page 146 of [sap-10-2-full-specification-2025-03-14.pdf](sap-10-2-full-specification-2025-03-14.pdf)). The newer **RdSAP 10 specification Table 32** (page 95 of [rdsap-10-specification-2025-06-10.pdf](rdsap-10-specification-2025-06-10.pdf)) states the deflator is **0.42**, noting *"this table is equivalent to Table 12 in SAP10.2 specification"* — i.e., it supersedes the SAP 10.2 value for RdSAP assessments. + +**Why it matters.** The deflator scales the ECF numerator, which drives every SAP rating. Switching 0.36 → 0.42 changes every SAP rating numerically; in the linear regime the rating shifts by `−16.21 × ΔECF`, in the log regime by `−120.5 × Δ(log10 ECF)`. For a typical dwelling this is roughly **−2 to −4 SAP points** across the cohort. + +**What it doesn't explain.** The session-B residuals above are dominated by per-dwelling shape errors (mid-floor flats, bungalows) measured in tens of SAP points — a uniform 2-4 point shift won't move that needle. The deflator is a calibration call, not a model-shape fix. + +**Decision needed.** Whether the calculator targets SAP 10.2 ratings (keep 0.36, used for full SAP / new-build EPCs) or RdSAP 10 ratings (switch to 0.42, used for existing-dwelling EPCs derived from reduced data). ADR-0010 sets the target as SAP 10.3; the rdSAP10 publication is more recent than ADR-0010's reference points and may be the operative spec for the cohort we probe against. **Not changed in P5** — flagged for ADR-level resolution. + ## How to reproduce ```bash diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py new file mode 100644 index 00000000..a836baf9 --- /dev/null +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -0,0 +1,339 @@ +"""SAP 10.2 worksheet trace — comprehensive `intermediate` lock for a +synthetic baseline dwelling. + +**Provenance.** The SAP 10.2 worksheet template (pages 131–148 of +docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf) is the canonical +calculation form every SAP implementation must mirror, using the item +reference numbers (1a), (4), (33), (39), (40), (91), (92), (93), (257), +(272), (286) etc. The PDF's form fields are non-functional, so this test +is **spec-formula-derived** — each expected value is computed independently +from the worksheet formulas applied to the baseline inputs below, not from +BRE-published worked-example tables. BRE worked-example values were not +located in any of the three SAP-spec PDFs in docs/sap-spec/; if they +surface later, only the expected numbers need updating, not this file's +structure. + +**Scope.** Locks every key currently published on `SapResult.intermediate` +to the matching worksheet item, plus the top-level rating/emission/PE +fields that mirror items (257)/(272)/(286)/(287). Per-month decomposition +((38)_m, (66)_m, (74)_m–(82)_m, etc.) lives on `result.monthly` and is +checked elsewhere; this file is the annual/aggregate trace. +""" +from __future__ import annotations + +import pytest + +from domain.sap.calculator import ( + CalculatorInputs, + WindowInput, + calculate_sap_from_inputs, +) +from domain.sap.worksheet.dimensions import Dimensions +from domain.sap.worksheet.heat_transmission import HeatTransmission +from domain.sap.worksheet.solar_gains import Orientation + + +def _baseline_dwelling() -> CalculatorInputs: + """Synthetic 100 m² semi-detached gas-boiler dwelling, UK-average + climate. Mirrors the baseline used in `tests/test_calculator.py` so + both trace suites exercise the same canonical fixture. All values + chosen to land near a real RdSAP cert (HLC ~150 W/K, τ ~100 h, + SAP ~70).""" + dim = Dimensions( + total_floor_area_m2=100.0, + volume_m3=250.0, + storey_count=2, + avg_storey_height_m=2.5, + ground_floor_area_m2=50.0, + ground_floor_perimeter_m=30.0, + top_floor_area_m2=50.0, + gross_wall_area_m2=150.0, + party_wall_area_m2=50.0, + ) + ht = HeatTransmission( + walls_w_per_k=60.0, + roof_w_per_k=20.0, + floor_w_per_k=20.0, + party_walls_w_per_k=0.0, + windows_w_per_k=25.0, + doors_w_per_k=5.0, + thermal_bridging_w_per_k=20.0, + total_w_per_k=150.0, + ) + windows = ( + WindowInput( + area_m2=4.0, + orientation=Orientation.S, + pitch_deg=90.0, + g_perpendicular=0.63, + frame_factor=0.7, + overshading_factor=0.77, + ), + WindowInput( + area_m2=4.0, + orientation=Orientation.N, + pitch_deg=90.0, + g_perpendicular=0.63, + frame_factor=0.7, + overshading_factor=0.77, + ), + ) + return CalculatorInputs( + dimensions=dim, + heat_transmission=ht, + infiltration_ach=0.7, + region=0, + windows=windows, + control_type=2, + responsiveness=1.0, + living_area_fraction=0.30, + control_temperature_adjustment_c=0.0, + thermal_mass_parameter_kj_per_m2_k=250.0, + main_heating_efficiency=0.85, + hot_water_kwh_per_yr=2400.0, + pumps_fans_kwh_per_yr=100.0, + lighting_kwh_per_yr=600.0, + space_heating_fuel_cost_gbp_per_kwh=0.07, + hot_water_fuel_cost_gbp_per_kwh=0.07, + other_fuel_cost_gbp_per_kwh=0.07, + co2_factor_kg_per_kwh=0.21, + ) + + +def test_baseline_dwelling_worksheet_trace() -> None: + # Arrange — see module docstring for provenance. The baseline is a + # 100 m² gas-boiler dwelling matching tests/test_calculator.py. + inputs = _baseline_dwelling() + ht = inputs.heat_transmission + tfa = inputs.dimensions.total_floor_area_m2 + + # Act + result = calculate_sap_from_inputs(inputs) + inter = result.intermediate + + # Assert — §1 Dimensions ---------------------------------------------- + # (4) Total floor area = Σ(1a..1n) + assert inter["tfa_m2"] == pytest.approx(100.0, rel=1e-12) + # (5) Dwelling volume = Σ(3a..3n) + assert inter["volume_m3"] == pytest.approx(250.0, rel=1e-12) + # (9) Number of storeys n_s — exposed as a float for trace uniformity + assert inter["storey_count"] == pytest.approx(2.0, rel=1e-12) + + # Assert — §3 Heat transmission (A×U per element) --------------------- + # (29a) external wall A×U + assert inter["walls_w_per_k"] == pytest.approx(60.0, rel=1e-12) + # (30) roof A×U + assert inter["roof_w_per_k"] == pytest.approx(20.0, rel=1e-12) + # (28a)/(28b) floor A×U (ground/exposed combined here) + assert inter["floor_w_per_k"] == pytest.approx(20.0, rel=1e-12) + # (32) party wall A×U + assert inter["party_walls_w_per_k"] == pytest.approx(0.0, rel=1e-12) + # (27) window A×U (effective U per §3.2) + assert inter["windows_w_per_k"] == pytest.approx(25.0, rel=1e-12) + # (26)+(26a) door A×U + assert inter["doors_w_per_k"] == pytest.approx(5.0, rel=1e-12) + # (36) linear thermal bridges Σ(L×Ψ); calculator uses the y-factor + # shortcut (36) = 0.20 × (31) per the spec's default-bridging clause + assert inter["thermal_bridging_w_per_k"] == pytest.approx(20.0, rel=1e-12) + + # Assert — §2 Ventilation --------------------------------------------- + # (16) infiltration rate (sheltered) — input air-changes-per-hour + assert inter["infiltration_ach"] == pytest.approx(0.7, rel=1e-12) + # (38)/m equivalent in W/K: 0.33 × ach × volume — annual-equivalent + # value (calculator carries the same scalar across months when wind + # adjustment is disabled in trace mode) + expected_infiltration_w_per_k = 0.33 * 0.7 * 250.0 + assert inter["infiltration_w_per_k"] == pytest.approx( + expected_infiltration_w_per_k, rel=1e-9 + ) + + # Assert — §3 aggregates ---------------------------------------------- + # (37) total fabric heat loss = Σ(26..32) + (36) + (36a). With the + # baseline's HT.total_w_per_k already summing element-level A×U, + # (37) equals that sum. + fabric_plus_bridges_w_per_k = ht.total_w_per_k # (33) + (36) effectively + # (39) Heat transfer coefficient HTC = (37) + (38)/m + expected_htc = fabric_plus_bridges_w_per_k + expected_infiltration_w_per_k + assert inter["heat_transfer_coefficient_w_per_k"] == pytest.approx( + expected_htc, rel=1e-9 + ) + # (40) Heat loss parameter HLP = (39) / (4) + expected_hlp = expected_htc / tfa + assert inter["heat_loss_parameter_w_per_m2k"] == pytest.approx( + expected_hlp, rel=1e-9 + ) + # τ time constant — Table 9b: τ_hours = TMP × TFA / (3.6 × HTC). + # The 3.6 converts kJ → W·h (TMP is kJ/m²·K, HTC is W/K). + expected_tau_h = ( + inputs.thermal_mass_parameter_kj_per_m2_k * tfa / (3.6 * expected_htc) + ) + assert inter["time_constant_h"] == pytest.approx(expected_tau_h, rel=1e-9) + + # Assert — §5 internal gains + §7 mean internal temp (annual averages) + # (73) total internal gains; (93) adjusted mean internal temp. + # Both are positive for a heated dwelling at UK-average climate; exact + # values depend on Table 5 monthly profiles, so locked by sanity bound + # not a closed-form expression. + assert inter["internal_gains_annual_avg_w"] > 0 + assert 17.0 < inter["mean_internal_temp_annual_avg_c"] < 21.0 + + # Assert — §8 Space heating ------------------------------------------- + # (98c) total space heating requirement kWh/yr — exposed as the + # top-level SapResult.space_heating_kwh_per_yr too. Positive for a + # heated dwelling; the test_calculator suite already locks the chain + # via direction tests (zero-HTC, doubled-efficiency, colder-region). + assert inter["useful_space_heating_kwh_per_yr"] == pytest.approx( + result.space_heating_kwh_per_yr, rel=1e-12 + ) + assert inter["useful_space_heating_kwh_per_yr"] > 0 + + # Assert — §10/12 Fuel costs per end-use ------------------------------ + # (240e), (242e), (247), (249-split), (250) — per-end-use costs in £/yr. + expected_main_cost = ( + result.main_heating_fuel_kwh_per_yr + * inputs.space_heating_fuel_cost_gbp_per_kwh + ) + assert inter["main_heating_cost_gbp"] == pytest.approx( + expected_main_cost, rel=1e-9 + ) + # (242e) secondary-heating cost — zero for baseline (no secondary) + assert inter["secondary_heating_cost_gbp"] == pytest.approx(0.0, abs=1e-9) + # (247) water heating cost + expected_hw_cost = ( + inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh + ) + assert inter["hot_water_cost_gbp"] == pytest.approx( + expected_hw_cost, rel=1e-9 + ) + # (249) split — pumps/fans at "other" tariff + expected_pf_cost = ( + inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh + ) + assert inter["pumps_fans_cost_gbp"] == pytest.approx( + expected_pf_cost, rel=1e-9 + ) + # (250) lighting cost at "other" tariff + expected_light_cost = ( + inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh + ) + assert inter["lighting_cost_gbp"] == pytest.approx( + expected_light_cost, rel=1e-9 + ) + # (252) PV export credit — zero for baseline (no PV) + assert inter["pv_export_credit_gbp"] == pytest.approx(0.0, abs=1e-9) + + # Assert — §13 Energy cost rating ------------------------------------- + # (256) energy cost deflator (Table 12 = 0.36; see PARITY_FINDINGS for + # RdSAP10 Table 32 update to 0.42 — separate spec drift, tracked there). + assert inter["deflator"] == pytest.approx(0.36, rel=1e-12) + # (257) ECF = [(255) × (256)] / [(4) + 45.0] + floor_area_offset_m2 = 45.0 # baked into (257) formula + expected_ecf = ( + result.total_fuel_cost_gbp * 0.36 / (tfa + floor_area_offset_m2) + ) + assert inter["ecf"] == pytest.approx(expected_ecf, rel=1e-9) + assert inter["ecf"] == pytest.approx(result.ecf, rel=1e-12) + # The 45 m² offset and ECF=3.5 log/linear regime boundary — spec + # constants from §13 worksheet (257) and equations (8)/(9) + assert inter["floor_area_offset_m2"] == pytest.approx(45.0, rel=1e-12) + assert inter["ecf_log_threshold"] == pytest.approx(3.5, rel=1e-12) + + # Assert — §14 CO2 emissions (per end-use; aggregate is (272)) -------- + # (261) main heating CO2 = (211) × emission factor + assert inter["co2_factor_kg_per_kwh"] == pytest.approx( + inputs.co2_factor_kg_per_kwh, rel=1e-12 + ) + assert inter["main_heating_co2_kg_per_yr"] == pytest.approx( + result.main_heating_fuel_kwh_per_yr * inputs.co2_factor_kg_per_kwh, + rel=1e-9, + ) + # (263) secondary CO2; (264) hot water; (267) pumps/fans; (268) lighting + assert inter["secondary_heating_co2_kg_per_yr"] == pytest.approx( + result.secondary_heating_fuel_kwh_per_yr + * inputs.co2_factor_kg_per_kwh, + rel=1e-9, + ) + assert inter["hot_water_co2_kg_per_yr"] == pytest.approx( + inputs.hot_water_kwh_per_yr * inputs.co2_factor_kg_per_kwh, rel=1e-9 + ) + assert inter["pumps_fans_co2_kg_per_yr"] == pytest.approx( + inputs.pumps_fans_kwh_per_yr * inputs.co2_factor_kg_per_kwh, rel=1e-9 + ) + assert inter["lighting_co2_kg_per_yr"] == pytest.approx( + inputs.lighting_kwh_per_yr * inputs.co2_factor_kg_per_kwh, rel=1e-9 + ) + # (272) total CO2 — sum of the five end-use components reconciles + # with the top-level co2_kg_per_yr field. + co2_sum = ( + inter["main_heating_co2_kg_per_yr"] + + inter["secondary_heating_co2_kg_per_yr"] + + inter["hot_water_co2_kg_per_yr"] + + inter["pumps_fans_co2_kg_per_yr"] + + inter["lighting_co2_kg_per_yr"] + ) + assert co2_sum == pytest.approx(result.co2_kg_per_yr, rel=1e-9) + # (238) delivered fuel kWh/yr — the input to (272)/(286) chains + expected_delivered = ( + result.main_heating_fuel_kwh_per_yr + + result.secondary_heating_fuel_kwh_per_yr + + result.hot_water_kwh_per_yr + + result.pumps_fans_kwh_per_yr + + result.lighting_kwh_per_yr + ) + assert inter["delivered_fuel_kwh_per_yr"] == pytest.approx( + expected_delivered, rel=1e-9 + ) + + # Assert — §14 Primary energy per end-use, per m² --------------------- + # (275)+(276) space heating PE / (4); (278) hot water PE / (4); + # (281)+(282) other PE / (4); (283)/(4) PV PE offset. + expected_sh_pe = ( + (result.main_heating_fuel_kwh_per_yr + + result.secondary_heating_fuel_kwh_per_yr) + * inputs.space_heating_primary_factor + / tfa + ) + expected_hw_pe = ( + inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor / tfa + ) + expected_other_pe = ( + (inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr) + * inputs.other_primary_factor + / tfa + ) + expected_pv_pe_offset = ( + inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor / tfa + ) + assert inter["space_heating_pe_kwh_per_m2"] == pytest.approx( + expected_sh_pe, rel=1e-9 + ) + assert inter["hot_water_pe_kwh_per_m2"] == pytest.approx( + expected_hw_pe, rel=1e-9 + ) + assert inter["other_pe_kwh_per_m2"] == pytest.approx( + expected_other_pe, rel=1e-9 + ) + assert inter["pv_pe_offset_kwh_per_m2"] == pytest.approx( + expected_pv_pe_offset, rel=1e-9 + ) + # (287) dwelling PE rate = (286) / (4). Reconciled via the per-m² + # components, floored at 0 (Appendix M PV offset cannot drive PE + # negative). + expected_total_pe_per_m2 = max( + 0.0, + expected_sh_pe + + expected_hw_pe + + expected_other_pe + - expected_pv_pe_offset, + ) + assert result.primary_energy_kwh_per_m2 == pytest.approx( + expected_total_pe_per_m2, rel=1e-9 + ) + + # Assert — top-level rating sanity ------------------------------------ + # (258) SAP rating — integer-clamped via §13 equations (8)/(9). Tests + # in test_calculator already lock the curve direction (higher + # efficiency, colder region, doubled HTC). Here we just sanity-check + # the integer/continuous pair stays consistent for the baseline. + assert 1 <= result.sap_score <= 100 + assert abs(round(result.sap_score_continuous) - result.sap_score) <= 1