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

16 KiB
Raw Blame History

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 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

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