Model/domain/sap10_calculator/docs/SAP_CALCULATOR.md
Khalim Conn-Kowlessar 1382c8c886 docs: add AGENT_GUIDE.md — fresh-start onboarding for the SAP calculator
A single durable doc so agents can pick up the calculator without reading
historical handovers: (1) the accuracy bar for the two input paths
(site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5
register fallback otherwise; cross-mapper parity); (2) the per-line-walk
debugging loop incl. comparing site-notes vs API; (3) the tools &
pipeline (Summary PDF → extractor → from_elmhurst_site_notes →
cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API
from_api_response front-end, section helpers, and where the test vectors
live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as
point-in-time notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:32:29 +00:00

24 KiB
Raw Permalink Blame History

SAP 10.2 / RdSAP 10 calculator — module overview

New here? Start with AGENT_GUIDE.md — the accuracy bar (site-notes vs API), the debugging loop, and the tools/pipeline. This file is the deeper architecture + API reference; the HANDOVER_* files are point-in-time session notes, not onboarding.

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: 941/941 pins green (rating + demand section cascade pins via test_section_cascade_pins.py, plus e2e SapResult + monthly infiltration ACH pins via test_e2e_elmhurst_sap_score.py).

This document is the public API + architecture reference. For fixture authoring see domain/sap10_calculator/README.md.


1. Public API

Three entry points, all in domain.sap10_calculator.rdsap.cert_to_inputs:

from domain.sap10_calculator.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.sap10_calculator.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.sap10_calculator.rdsap.cert_to_inputs import (
    cert_to_inputs, local_climate_for_cert,
    environmental_section_from_cert, primary_energy_section_from_cert,
)
from domain.sap10_calculator.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.sap10_calculator.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

domain/sap10_calculator/
├── 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)

domain/sap10_calculator/docs/specs/
├── 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 domain/sap10_calculator/ --no-cov

# Cascade pins only (the conformance suite)
python -m pytest \
  domain/sap10_calculator/worksheet/tests/test_section_cascade_pins.py \
  domain/sap10_calculator/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 domain/sap10_calculator/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)              domain/sap10_calculator/docs/specs/pcdb_table_105_...
  Table 172 (postcode-district weather)    domain/sap10_calculator/tables/pcdb/data/pcdb10.dat

8. Elmhurst-mirrored spec divergences

The calculator's contract is bit-faithful replication of the BRE-approved Elmhurst rdSAP engine, not literal compliance with the SAP 10.2 spec text. The two coincide >99% of the time, but in a few places the worksheet PDFs from Elmhurst lodge a value that the spec text — read in isolation — would call wrong. We mirror the engine in those cases and document the divergence here.

Trigger to ADD a row: cascade matches spec literal interpretation, but worksheet PDF disagrees, AND the worksheet PDF value is reproducible across multiple Elmhurst-lodged certs (i.e. it's the engine's behaviour, not a one-off lodging defect). Per feedback-software-no-special-handling / feedback-spec-floor-skepticism verify both the worksheet PDF and the cascade output before adding.

8.1 HW PE/CO2 factors on dual-rate tariffs use Table 12 annual, not Table 12e/12d monthly

Slice: S0380.163. Code: _hot_water_primary_factor, _hot_water_co2_factor_kg_per_kwh. Test: test_electric_water_heating_factors_use_annual_table_12_on_dual_rate_tariff.

SAP 10.2 Table 12 footnote (t) (PDF p.189) reads:

PE factors for grid electricity vary by month. The average figure given in this table is therefore not used directly. Instead the monthly factors given in Table 12e should be used in the SAP worksheet.

(Footnote (s) says the same for CO2 / Table 12d.) Read literally this applies to every electric end-use including dual-rate HW. The cascade originally followed the literal reading: Σ(HW_m × F_m_12e) / ΣHW_m = ~1.521 PE for 18-hour HW on a winter-skewed demand profile.

The Elmhurst worksheet ((278) "Water heating (low-rate cost)") uses 1.5010 PE / 0.136 CO2 — the Table 12 ANNUAL row — on every dual-rate tariff cert in the 41-variant controlled-variable corpus. The engine applies monthly Table 12e for lighting (1.5338 winter-weighted) and secondary heating (1.5715) on the same certs, but flat Table 12 for the "low-rate cost" line items (SH main 1 + HW). It's an Elmhurst implementation choice, not a documented spec exception.

Cascade rule (post-S0380.163):

Tariff HW PE / CO2 factor source
STANDARD Table 12e / 12d monthly, weighted by HW demand seasonality (per spec literal)
7-hour / 10-hour / 18-hour / 24-hour Table 12 annual flat (1.501 PE / 0.136 CO2)

The SH main factor (_main_heating_primary_factor) already matches Elmhurst by accident: for dual-rate tariffs the _table_12a_system_for_main lookup returns None for storage heaters / electric direct-acting / electric boilers without PCDB → falls through to primary_energy_factor(fuel) annual. STANDARD tariff goes through the monthly cascade.

