The SAP 10.2 / RdSAP 10 calculator is closed at 930/930 pin tests green.
Tidying the docs for hand-off to the API-integration agent.
New: docs/sap-spec/SAP_CALCULATOR.md
Canonical module overview — public API surface, two-cascade
architecture (Rating UK-avg, Demand postcode), simulator-use-case
example, file map, validation contract + hard rules, fixture cohort
notes, spec page references. Replaces the scattered "what's the
shape" knowledge that was previously only in commit messages.
Rewritten: docs/sap-spec/HANDOVER_NEXT.md
Old handover (work queue for slices 26-36) is obsolete. Replaced
with the next agent's brief: build an API → SAP scoring integration
test using the 6 Elmhurst fixtures. Includes a copy-paste reference
scoring path, expected outputs per fixture, list of files to read
on day 1, and scope guardrails.
Refreshed module docstrings:
- cert_to_inputs.py: now describes both cascades, the deferred-edge-
case list reflects current state (RR/secondary/§15 living-area
rounding all DONE; thermal-mass and control-temp adjustment still
deferred).
- calculator.py: per-end-use CO2/PE factor machinery documented;
stale "single-fuel approximation" claim removed (closed in slice 32).
- sap/README.md: validation paragraph now says "930/930 green" and
points to SAP_CALCULATOR.md instead of the obsolete HANDOVER_NEXT.
Verified the API examples in both docs produce the expected per-fixture
outputs (SAP=62, EI=60, Carbon=3104.1222, PE=16931.7227 for 000474).
Wider regression: 1585/1585 PASS, zero failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
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.
1. Public API
Three entry points, all in domain.sap.rdsap.cert_to_inputs:
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:
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:
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>_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:
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
EpcPropertyDataencoding (theSummary→ fixture'sbuild_epc()) - Every populated worksheet line ref
(1a)..(286)to 4 d.p. (theU985-...PDF → fixture'sLINE_*/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
# 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-4on every pin. Norel=…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_upat §15 RdSAP boundaries — never Python's banker'sround().- 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
for the step-by-step cookbook. Summary:
- Drop a fixture module at
worksheet/tests/_elmhurst_worksheet_NNNNNN.py - Mirror the
Summary_NNNNNN.pdfintobuild_epc() - Capture every populated worksheet line as
LINE_*(Block 1, rating cascade) +DEMAND_LINE_*(Block 2, demand cascade) constants - Register in
_elmhurst_fixtures.py - 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