Model/docs/sap-spec/SAP_CALCULATOR.md
Khalim Conn-Kowlessar d44af109a9 Docs: SAP calculator module README + API integration test handover
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>
2026-05-24 10:04:34 +00:00

375 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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>_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
```