mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
4da8a4703d
commit
d44af109a9
5 changed files with 585 additions and 475 deletions
|
|
@ -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.00010–0.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 95–99)
|
||||
|
||||
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 100–108)
|
||||
|
||||
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, 206–219)
|
||||
|
||||
Lodged on fixtures:
|
||||
- LINE_211 main heating fuel (annual)
|
||||
- LINE_215 secondary heating fuel (annual)
|
||||
- LINE_219 hot water fuel (annual)
|
||||
- Plus LINE_201, 206–208, 213–215 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 240–255)
|
||||
|
||||
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 256–258)
|
||||
|
||||
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 261–282)
|
||||
|
||||
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.
|
||||
|
|
|
|||
375
docs/sap-spec/SAP_CALCULATOR.md
Normal file
375
docs/sap-spec/SAP_CALCULATOR.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 cert→inputs 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/m²).
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue