# SAP 10.2 / RdSAP 10 calculator — module overview Deterministic, bit-faithful replication of the RdSAP10 calculation engine. Validated against the 6 Elmhurst U985 worksheet PDFs at **abs=1e-4 on every line ref** for both the Rating cascade (UK-average climate, used for the published SAP rating + EI rating) and the Demand cascade (postcode climate via PCDB Table 172, used for the EPC's published Current Carbon, Current Primary Energy, and Fuel Bill). **Current state: 930/930 pins green** (768 rating + 90 demand + 72 e2e). This document is the public API + architecture reference. For fixture authoring see [`packages/domain/src/domain/sap/README.md`](../../packages/domain/src/domain/sap/README.md). --- ## 1. Public API Three entry points, all in `domain.sap.rdsap.cert_to_inputs`: ```python from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, # SAP rating + EI rating (UK-avg climate) cert_to_demand_inputs, # Current Carbon + Current PE (postcode climate) local_climate_for_cert, # postcode → PostcodeClimate (None on miss) ) from domain.sap.calculator import calculate_sap_from_inputs, SapResult ``` ### 1.1 Rating cascade — `cert_to_inputs(epc)` Produces a `CalculatorInputs` aggregate with UK-average climate. Feed it to `calculate_sap_from_inputs(inputs)` to get a `SapResult`: ```python inputs = cert_to_inputs(epc) result = calculate_sap_from_inputs(inputs) result.sap_score # int — published SAP rating (1-100+) result.sap_score_continuous # float — un-rounded result.ecf # Energy Cost Factor result.total_fuel_cost_gbp # Rating-cascade cost (NOT the EPC's Fuel Bill) ``` Per SAP10.2 Appendix U (p.124) only the SAP rating and EI rating use UK-average weather. Everything else (emissions, primary energy, fuel bill) the EPC publishes comes from the demand cascade below. ### 1.2 Demand cascade — `cert_to_demand_inputs(epc)` Same physics, postcode-district climate from PCDB Table 172: ```python inputs = cert_to_demand_inputs(epc) result = calculate_sap_from_inputs(inputs) result.co2_kg_per_yr # EPC's "Current Carbon" (tonnes/year ÷ 1000) result.primary_energy_kwh_per_yr # EPC's "Current Primary Energy" ``` Falls back to UK-average climate when `epc.postcode` is missing or the district is not in Table 172 (rural postcodes → no PCDB match). ### 1.3 Section helpers — `
_section_from_cert(epc, postcode_climate=...)` Each U985 worksheet section has a typed dataclass + a `_section_from_cert` helper. Use these for explicit line-ref pinning or to compose your own flow. The `postcode_climate` kwarg selects rating (None) vs demand (PostcodeClimate) cascade. | Helper | Returns | Pins | |---|---|---| | `dimensions_from_cert(epc)` | `Dimensions` | §1 (1)..(5) | | `ventilation_from_cert(epc, postcode_climate=...)` | `VentilationResult` | §2 (6a)..(25)m | | `heat_transmission_section_from_cert(epc)` | `HeatTransmission` | §3 (26)..(37) | | `water_heating_section_from_cert(epc)` | `WaterHeatingResult` | §4 (42)..(65)m | | `internal_gains_section_from_cert(epc)` | `InternalGainsResult` | §5 (66)..(73) | | `solar_gains_section_from_cert(epc, postcode_climate=...)` | `SolarGainsResult` | §6 (74)..(83) | | `mean_internal_temperature_section_from_cert(epc, postcode_climate=...)` | `MeanInternalTemperatureResult` | §7 (85)..(94) | | `space_heating_section_from_cert(epc, postcode_climate=...)` | `SpaceHeatingResult` | §8 (95)..(99) | | `space_cooling_section_from_cert(epc, postcode_climate=...)` | `SpaceCoolingResult` | §8c (100)..(108) | | `fabric_energy_efficiency_from_cert(epc)` | `float` | §8f (109) | | `energy_requirements_section_from_cert(epc, postcode_climate=...)` | `EnergyRequirementsResult` | §9a (201)..(221) | | `fuel_cost_section_from_cert(epc, postcode_climate=...)` | `FuelCostResult` | §10a (240)..(255) | | `sap_rating_section_from_cert(epc)` | `SapRatingSection` | §11a (256)..(258) — UK-avg only | | `environmental_section_from_cert(epc, postcode_climate=...)` | `EnvironmentalSection` | §12 (261)..(274) | | `primary_energy_section_from_cert(epc, postcode_climate=...)` | `PrimaryEnergySection` | §13a (275)..(286) | --- ## 2. The simulator use case The calculator is built for "what-if" analysis — modify cert inputs (e.g. upgrade wall insulation), re-run, observe the delta. The shape: ```python import dataclasses from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, local_climate_for_cert, environmental_section_from_cert, primary_energy_section_from_cert, ) from domain.sap.calculator import calculate_sap_from_inputs def dwelling_outputs(epc): """The 4 EPC-facing outputs for any cert. SAP and EI ratings use UK-average climate per Appendix U; Current Carbon and Current Primary Energy use postcode climate from PCDB Table 172.""" pc = local_climate_for_cert(epc) rating = calculate_sap_from_inputs(cert_to_inputs(epc)) env_rating = environmental_section_from_cert(epc) # UK-avg env_demand = environmental_section_from_cert(epc, postcode_climate=pc) pe_demand = primary_energy_section_from_cert(epc, postcode_climate=pc) return { "sap_rating": rating.sap_score, # UK-avg "ei_rating": env_rating.ei_rating_integer if env_rating else None, # UK-avg "current_carbon_kg": env_demand.total_co2_kg_per_yr if env_demand else None, # postcode "current_pe_kwh": pe_demand.total_pe_kwh_per_yr if pe_demand else None, # postcode } # Baseline baseline = dwelling_outputs(epc) # Counterfactual — fill the cavity upgraded_walls = [ dataclasses.replace(w, insulation_thickness_mm=50, wall_insulation_type=2) for w in epc.walls ] modified_epc = dataclasses.replace(epc, walls=upgraded_walls) upgraded = dwelling_outputs(modified_epc) print({k: upgraded[k] - baseline[k] for k in baseline}) # impact ``` Absolute values match the EPC; deltas reflect the modelled retrofit. --- ## 3. Architecture Two cascades stacked on a shared physics core: ``` cert: EpcPropertyData │ ┌──────────────────────────┼──────────────────────────┐ │ │ cert_to_inputs(epc) cert_to_demand_inputs(epc) (UK-avg climate, region 0) (postcode climate via PCDB Table 172) │ │ ▼ ▼ CalculatorInputs (rating) CalculatorInputs (demand) │ │ ▼ ▼ calculate_sap_from_inputs(inputs) calculate_sap_from_inputs(inputs) │ │ ▼ ▼ SapResult (rating) SapResult (demand) • sap_score • co2_kg_per_yr (EPC value) • sap_score_continuous • primary_energy_kwh_per_yr • ecf • space_heating_kwh_per_yr • total_fuel_cost_gbp • main_heating_fuel_kwh_per_yr • (more, all at postcode climate) ``` Climate is the only difference between the two cascades. Internally, the climate is plumbed through as either an `int` region index (0..21) or a `PostcodeClimate` instance (PCDB Table 172). Four functions in `domain.sap.climate.appendix_u` dispatch on `isinstance`: `external_temperature_c`, `wind_speed_m_per_s`, `horizontal_solar_irradiance_w_per_m2`, plus `_latitude_deg` in `worksheet/solar_gains.py`. ### Per-end-use CO2 and PE factors For the demand cascade's CO2 (§12) and PE (§13a) line refs: - Gas end-uses (main heating, water heating with a gas boiler) use the annual Table 12 / Table 32 (RdSAP10) factor — gas factors don't vary monthly. - Electricity end-uses (secondary heater, pumps/fans, lighting, electric shower, secondary heating with electric resistance) use the Σ(kWh_m × Table 12d_m) / Σ kWh_m **effective annual** factor — a Days-weighted average of the monthly factor by the per-end-use monthly kWh distribution. Same shape for PE (Table 12e). This is the slice-32 / slice-33 mechanism. See `_effective_monthly_factor` in `cert_to_inputs.py` for the helper and the per-end-use factor fields on `CalculatorInputs`. --- ## 4. File map ``` packages/domain/src/domain/sap/ ├── calculator.py # Top-level orchestrator (CalculatorInputs → SapResult) ├── README.md # Fixture authoring cookbook ├── rdsap/ │ └── cert_to_inputs.py # EpcPropertyData → CalculatorInputs (both cascades) ├── worksheet/ # Per-section physics modules (§1..§13a) │ ├── dimensions.py # §1 │ ├── ventilation.py # §2 │ ├── heat_transmission.py # §3 │ ├── water_heating.py # §4 │ ├── internal_gains.py # §5 │ ├── solar_gains.py # §6 │ ├── mean_internal_temperature.py # §7 │ ├── space_heating.py # §8 │ ├── space_cooling.py # §8c │ ├── fabric_energy_efficiency.py # §8f │ ├── energy_requirements.py # §9a │ ├── fuel_cost.py # §10a │ ├── rating.py # §11a + §14 EI rating equations │ ├── utilisation_factor.py # Table 9a η helper │ └── tests/ │ ├── _elmhurst_worksheet_NNNNNN.py # 6 conformance fixtures │ ├── _elmhurst_fixtures.py # ALL_FIXTURES registry │ ├── test_section_cascade_pins.py # THE conformance suite │ └── test_e2e_elmhurst_sap_score.py # Top-level SapResult pins ├── climate/ │ └── appendix_u.py # Tables U1/U2/U3 (UK-avg + 22 regions) └── tables/ ├── table_12.py # Fuel prices, CO2 factors, PE factors (annual + Table 12d/12e monthly) ├── table_12a.py # Off-peak high-rate fractions ├── table_32.py # RdSAP10 fuel prices (Table 32) └── pcdb/ ├── postcode_weather.py # PCDB Table 172 (postcode-district weather) ├── parser.py # PCDB row parsers └── (other PCDB tables) docs/sap-spec/ ├── sap-10-2-full-specification-2025-03-14.pdf # SAP 10.2 spec ├── RdSAP 10 Specification 10-06-2025.pdf # RdSAP 10 spec ├── pcdb10.dat # PCDB raw data (Table 172 + others) ├── SAP_CALCULATOR.md # this file └── pcdb_table_*.jsonl # PCDB extracts per table ``` --- ## 5. Validation ### The 6 Elmhurst U985 fixtures Each fixture is a real-cert ground-truth captured from Elmhurst Energy's RdSAP tool. The pair of PDFs (`Summary_NNNNNN.pdf` cert + `U985-0001- NNNNNN.pdf` worksheet) gives us: - A full `EpcPropertyData` encoding (the `Summary` → fixture's `build_epc()`) - Every populated worksheet line ref `(1a)..(286)` to 4 d.p. (the `U985-...` PDF → fixture's `LINE_*` / `DEMAND_LINE_*` constants) The fixtures span the cert-shape variations we've seen in the wild: 1-2 extensions, room-in-roof present/absent, electric shower present, party-wall code variations, suspended timber floor quirks, etc. | Fixture | TFA | Notes | |---|---|---| | 000474 | 56.79 | Main + 2 extensions, gas combi | | 000477 | 77.58 | RR main-only, gas combi | | 000480 | 84.41 | Main + 1 extension + RR | | 000487 | 81.57 | RR + extension + alt wall, **electric shower** | | 000490 | 66.06 | Main + 1 extension | | 000516 | 90.54 | Main only, gas combi | ### Pin scoreboard ``` RATING CASCADE (UK-avg climate) §1 12/12 §2 96/96 §3 24/24 §4 54/54 §5 54/54 §6 12/12 §7 60/60 §8 36/36 §8c 42/42 §8f 6/6 §9a 72/72 §10a 192/192 §11a 24/24 §12 84/84 rating Σ = 768/768 DEMAND CASCADE (postcode climate) D§12 54/54 D§13a 36/36 demand Σ = 90/90 E2E SapResult pins sap_score, ecf, fuel_cost, co2, kwh fields 66/66 monthly_infiltration_ach 6/6 e2e Σ = 72/72 GRAND TOTAL = 930/930 ``` ### How to run ```bash # Full SAP calculator suite (cascade pins + e2e + helpers) python -m pytest packages/domain/src/domain/sap/ --no-cov # Cascade pins only (the conformance suite) python -m pytest \ packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py \ packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py \ --no-cov --no-header --tb=no -q ``` ### Hard rules These are non-negotiable per `[[feedback-zero-error-strict]]` / `[[feedback-e2e-validation-philosophy]]`: - `abs=1e-4` on every pin. **No `rel=…` tolerances, no widening, no xfail.** - A failing pin is a real calculator bug or fixture defect — diagnose before relaxing. - Audit the fixture against the PDF **first** when a cascade pin fails (many lodgements have been incomplete). - `_round_half_up` at §15 RdSAP boundaries — never Python's banker's `round()`. - Cascade pins walk the real cert→inputs cascade end-to-end. Don't isolate sections using PDF values as inputs. --- ## 6. Adding a new conformance fixture See [`packages/domain/src/domain/sap/README.md#adding-a-new-elmhurst-conformance-fixture`](../../packages/domain/src/domain/sap/README.md#adding-a-new-elmhurst-conformance-fixture) for the step-by-step cookbook. Summary: 1. Drop a fixture module at `worksheet/tests/_elmhurst_worksheet_NNNNNN.py` 2. Mirror the `Summary_NNNNNN.pdf` into `build_epc()` 3. Capture every populated worksheet line as `LINE_*` (Block 1, rating cascade) + `DEMAND_LINE_*` (Block 2, demand cascade) constants 4. Register in `_elmhurst_fixtures.py` 5. Pins should all pass; if they don't, audit the fixture before blaming the calculator. --- ## 7. Spec references at hand ``` SAP 10.2 (14-03-2025): §7 Mean internal temperature p.28-32 §13 SAP rating equations p.38-39 §14 EI rating + Primary Energy p.43-44 Appendix J §2a Nbath p.81 Appendix J §8 electric shower p.82 Table J4 (shower flow/power) p.83 Table J5 (behavioural fbeh) p.83 Table 3a/3b/3c (HW combi loss) p.160-162 Table 9a/9b/9c (heating + utilisation) p.183-185 Table 12 (price/CO2/PEF annual) p.191 Table 12a (off-peak high-rate) p.191-192 Table 12d (monthly CO2 for electricity) p.194 Table 12e (monthly PE for electricity) p.195 Appendix U §U1/U2/U3 (region tables) p.124-127 Appendix U paragraph 1 (rating vs demand) p.124 RdSAP 10 (10-06-2025): §3.1 precision rule p.16 §3.6 wall area p.19 §3.7.1 window area p.20 §3.8 roof area (max-floor) p.20 §3.9 RR simplified p.21 §3.10 RR detailed p.21 Table 4 (RR gable walls) p.22 §5.12 + Table 19 floor U p.46 §5.13 + Table 20 exposed floor p.47 §5.17 + Table 23 basement p.48 §5.18 curtain wall p.48 Table 24 (window U) p.50 §9.2 + Table 27 living area p.52 §15 rounding rules p.66 §19.2 RdSAP10 CO2/PE = SAP10.2 Table 12 p.94 Table 32 (fuel prices, CO2, PEF) p.95 Table 11 (secondary fraction) p.188 Table 12a (standing/off-peak) p.191 PCDB10: Table 105 (gas/oil boilers) docs/sap-spec/pcdb_table_105_... Table 172 (postcode-district weather) docs/sap-spec/pcdb10.dat ```