mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
375 lines
16 KiB
Markdown
375 lines
16 KiB
Markdown
# 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
|
||
```
|