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