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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 10:04:34 +00:00
parent 4da8a4703d
commit d44af109a9
5 changed files with 585 additions and 475 deletions

View file

@ -1,481 +1,194 @@
# Handover — §7 LINE_92/93 + §8§12 sweep to abs=1e-4 closure
# Handover — API → SAP integration test
**Goal: every line ref of every output for every one of the 6 Elmhurst
fixtures pins against the U985 worksheet PDF at abs=1e-4.**
The SAP 10.2 / RdSAP 10 calculator is **closed**: 930/930 pin tests
green against the 6 Elmhurst U985 worksheet PDFs (Rating cascade for
SAP rating + EI rating; Demand cascade for EPC Current Carbon +
Current Primary Energy). Architecture + public API live in
[`SAP_CALCULATOR.md`](./SAP_CALCULATOR.md) — **read that first.**
Owner: `khalim@domna.homes`. Branch: `ara-backend-design-prd`.
Spec PDFs in `docs/sap-spec/`: SAP 10.2 (14-03-2025), RdSAP 10 (10-06-2025), PCDF.
Your job: build an integration test that runs **API request → cert →
SAP scoring** end-to-end against this calculator, using the 6 Elmhurst
fixtures as the strongest test case in the repo.
---
## §A — Hard rules. Internalise before any code.
## What "done" looks like
### A.1 What this project IS
A test (probably under `backend/` somewhere, exact location TBD by
the codebase shape) that:
This repo replicates the **rdSAP calculation engine** to bit-level fidelity
against 6 known test vectors (the U985 Elmhurst worksheets):
1. Spins up the API (FastAPI or whatever the http surface is).
2. Sends a request with a representative `EpcPropertyData` payload
(use one of the 6 Elmhurst fixtures' `build_epc()` outputs as the
reference, or send the upstream JSON shape if that's the boundary).
3. Receives the 4 EPC-facing outputs back through whatever endpoint the
API exposes them on (or invokes the SAP scoring code path the API
would use internally).
4. Asserts the 4 outputs match the fixture's lodged values at the
stated tolerance:
- `sap_score` (integer, exact match)
- `ei_rating` (integer, exact match)
- `current_carbon_kg` (`abs=1e-4` against `DEMAND_LINE_272_TOTAL_CO2`)
- `current_pe_kwh` (`abs=1e-4` against `DEMAND_LINE_286_TOTAL_PE`)
- **Inputs**: `Summary_NNNNNN.pdf` (cert lodgement) for each of 6 fixtures
(000474, 000477, 000480, 000487, 000490, 000516).
- **Intermediate values**: `U985-0001-NNNNNN.{pdf,txt}` lodges every
worksheet line ref (1) through (282+) to 4 decimal places.
- **Final outputs**: SAP rating (continuous + integer), ECF, total fuel cost,
CO2, primary energy, per-end-use kWh.
It is a deterministic numerical function with fully-known test vectors.
### A.2 The bar: abs=1e-4 on EVERY pin
- The PDF lodges 4 d.p. display precision. abs=1e-4 is the floor of "match
what the PDF says".
- **NO `rel=…` tolerances.**
- **NO `<= 0.5` continuous SAP ceilings.**
- **NO `xfail` markers on cascade pins.**
- **NO "documented widening".**
A failing pin is a calculator bug or fixture defect. If you can't close
it in this slice, leave it failing — that's the next slice's work.
### A.3 Past mistakes — DO NOT REPEAT
1. **Treating SAP integer Δ=0 as "closed"** — that's a weak gate (hides ±0.5
continuous drift). The real gate is per-line-ref abs=1e-4.
2. **Widening tolerances** to make tests green.
3. **Testing sections in isolation** using `fixture.LINE_X` PDF values AS
INPUTS. The cascade test walks `cert_to_inputs(epc)`, NOT isolated calls.
4. **Missing fixture defects** — When a cascade pin fails, audit the
fixture against the PDF FIRST. Many lodgements have been incomplete.
5. **Diagnosing downstream first**. Cascade is upstream→downstream
(§1 → §2 → §3 → §4 → §5 → §6 → §7 → §8 → §9a → §10a → §11a → §12).
A downstream pin failure is meaningless to diagnose until upstream pins
close.
If you find yourself about to widen a tolerance, add an xfail, or skip a
fixture — **stop and ask the user.**
### A.4 Reporting format — matrix not prose
```
sec 474 477 480 487 490 516 total
--- ---- ---- ---- ---- ---- ---- -----
§1 2/2 2/2 2/2 2/2 2/2 2/2 12/12
§3 4/4 4/4 4/4 4/4 4/4 4/4 24/24
...
```
Or numeric residuals when finer granularity helps:
```
fixture | LINE_92 Δ | LINE_93 Δ
000474 | 0.00013 | 0.00013
000477 | 0.00016 | 0.00016
...
```
✓ = within abs=1e-4. Use this format instead of prose summaries.
### A.5 Workflow rules
- **Don't scan >50 lines of spec PDF without checking with the user** for
the page anchor. The user has the page references and prefers to give
them up-front rather than have you fumble through the spec.
- **One slice = one commit**. AAA test convention (`# Arrange / # Act /
# Assert`). Co-Authored-By trailer.
- **Don't touch SAP rating constants in `worksheet/rating.py`**
`ENERGY_COST_DEFLATOR=0.42`, `ECF_LOG_THRESHOLD=3.5`, `SAP_LOG_COEFF=113.7`,
`SAP_LOG_CONSTANT=117.0`. SAP 10.2 per ADR-0010, pinned by 8+ tests.
- **Don't auto-update unrelated `git status` entries**. The pre-existing
deletion of `docs/sap-spec/rdsap-10-specification-2025-06-10.pdf` and
the untracked `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf`
are stable; don't touch.
- **Don't invoke `/ultrareview`** — user-triggered only.
- **Terse prose.** No filler.
- **Delete `_TEMP.py` diagnostic files before commit.**
Parametrise the test over all 6 fixtures so any regression in the
plumbing fails loudly.
---
## §B — Current state
## What's in the box
### B.1 Cascade pin scoreboard (per-section)
### Public API (the only thing you need from the SAP module)
```
sec 474 477 480 487 490 516 total
--- ---- ---- ---- ---- ---- ---- -----
§1 2/2 2/2 2/2 2/2 2/2 2/2 12/12 ✓
§2 16/16 16/16 16/16 16/16 16/16 16/16 96/96 ✓
§3 4/4 4/4 4/4 4/4 4/4 4/4 24/24 ✓
§4 9/9 9/9 9/9 9/9 9/9 9/9 54/54 ✓
§5 9/9 9/9 9/9 9/9 9/9 9/9 54/54 ✓
§6 2/2 2/2 2/2 2/2 2/2 2/2 12/12 ✓
§7 8/10 8/10 8/10 10/10 8/10 10/10 52/60
---------- ------------------------------------------------------ -------
total 304/312 (97.4%)
```python
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs, # Rating cascade
cert_to_demand_inputs, # Demand cascade
local_climate_for_cert,
environmental_section_from_cert,
primary_energy_section_from_cert,
)
from domain.sap.calculator import calculate_sap_from_inputs, SapResult
```
**§1§6 fully close for all 6 fixtures (252/252).** Only §7 LINE_92/93
on 4 fixtures (000474/477/480/490) remains in the cascade.
See `SAP_CALCULATOR.md` §2 for the recommended `dwelling_outputs(epc)`
function shape — copy-paste it as your reference scoring path.
### B.2 SapResult pin matrix (e2e)
### Fixture cohort (the most comprehensive test case in the repo)
```
field | 474 | 477 | 480 | 487 | 490 | 516
-----------------------------------+-----+-----+-----+-----+-----+-----
sap_score (int) | ✓ | ✓ | ✓ | ✗ | ✓ | ✓
sap_score_continuous | ✗ | ✗ | ✗ | ✗ | ✗ | ✓
ecf | ✓ | ✓ | ✓ | ✗ | ✓ | ✓
total_fuel_cost_gbp | ✗ | ✗ | ✗ | ✗ | ✗ | ✗
co2_kg_per_yr | ✗ | ✗ | ✗ | ✗ | ✗ | ✗
space_heating_kwh_per_yr | ✗ | ✗ | ✗ | ✗ | ✗ | ✗
main_heating_fuel_kwh_per_yr | ✗ | ✗ | ✗ | ✗ | ✗ | ✗
secondary_heating_fuel_kwh_per_yr | ✓ | ✗ | ✗ | ✗ | ✗ | ✗
hot_water_kwh_per_yr | ✓ | ✗ | ✓ | ✓ | ✗ | ✗
lighting_kwh_per_yr | ✓ | ✓ | ✓ | ✓ | ✓ | ✓
pumps_fans_kwh_per_yr | ✓ | ✓ | ✓ | ✓ | ✓ | ✓
```
6 real-world certs with full PDF ground-truth:
27 SapResult pin PASS / 39 FAIL. Most downstream fails will close as
§8/§9a/§10a/§11a/§12 land. 000516 `sap_score_continuous` already
passes — a useful sanity check that the full cascade is consistent
when the upstream sections close.
| Fixture | TFA | Notable cert-shape features |
|---|---|---|
| `_elmhurst_worksheet_000474` | 56.79 | Main + 2 ext, gas combi, no secondary |
| `_elmhurst_worksheet_000477` | 77.58 | RR main-only, electric secondary |
| `_elmhurst_worksheet_000480` | 84.41 | Main + ext + RR, electric secondary |
| `_elmhurst_worksheet_000487` | 81.57 | RR + ext + alt-wall, **electric shower** |
| `_elmhurst_worksheet_000490` | 66.06 | Main + ext |
| `_elmhurst_worksheet_000516` | 90.54 | Main only |
### B.3 Recent slices (in reverse order — newest first)
Each fixture exposes:
- `build_epc() -> EpcPropertyData` — encode the cert as our domain type
- `LINE_*` — rating-cascade worksheet expected values (Block 1)
- `DEMAND_LINE_*` — demand-cascade worksheet expected values (Block 2)
- `SAP_VALUE_CONTINUOUS` / `LINE_258_SAP_RATING_INTEGER` — SAP rating
- `LINE_274_EI_RATING_INTEGER` — EI rating
```
25d: 000487 §4 LINE_65 closure — derive (64a) electric-shower kWh from cert (App J step 8, p.82)
25c: 000477 §4/§5/§6 closure — SAP10.2 Table 3c (p.162) M+L lower bound 100.0 → 100.2
25b: 000487 §4 LINE_43-64 closure — has_electric_shower + Appendix J step 2a Nbath branch
25a: 000487 §3 full closure — RR detailed surfaces + gable_wall_external + §3.8 max-floor roof + half-up rounding
26c: §7 mean internal temp cascade pin (60 cases, 52 PASS)
26b: §6 solar gains cascade pin + SapRoofWindow solar attrs + plumb to §6 cascade
26: §5 internal gains cascade pin + rooflight daylight plumb
27b: §3 element-area rounding to 2 d.p. per RdSAP10 §15 (p.66)
27: BS EN ISO 13370 floor U rounded to 2 d.p. per RdSAP10 §5.12 (p.46)
24: rooflight (line 27a) — SapRoofWindow datatype + 000516 cascade closure
```
Expected EPC outputs per fixture:
| | sap_score | ei_rating | current_carbon_kg | current_pe_kwh |
|---|---|---|---|---|
| 000474 | 62 | 60 | 3104.1222 | 16931.7227 |
| 000477 | 65 | 69 | 2879.7824 | 16545.4543 |
| 000480 | 61 | 65 | 3479.1552 | 19953.4189 |
| 000487 | 62 | 69 | 3005.2667 | 17755.3174 |
| 000490 | 57 | 61 | 3250.1703 | 18583.7962 |
| 000516 | 63 | 66 | 3501.4376 | 20087.8232 |
---
## §C — Work queue (priority order)
## What you'll need to investigate
### C.1 §7 LINE_92/93 marginal residual (8 fails, 4 fixtures)
The SAP calculator side is a pure-Python function chain — easy. The API
side is what you need to map out:
Per the matrix above. Diff is ~0.000100.00016 K per failing case — just
above the 1e-4 threshold. The PDF passes LINE_87 (T_living) and LINE_90
(T_elsewhere) for the same 4 fixtures, but the weighted combination
LINE_92 = `(91) × T_living + (1 - (91)) × T_elsewhere` drifts.
1. **Where does cert data enter the system?** Find the FastAPI / Django
/ whatever endpoint that accepts cert input. Look under `backend/`
for routers.
2. **What's the request payload shape?** Is it `EpcPropertyData` JSON
directly, or a different upstream representation that gets mapped?
Check `datatypes/epc/domain/mapper.py` — the mapper from various
schema versions (SAP-Schema-18/19, RdSAP-Schema-18) to
`EpcPropertyData` lives there.
3. **Is SAP scoring already wired to the API?** Search the backend for
imports of `domain.sap.rdsap.cert_to_inputs` or
`domain.sap.calculator`. If it's not yet wired, the integration test
is a forcing function for wiring it.
4. **What's the response shape?** The 4 outputs above are what the EPC
publishes; the API may already expose them, or may expose a wider
surface (per-section breakdown for retrofit modelling, etc.).
Hypotheses to test:
1. **PDF uses rounded T_living/T_elsewhere** at some precision higher than
4 d.p. but lower than full float in the weighted sum. The cascade pin
on LINE_87/90 passes at abs=1e-4 because both my full-precision and
the PDF's higher-precision values round to the same 4-d.p. display.
2. **PDF rounds LINE_92 to specific d.p. before later use**, but the
stored value doesn't quite match the in-memory full-precision combo.
3. **A spec-defined intermediate rounding step in §7 step 9** (RdSAP10
§15 doesn't list MIT in its rounding list — only U-values and areas).
Diagnostic: write a TEMP test that prints my T_living[m], T_elsewhere[m],
LINE_91, and computes the weighted sum at several precision levels (4 d.p.,
5 d.p., 6 d.p., full). Compare each to the PDF's LINE_92[m]. If 5-d.p.
matches the PDF for all 4 fixtures and 12 months, the rule is "round
T_living + T_elsewhere to 5 d.p. before combining". Ask the user for the
SAP10.2 §7 spec page (likely §9.3 or near, page ~28-32) before applying
any new rounding rule.
000516 + 000487 §7 already close at 10/10 — so the artefact isn't
universal. Compare their T_living[m] values against the failing fixtures
to spot the trigger pattern.
### C.2 §8 space heating cascade pin (lines 9599)
Fixtures lodge:
- `LINE_95_M_USEFUL_GAINS_W` (12-tuple)
- `LINE_97_M_HEAT_LOSS_RATE_W` (12-tuple)
- `LINE_98A_M_SPACE_HEATING_KWH` (12-tuple)
- `LINE_98C_M_TOTAL_SPACE_HEATING_KWH` (12-tuple, same as 98a for current fixtures)
- `LINE_98C_ANNUAL_KWH` (scalar)
- `LINE_99_PER_M2_KWH` (scalar)
§8 orchestrator: `domain.sap.worksheet.space_heating.space_heating_monthly_kwh`.
Section helper to add: `space_heating_section_from_cert(epc)` in
`cert_to_inputs.py`. Inputs needed: §7 (MIT + η_whole), §1 (TFA, volume),
§2 (effective_monthly_ach), §3 (total HLC), §5+§6 (total gains), climate.
Same composition pattern as `mean_internal_temperature_section_from_cert`.
Add pin tests at the end of `test_section_cascade_pins.py` mirroring the
`_SECTION_7_MONTHLY_PINS` shape.
### C.3 §8c space cooling cascade pin (lines 100108)
All 6 fixtures lodge `f_C=0` (no air conditioning), so:
- LINE_103 cooling gains = (0,)×12
- LINE_107 monthly cooling = (0,)×12
- LINE_107 annual = 0
- LINE_108 per m² = 0
LINE_101 utilisation factor collapses to 1.0 (γ ≤ 0 branch); LINE_106
intermittency monthly is the spec default mask. Fixture constants
`LINE_101_M_UTILISATION_FACTOR_LOSS = SECTION_8C_ETA_LOSS_ALL_ONE`,
`LINE_106_M_INTERMITTENCY_FACTOR = SECTION_8C_INTERMITTENCY_MONTHLY`,
`LINE_107_M_SPACE_COOLING_KWH = SECTION_8C_ALL_ZERO_MONTHLY`.
§8c orchestrator: `domain.sap.worksheet.space_cooling`. Section helper
likely trivial since all inputs collapse to zero.
### C.4 §8f Fabric Energy Efficiency (line 109)
Single scalar: `LINE_109_FEE_KWH_PER_M2`. Per spec, (109) = (98a)/TFA +
(108). For all 6 fixtures (98b) solar space heating = 0, so Σ(98a) =
Σ(98c) → LINE_109 = LINE_99 + LINE_108 = LINE_99 (no AC).
§8f orchestrator: `domain.sap.worksheet.fabric_energy_efficiency`.
### C.5 §9a energy requirements (lines 201, 206219)
Lodged on fixtures:
- LINE_211 main heating fuel (annual)
- LINE_215 secondary heating fuel (annual)
- LINE_219 hot water fuel (annual)
- Plus LINE_201, 206208, 213215 monthly tuples possibly
Already partially exposed on `SapResult` (`main_heating_fuel_kwh_per_yr`,
`secondary_heating_fuel_kwh_per_yr`, `hot_water_kwh_per_yr`). Pin tests
at the cascade level walk `energy_requirements_from_cert` (or compose
inside cert_to_inputs).
### C.6 §10a fuel costs (lines 240255)
17+ line refs. Already exposed via `SapResult.total_fuel_cost_gbp`.
Cascade tests should pin each component (main fuel cost, secondary,
hot water, pumps/fans, lighting, PV credit, standing charges).
§10a orchestrator: `domain.sap.worksheet.fuel_cost.fuel_cost`.
### C.7 §11a SAP rating (lines 256258)
3 line refs:
- LINE_256 ECF (energy cost factor)
- LINE_257 SAP score continuous
- LINE_258 SAP score integer
Already on `SapResult` as `ecf`, `sap_score_continuous`, `sap_score`.
e2e pins exist. Add explicit cascade pins for symmetry.
`rating.py` constants are immutable per ADR-0010 — do not touch.
### C.8 §12 environmental (lines 261282)
CO2 + primary energy + EI rating monthly + annual. Already partly on
`SapResult.co2_kg_per_yr`. Big section with many line refs.
If the API doesn't yet expose SAP scoring, the integration test scope
might include adding the endpoint. Confirm scope with the user before
expanding.
---
## §D — Workflow toolbox
## Workflow conventions (from the SAP cleanup work)
### D.1 Adding a section cascade pin (the standard pattern)
1. **Find or extract** a `<section>_from_cert(epc)` helper in
`domain.sap.rdsap.cert_to_inputs`. If it doesn't exist, add one
mirroring `internal_gains_section_from_cert` or `mean_internal_
temperature_section_from_cert` — compose upstream section helpers
then call the orchestrator with the result's fields.
2. **Add a `_SECTION_X_PINS` tuple** to `test_section_cascade_pins.py`
mapping `("LINE_X_<NAME>", "result_attr_name")`.
3. **Add a parametrised test** that walks every `(fixture, line_ref)`
pair and asserts `_pin(actual, expected, ...)` at abs=1e-4.
4. **Run, see failures, diagnose. Fixture defect or calculator bug —
fix in place, no widening.**
### D.2 Diagnostic pattern
When a pin fails:
1. Add a TEMP test file `test_<thing>_diag_TEMP.py` that dumps the
per-component breakdown alongside PDF expected values.
2. `awk '/^X\. Section/,/^Y\./' "sap worksheets/U985-0001-NNNNNN.txt"`
to extract the PDF block.
3. Identify the drift source — fixture defect (audit fixture first)
or calc bug.
4. Fix. Re-run the pin.
5. **Delete the TEMP file before committing.**
### D.3 Spec page references already in hand
```
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 floor U + Table 19 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 (Standard | Roof window cols)
§15 rounding rules p.66
Table 11 (secondary fraction) p.188
Table 12 (fuel/CO2/PEF) p.189
Table 12a (standing/off-peak) p.191
SAP 10.2 (14-03-2025):
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 (HW combi keep-hot) p.160
Table 3b (HW combi profile M) p.161
Table 3c (HW combi M+L / M+S) p.162
```
For new pages **ask the user**. Spec PDFs are big.
### D.4 Spec-grounded patterns we've discovered
- **RdSAP §15 rounding**: U-values + element gross areas to 2 d.p. —
apply at the BOUNDARY between RdSAP input and SAP calculator. See
`heat_transmission.py` for the pattern (`_round_half_up`).
- **Half-up rounding, not banker's**: Python's `round(17.125, 2) = 17.12`
but SAP wants 17.13. The `_round_half_up` helper in `heat_transmission.py`
is the right utility — reuse it for any new §15 boundary you cross.
- **§3.8 roof area = MAX of floor areas across levels**, not the top
floor area. Bites when an extension's footprint steps back.
- **Assessor-lodged U overrides cascade**: cert PDFs lodge measured U
for some walls/gables. The `u_value` field on `SapRoomInRoofSurface`
and `SapAlternativeWall` honours this. When extending to new surface
types, follow the same pattern.
### D.5 Section helper map (cert→inputs cascade entry points)
```
domain.sap.rdsap.cert_to_inputs
dimensions_from_cert(epc) §1 → Dimensions
ventilation_from_cert(epc) §2 → VentilationResult
heat_transmission_section_from_cert(epc) §3 → HeatTransmission
water_heating_section_from_cert(epc) §4 → WaterHeatingResult
internal_gains_section_from_cert(epc) §5 → InternalGainsResult
solar_gains_section_from_cert(epc) §6 → SolarGainsResult
mean_internal_temperature_section_from_cert(epc) §7 → MeanInternalTemperatureResult
-- next to add --
space_heating_section_from_cert(epc) §8 → SpaceHeatingResult
space_cooling_section_from_cert(epc) §8c → SpaceCoolingResult
fabric_energy_efficiency_from_cert(epc) §8f → float (kWh/m²)
energy_requirements_section_from_cert(epc) §9a → EnergyRequirementsResult
fuel_cost_section_from_cert(epc) §10a → FuelCostResult
sap_rating_section_from_cert(epc) §11a → (ecf, sap_continuous, sap_int)
environmental_section_from_cert(epc) §12 → EnvironmentalResult
```
### D.6 Hard rules summary card
| do | don't |
|----|-------|
| `pytest.approx(..., abs=1e-4)` | `rel=…` |
| Audit fixture against PDF first | Diagnose downstream first |
| Leave failing pins, fix one at a time | Widen tolerance / add xfail |
| Quote PDF page when asking for spec | Scan >50 lines of PDF without asking |
| `[[reference-style]]` cross-links in memory | Bare prose references |
| Use `_round_half_up`, not Python `round` | Banker's rounding at §15 boundaries |
| Delete `_TEMP.py` before commit | Commit diagnostic scripts |
- **AAA tests**`# Arrange / # Act / # Assert` headers on every new
test.
- **One slice = one commit** with Co-Authored-By trailer.
- **`pytest.approx(..., abs=1e-4)` for the EPC outputs** — same bar as
the SAP cascade tests. The 4 expected values above are at 4 d.p. so
abs=1e-4 is the floor.
- **Don't widen tolerances.** If a pin fails, it's a real bug (probably
in the API plumbing, since the calculator is closed).
---
## §E — File map
## Files to read on day 1
```
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
HANDOVER_NEXT.md this file
pcdb_table_105_gas_oil_boilers.jsonl PCDB combi records
sap worksheets/ U985 + Summary PDFs
packages/domain/src/domain/sap/calculator.py Top-level SAP10.2 orchestrator
packages/domain/src/domain/sap/rdsap/cert_to_inputs.py Cert→CalculatorInputs + section helpers
packages/domain/src/domain/sap/tables/table_12.py Table 12 (price/CO2/PEF)
packages/domain/src/domain/sap/tables/table_12a.py Off-peak high-rate fraction
packages/domain/src/domain/sap/tables/table_32.py RdSAP10 Table 32 (cost prices)
packages/domain/src/domain/sap/worksheet/
dimensions.py §1
ventilation.py §2 + VentilationResult
heat_transmission.py §3 + HeatTransmission + _round_half_up helper
water_heating.py §4 + WaterHeatingResult + electric_shower_monthly_kwh
internal_gains.py §5 + InternalGainsResult
solar_gains.py §6 + SolarGainsResult + RoofWindowInput
mean_internal_temperature.py §7 + MeanInternalTemperatureResult
space_heating.py §8 + SpaceHeatingResult
space_cooling.py §8c
fabric_energy_efficiency.py §8f
energy_requirements.py §9a + EnergyRequirementsResult
fuel_cost.py §10a + FuelCostResult
rating.py §11/§13 SAP rating equations (DO NOT TOUCH constants)
packages/domain/src/domain/sap/worksheet/tests/
test_section_cascade_pins.py Strict per-section line-ref pins (THE work)
test_e2e_elmhurst_sap_score.py SapResult-field pins
_elmhurst_worksheet_NNNNNN.py The 6 fixture modules (1 per fixture)
_elmhurst_fixtures.py ALL_FIXTURES registry
test_*.py Legacy per-section isolation tests
datatypes/epc/domain/epc_property_data.py
SapBuildingPart + sap_room_in_roof
SapRoomInRoof + detailed_surfaces
SapRoomInRoofSurface + u_value override, kind enum:
"slope" | "flat_ceiling" | "stud_wall" |
"gable_wall" | "gable_wall_external"
SapAlternativeWall + u_value override
SapRoofWindow area + u_value_raw + orientation +
pitch_deg + g_perpendicular + frame_factor
SapHeating + electric_shower_count, mixer_shower_count,
number_baths
```
| File | Why |
|---|---|
| [`docs/sap-spec/SAP_CALCULATOR.md`](./SAP_CALCULATOR.md) | Module API + architecture (you're heading there) |
| [`packages/domain/src/domain/sap/calculator.py`](../../packages/domain/src/domain/sap/calculator.py) | `SapResult` fields you'll assert against |
| [`packages/domain/src/domain/sap/rdsap/cert_to_inputs.py`](../../packages/domain/src/domain/sap/rdsap/cert_to_inputs.py) | The 3 public entry points + the section helpers |
| [`packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py`](../../packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py) | A reference fixture — `build_epc()` shows the EpcPropertyData shape |
| [`packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py`](../../packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py) | The current e2e test pattern — model your integration test on this |
| `backend/` (explore) | API entry points |
| [`datatypes/epc/domain/mapper.py`](../../datatypes/epc/domain/mapper.py) | Schema → EpcPropertyData mappers |
---
## §F — Definitely do NOT
- Do **not** widen any tolerance.
- Do **not** add xfail to cascade pins.
- Do **not** "investigate later" by widening — fix it or leave it failing.
- Do **not** assume the calculator is wrong before auditing the fixture.
- Do **not** touch `rating.py` constants.
- Do **not** scan unread spec PDF pages without asking the user.
- Do **not** invoke `/ultrareview`.
- Do **not** auto-update unrelated `git status` items.
- Do **not** use Python `round()` at a §15 boundary — use `_round_half_up`.
---
## §G — Quick orient
## Quick orient
```bash
# Run the full cascade scoreboard
# Confirm SAP calculator is still 930/930 green
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-header --no-cov --tb=no -q
--no-cov --no-header --tb=no -q
# Run §7 only
python -m pytest packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py \
-k "section_7" --no-cov --tb=no -q
# Per-fixture residual diffs for a section
python -m pytest packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py \
-k "section_7 and 000474" --no-cov --tb=line
# Single SapResult pin numeric diff
python -m pytest \
"packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py::test_sap_result_pin[000477-space_heating_kwh_per_yr]" \
--no-cov 2>&1 | grep AssertionError
# Extract a PDF §X block for a fixture
awk '/^X\. Section/,/^Y\./' "sap worksheets/U985-0001-NNNNNN.txt"
# Wider regression check
python -m pytest packages/domain/src/domain/sap/worksheet/tests/ \
packages/domain/src/domain/sap/tests/ packages/domain/src/domain/ml/ \
--no-header --no-cov --tb=no -q | tail -5
# Show the 4 EPC outputs for fixture 000474
cd packages/domain/src && python -c "
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
from domain.sap.worksheet.tests import _elmhurst_worksheet_000474 as w
epc = w.build_epc()
pc = local_climate_for_cert(epc)
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
env_rating = environmental_section_from_cert(epc)
env_demand = environmental_section_from_cert(epc, postcode_climate=pc)
pe_demand = primary_energy_section_from_cert(epc, postcode_climate=pc)
print(f'SAP: {rating.sap_score}') # 62 (UK-avg)
print(f'EI: {env_rating.ei_rating_integer}') # 60 (UK-avg)
print(f'Carbon: {env_demand.total_co2_kg_per_yr:.4f} kg/yr') # 3104.1222 (postcode)
print(f'PE: {pe_demand.total_pe_kwh_per_yr:.4f} kWh/yr') # 16931.7227 (postcode)
"
```
End of handover. Read §A again before starting.
**Important:** SAP rating and EI rating use UK-average climate; Current
Carbon and Current Primary Energy use postcode climate. Don't read EI
from the demand-cascade `environmental_section_from_cert` — that's a
postcode-conditions EI value, not what the EPC publishes.
---
## What's NOT in scope
- **Extending the SAP calculator.** It's closed at the EPC-output layer.
If you find an additional cert-shape variation that breaks the
calculator, capture it as a new conformance fixture (see
`packages/domain/src/domain/sap/README.md`) — don't paper over it in
the integration test.
- **BEDF fuel pricing.** The Fuel Bill on the EPC uses postcode-specific
BEDF prices (PCDB Table 200), which are deferred. The 4 outputs above
cover SAP + EI + Carbon + PE; Fuel Bill is a follow-up.
- **The Demand-SAP "improved dwelling" cascade.** That's Block 3 of the
U985 worksheet (retrofit-applied SAP rating). Out of scope.
Good luck. The SAP side is solid; this is purely a plumbing exercise.

View file

@ -0,0 +1,375 @@
# 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
```

View file

@ -21,7 +21,7 @@ sap/
Spec references: `docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf` (SAP 10.2, the active target per ADR-0010), `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf` (RdSAP cascade). Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root — loaded by `_xlsx_loader.py`.
**Validation contract.** Per `[[feedback-zero-error-strict]]` the 6 Elmhurst U985 fixtures are deterministic test vectors: every line ref of every output must pin against the U985 PDF at `abs=1e-4`. See `worksheet/tests/test_section_cascade_pins.py` (per-section line refs) and `test_e2e_elmhurst_sap_score.py::test_sap_result_pin` (top-level SapResult fields). Tolerances are never widened. The current work queue + scoreboard lives in `docs/sap-spec/HANDOVER_NEXT.md`.
**Validation contract.** Per `[[feedback-zero-error-strict]]` the 6 Elmhurst U985 fixtures are deterministic test vectors: every line ref of every output must pin against the U985 PDF at `abs=1e-4`. See `worksheet/tests/test_section_cascade_pins.py` (per-section line refs, 768 rating + 90 demand pins) and `test_e2e_elmhurst_sap_score.py::test_sap_result_pin` (top-level SapResult fields). Tolerances are never widened. **Current state: 930/930 pins green.** The public API + architecture overview lives in `docs/sap-spec/SAP_CALCULATOR.md`.
## Adding a new Elmhurst conformance fixture

View file

@ -1,33 +1,42 @@
"""SAP 10.2 synthetic-input calculator orchestrator.
"""SAP 10.2 calculator orchestrator.
Drives the 12-month heat-balance loop from a typed `CalculatorInputs`
aggregate and emits a typed `SapResult`. This module is the physics
assembly only the RdSAP certinputs mapping lives in
`domain.sap.rdsap.cert_to_inputs` (Session A slice 7b). Splitting the two
keeps orchestration testable against synthetic inputs without dragging in
cert-shape assumptions.
`domain.sap.rdsap.cert_to_inputs`. Splitting the two keeps orchestration
testable against synthetic inputs without dragging in cert-shape
assumptions.
Each month:
1. External temperature, wind speed, horizontal solar irradiance from
Appendix U Tables U1-U3 by region + month.
Per-month worksheet flow (§§5-13):
1. External temp / wind / horizontal solar from `monthly_external_
temp_c_override` tuple if set (postcode demand cascade), else
Appendix U Tables U1-U3 by region.
2. Internal gains (§5 + Appendix L) given TFA and month.
3. Solar gains (§6 + Appendix U §U3.2) summed over the window list.
4. HLC = HLC_T (already supplied) + HLC_V = ach × volume × 0.33.
5. Thermal time constant τ = TMP × TFA / (3.6 × HLC) for utilisation η.
6. Mean internal temperature (§7 + Table 9b/9c) and utilisation factor
(Table 9a) iterated twice because each depends on the other; SAP
10.3 §7.3 says two passes are sufficient.
(Table 9a) supplied as monthly tuples from cert_to_inputs.
7. Useful space-heating requirement (Table 9c step 10).
8. Delivered fuel kWh = Q_heat / main-heating efficiency.
Annual totals = month sums; ECF = §13 Table 12 deflator × total cost /
(TFA + 45); SAP rating from §13 piecewise log/linear; CO2 from CO2
emission factor × delivered fuel (single-fuel approximation in this
slice slice S-A8 splits hot-water/lighting onto per-fuel factors).
Annual aggregation:
- ECF = Table 12 deflator × total cost / (TFA + 45); SAP rating from
§13 piecewise log/linear (slice 23 constants pinned by ADR-0010).
- CO2 per end-use uses per-end-use factors on CalculatorInputs:
gas end-uses (main, hot water) use the annual Table 12 factor;
electricity end-uses (secondary, pumps/fans, lighting, electric
shower) use the Σ(kWh_m × Table 12d_m) / Σ kWh_m effective annual.
- Primary Energy: same shape with Table 12 / Table 12e factors.
- Environmental Impact Rating from §14 (log/linear on CO2/).
Reference: SAP 10.2 specification (14-03-2025) §§5-13 (pages 23-43), Table
9a/9b/9c (pages 184-186), Table 12 (page 191), Appendix L + U.
The factor-per-end-use machinery is the slice-32/33 closure of the U985
Block 2 (demand cascade) §12 / §13a line refs. See
`worksheet/tests/test_section_cascade_pins.py` for the conformance suite.
Reference: SAP 10.2 specification (14-03-2025) §§5-14 (pages 23-44),
Tables 9a/9b/9c (pages 183-185), Table 12/12a/12d/12e (pages 191-195),
Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors.
"""
from __future__ import annotations

View file

@ -1,37 +1,50 @@
"""RdSAP 10 cert → SAP 10.2 CalculatorInputs mapping.
Reads `EpcPropertyData` (the gov EPC API / site-notes domain model) and
produces the typed `CalculatorInputs` the synthetic-input orchestrator
produces the typed `CalculatorInputs` the deterministic calculator
consumes. The boundary between this module and `calculator.py` is the
cleanest one in the deterministic calculator: cert-shape assumptions and
RdSAP defaulting rules stay here; physics stays in `calculator.py` +
`worksheet/*`.
cleanest one: cert-shape assumptions and RdSAP defaulting rules stay
here; physics stays in `calculator.py` + `worksheet/*`.
Two cascades, two climate sources (per SAP10.2 Appendix U p.124):
* `cert_to_inputs(epc)` RATING cascade, UK-average climate. Produces
the SAP rating and EI rating that the EPC publishes.
* `cert_to_demand_inputs(epc)` DEMAND cascade, postcode-district
climate via PCDB Table 172. Produces the EPC's published "Current
Carbon", "Current Primary Energy", and (eventually) fuel bill.
Each cascade also exposes per-section helpers `*_section_from_cert(epc,
postcode_climate=None)` for §1..§13a worksheet line-ref pinning. The
section helpers map 1:1 to U985 worksheet sections; see
`worksheet/tests/test_section_cascade_pins.py` for the conformance suite.
Defaulting rules per RdSAP 10 (10-06-2025):
- Dimensions: §3 `worksheet/dimensions.py`
- Heat transmission: §5 `worksheet/heat_transmission.py`
- Infiltration: §4 Table 5 `worksheet/ventilation.py`
- Living-area fraction: Table 27 by `habitable_rooms_count` (with §15
2-d.p. area rounding, see slice-26 docstring on `_living_area_fraction`)
- Heating efficiency: SAP 10.2 Tables 4a/4b + PCDB Table 105 override
- Hot-water demand: Appendix J full cascade (`worksheet/water_heating.py`)
- Lighting demand: Appendix L L1-L11 (`worksheet/internal_gains.py`)
- Fuel unit cost: RdSAP10 Table 32 (pence/kWh £/kWh here)
- CO2 factors: Table 12 annual (gas) + Table 12d monthly (electricity)
- PE factors: Table 12 annual (gas) + Table 12e monthly (electricity)
- Dimensions: §3 (port lives in `worksheet/dimensions.py`)
- Heat transmission: §5 (port in `worksheet/heat_transmission.py`)
- Infiltration: §4 Table 5 (port in `worksheet/ventilation.py`)
- Living-area fraction: Table 27 by `habitable_rooms_count`
- Heating efficiency: SAP 10.2 Tables 4a/4b (existing
`domain.ml.sap_efficiencies.seasonal_efficiency` cascade)
- Hot-water demand: Appendix J (existing `domain.ml.demand`)
- Lighting demand: Appendix L simplified (`domain.ml.demand`)
- Fuel unit cost: Table 12 (existing `domain.ml.sap_efficiencies`,
pence/kWh £/kWh conversion happens here)
- CO2 factors: Table 12
Edge cases deliberately deferred to Session B:
Edge cases deliberately deferred (no fixture exercises):
- conservatory modes (`has_conservatory`)
- room-in-roof contributions to wall/roof area
- secondary heating split (Table 11)
- multi-fuel weighted unit cost (currently main-fuel only)
- multi-fuel weighted unit cost (main-fuel only Table 11 secondary split
IS implemented for kWh / CO2 / PE / fuel-cost paths)
- thermal mass parameter from construction type (defaults to medium 250)
- control_temperature_adjustment from main_heating_control code 2101/2103/2106
(defaults to 0)
(defaults to 0; all 6 Elmhurst fixtures lodge 0)
- Table 12a off-peak tariff high-rate-fraction split (STANDARD-tariff only)
- BEDF (postcode-specific) fuel prices (Table 32 amendment prices only)
Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification
(13-01-2026) Tables 4a/4b/4e/12.
(14-03-2025) Tables 4a/4b/4e/12/12d/12e; PCDB10 data file Table 172
(postcode weather) + Table 105 (gas/oil boilers).
"""
from __future__ import annotations