Cohort impact

The 41-variant heating-systems corpus closed its HW PE/CO2 residual on 18 variants (all dual-rate electric HW: electric 1/2/3/5/6/7/8/9, solid fuel 4/5/6/7/8/9/10/11, ashp, gshp). Each variant moved from PE +25.51 or +48.66 → ±0.0000, CO2 +6.31 or +11.95 → ±0.0000. Cohort-1 ASHP certs (STANDARD tariff) and the 6 Elmhurst U985 fixtures (gas combi, STANDARD tariff) are unaffected — they continue to use the monthly cascade.

8.2 §12.4.4 back-boiler summer-immersion CO2/PE doubles the summer term

Slice: S0380.164. Code: _section_12_4_4_hw_blend. Tests: test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count, test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade.

SAP 10.2 §12.4.4 (PDF p.36-37) routes DHW through the boiler Oct-May and an electric immersion Jun-Sep for back-boiler combos (Table 4a codes 156 + 158). The spec-literal CO2/PE formula multiplies summer-immersion fuel by the Table 12d / 12e monthly cascade (per Table 12 footnotes (s)/(t)). The BRE-approved Elmhurst engine adds a SECOND term — summer_fuel × Table 12 ANNUAL electric factor — on top of the monthly cascade for the (264) HW CO2 and (278) HW PE worksheet lines on dual-rate tariffs. Same shape as §8.1 / S0380.163 but additive rather than substitutive.

Cascade rule (post-S0380.164):

Tariff §12.4.4 winter CO2 / PE §12.4.4 summer immersion CO2 / PE
STANDARD W_fuel × boiler_annual_factor Σ wh_summer_m × Table 12d/e monthly (spec literal)
7-hour / 10-hour / 18-hour / 24-hour W_fuel × boiler_annual_factor Σ wh_summer_m × Table 12d/e monthly + S_fuel × Table 12 annual electric (Elmhurst mirror)

Cost is computed cleanly per spec (W_fuel × boiler_price + S_fuel × off_peak_low_price) — the double-count quirk only affects the CO2 and PE factor lines.

Cohort impact

The heating-systems corpus has exactly one §12.4.4 fixture: solid fuel 2 (Table 4a code 158, anthracite, 18-hour tariff, 110 L cylinder + cyl thermostat). Pre-slice the cascade carried ΔCO2 = 93.10 kg/yr / ΔPE = 1027.51 kWh/yr — matching 684.55 kWh × 0.136 CO2 and 684.55 kWh × 1.501 PE to within rounding. Post-slice closes to ±0.0000 on all four metrics, completing the cohort closure at 25/25 cascade-OK variants EXACT vs the Elmhurst worksheet.

⚠ Single-cert evidence

The §12.4.4 divergence is documented here on one worksheet (SF2) because the corpus has no second §12.4.4 fixture (solid fuel 1 = code 156 is an empty folder). The math nonetheless matches the worksheet to within rounding and aligns with §8.1's S0380.163 mirror shape (Table 12 annual where spec literal says monthly), so the gate is implemented under the same dual-rate → annual on top of monthly discipline. If a second §12.4.4-eligible cert worksheet diverges from this rule it should be raised against this row before re-tuning.

8.3 Community-heating CHP uses Table 12f "flexible operation" by default

Slice S0380.182. For RdSAP-defaulted community heating with CHP (SAP code 302) that is not in the PCDB, the displaced-electricity credit (worksheet (364)/(366) CO2 and (464)/(466) PE) needs a Table 12f (PDF p.196) "fuel factor for electricity generated by CHP". Table 12f offers three regimes per CHP vintage:

Regime CO2 kg/kWh PE Note
export only 0.394 2.345
flexible operation 0.420 2.369 needs assessor evidence
standard 0.348 2.149 "all other operating regimes"

Table 12f's own notes make standard the default ("Standard ... should be used for all other operating regimes of gas CHP plants") and require submitted evidence for flexible. Yet the BRE-approved Elmhurst rdSAP engine emits 0.420 / 2.369 (flexible) for these RdSAP-defaulted community-CHP certs — verified line-by-line against the CH2 (gas) / CH4 (oil) / CH6 (coal) corpus worksheets (364)/(366)/(464)/(466), all of which carry 0.4200 CO2 and 2.3690 PE regardless of the community fuel. RdSAP 10 §C (p.58) is silent on the Table 12f regime, so this is an engine default not derivable from the spec text.

Per feedback-software-no-special-handling / feedback-worksheet-not-api-reference we mirror the engine: _TABLE_12F_CHP_FLEXIBLE_{CO2,PE} in cert_to_inputs. CH2 + CH4 close to <1e-4 on both CO2 and PE with this factor; "standard" (0.348/2.149) would leave a residual. If a future PCDB-listed or evidence-backed CHP cert diverges, raise it against this row before re-tuning.