docs: add AGENT_GUIDE.md — fresh-start onboarding for the SAP calculator

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 21:32:29 +00:00 committed by Jun-te Kim
parent 0e5f5b7d4a
commit 3cbf7e244b
2 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,265 @@
# SAP calculator — agent guide (start here)
This is the **canonical onboarding doc** for working on the SAP 10.2 /
RdSAP 10 calculator. It is meant to get you productive **without reading
any historical handover**. The `HANDOVER_*.md` files in this directory
are point-in-time session notes (useful for the specific residual they
chase, ignore otherwise). For deep architecture/API see
[`SAP_CALCULATOR.md`](SAP_CALCULATOR.md).
Three things this doc gives you: (1) the **accuracy bar** for the two
input paths, (2) the **debugging loop**, (3) the **tools & pipeline**.
---
## 0. The one-paragraph mental model
A cert's data comes in via one of two front-ends — an **Elmhurst Summary
PDF** (site-notes path) or an **EPC-register API JSON** (API path). Both
map to the same typed `EpcPropertyData`, which feeds a deterministic
cascade that reproduces the RdSAP10 engine. Our **ground truth is the
Elmhurst worksheet PDF** (U985 / P960 / dr87) — the per-line `(1)..(286)`
calculation, not the rounded values the EPC register lodges. We pin the
cascade against the worksheet to **abs = 1e-4 on every line ref**.
---
## 1. Accuracy expectations — site-notes vs API
The worksheet PDF is **always** the target. The EPC register's lodged
SAP/CO2/PE are rounded *and* carry Elmhurst's own residual, so matching
the lodged values is not the goal — matching the worksheet is.
| Path | Input | When a worksheet PDF exists for the cert | API/site-notes-only (no worksheet) |
|---|---|---|---|
| **Site-notes** | Elmhurst Summary PDF → extractor → `from_elmhurst_site_notes` | **abs = 1e-4** on continuous SAP **and every populated line ref** and cost / CO2 / PE | n/a (we always have the worksheet for site-notes fixtures) |
| **API** | register JSON → `from_api_response` | **abs = 1e-4** on continuous SAP vs the worksheet (same bar as site-notes — the two paths must converge) | **±0.5** SAP vs the lodged register value (fallback only) |
Three rules that fall out of this:
- **Cross-mapper parity.** For a cert that has both an API JSON and an
Elmhurst Summary, the two paths must produce SAP within **1e-4 of each
other** *and* of the worksheet. The cascade output (not a structural
EPC diff) is the equivalence check. A divergence localises to one
mapper.
- **No tolerance widening.** A failing 1e-4 pin is a real cascade bug or
a fixture defect — diagnose it, don't relax it. No `rel=`, no `xfail`,
no adaptive ceilings. ΔSAP = 0.07 is **not** "closed".
- **±0.5 is a fallback, not a destination.** It's only for API-only
certs with no worksheet to check against. If you can get a worksheet,
the bar is 1e-4.
Two documented, deliberate exceptions to "match the spec literal" live
in [`SAP_CALCULATOR.md` §8](SAP_CALCULATOR.md) ("Elmhurst-mirrored spec
divergences") — cases where the BRE-approved Elmhurst engine diverges
from the SAP 10.2 text and we mirror the engine. Add a §8 row only with
≥2-cert evidence.
---
## 2. The tools & pipeline
### 2.1 The two PDFs per cert
- **`Summary_NNNNNN.pdf`** — the Elmhurst **site notes / input**. This is
what the assessor lodged: dimensions, fabric, heating system, controls,
cylinder, etc. It is the INPUT, equivalent to the API JSON.
- **The worksheet** — the **ground truth output**, every line ref
`(1)..(286)` to 4 d.p. Three families, all the same format:
- `U985-0001-NNNNNN.pdf` — the 6 gas-combi conformance fixtures.
- `P960-0001-NNNNNN.pdf` — the heating-systems corpus + community heating.
- `dr87-0001-NNNNNN.pdf` — the API-paired cohort ("Additional data with api").
### 2.2 The cascade pipeline (site-notes path)
```python
import subprocess, re
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_inputs, cert_to_demand_inputs, local_climate_for_cert,
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
# 1. Summary PDF -> per-page text (pdftotext -layout, one string per page)
def summary_pdf_to_pages(pdf: Path) -> list[str]:
n = int(re.search(r"Pages:\s+(\d+)",
subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True).stdout).group(1))
pages = []
for i in range(1, n + 1):
layout = subprocess.run(
["pdftotext", "-layout", "-f", str(i), "-l", str(i), str(pdf), "-"],
capture_output=True, text=True).stdout
pages.append("\n".join(
tok for line in layout.splitlines() for tok in re.split(r"\s{2,}", line.strip()) if tok))
return pages
pages = summary_pdf_to_pages(Path("sap worksheets/.../Summary_NNNNNN.pdf"))
site_notes = ElmhurstSiteNotesExtractor(pages).extract() # -> ElmhurstSiteNotes
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) # -> EpcPropertyData
# 2. Two cascades. RATING = SAP/EI rating (UK-avg climate, region 0).
# DEMAND = Current Carbon / Current PE / Fuel Bill (postcode climate, PCDB Table 172).
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) # climate = local_climate_for_cert(epc)
rating.sap_score_continuous # un-rounded SAP — pin THIS, not the integer
rating.total_fuel_cost_gbp
rating.co2_kg_per_yr
demand.primary_energy_kwh_per_yr
```
Shortcut: `Sap10Calculator().calculate(epc)` runs the rating cascade
(`cert_to_inputs``calculate_sap_from_inputs`) in one call.
### 2.3 The API path
Identical from `EpcPropertyData` onward — only the front-end changes:
```python
import json
data = json.loads(Path("tests/domain/sap10_calculator/rdsap/fixtures/golden/<cert>.json").read_text())
epc = EpcPropertyDataMapper.from_api_response(data) # -> EpcPropertyData
# ... same cert_to_inputs / calculate_sap_from_inputs as above
```
### 2.4 Section helpers — intermediate line refs
Every worksheet section has a `<section>_section_from_cert(epc)` helper
returning a typed result with the line-ref values. Use these to inspect
where a residual originates **without** running the whole cascade
(`postcode_climate=` selects rating vs demand):
```python
from domain.sap10_calculator.rdsap.cert_to_inputs import (
water_heating_section_from_cert, # §4 (42)..(65)m
heat_transmission_section_from_cert, # §3 (26)..(37)
internal_gains_section_from_cert, # §5 (66)..(73)
mean_internal_temperature_section_from_cert, # §7 (85)..(94)
space_heating_section_from_cert, # §8 (95)..(99)
fuel_cost_section_from_cert, # §10a (240)..(255)
environmental_section_from_cert, # §12 (261)..(274)
primary_energy_section_from_cert, # §13a (275)..(286)
)
wh = water_heating_section_from_cert(epc)
wh.energy_content_monthly_kwh # (45)m ; wh.output_kwh_per_yr # (62)/(64)
```
(Full table of helpers + line refs is in [`SAP_CALCULATOR.md` §1.3](SAP_CALCULATOR.md).)
### 2.5 Reading the worksheet from the shell
```bash
# Dump a worksheet line ref (e.g. (217)m water-heater monthly efficiency):
pdftotext -layout "sap worksheets/.../P960-0001-NNNNNN.pdf" - | grep -nE "\(217\)|\(62\)|\(210\)"
# Read a Summary input field (controls, cylinder, fuel):
pdftotext -layout "sap worksheets/.../Summary_NNNNNN.pdf" - | grep -niE "cylinder|control|interlock|fuel"
```
### 2.6 Where the test vectors live
| Set | Location | What |
|---|---|---|
| 6 U985 conformance fixtures | `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_NNNNNN.py` (+ Summary PDFs in `backend/documents_parser/tests/fixtures/`) | Gas-combi certs, every line ref transcribed as `LINE_*` / `DEMAND_LINE_*` constants. Pinned in `worksheet/test_section_cascade_pins.py` + `worksheet/test_e2e_elmhurst_sap_score.py`. |
| Heating-systems corpus | `sap worksheets/heating systems examples/<variant>/` (Summary + P960) | 41 variants of **one property** with only the heating system changed → any residual is attributable to the heating subsystem. Pinned in `backend/documents_parser/tests/test_heating_systems_corpus.py`. |
| API golden fixtures | `tests/domain/sap10_calculator/rdsap/fixtures/golden/<cert>.json` | Register JSON for the API path. |
| API + worksheet pairs | `sap worksheets/Additional data with api/<cert>/` (Summary + dr87) | Certs that have BOTH an API JSON and a worksheet → cross-mapper parity checks. |
---
## 3. The debugging loop
When a cert's SAP/cost/CO2/PE is off, **never guess a fix** — walk it.
1. **Reproduce & decompose.** Build the epc (extractor+mapper, or a
fixture's `build_epc()`), run both cascades, and see **which of the
four outputs** drifts. Cost/CO2/PE drift with the same sign as energy;
isolate the carrier.
2. **Find the section.** Walk the four metrics back to a worksheet
section: SAP off but cost EXACT often means a demand/gains issue;
cost off but energy EXACT means a price/factor issue; CO2/PE off but
cost EXACT means a factor issue. Use the §2.4 section helpers to get
the cascade's intermediate line refs.
3. **Per-line compare vs the worksheet.** `pdftotext -layout` the
worksheet and compare the cascade's `(45)/(56)/(62)/(210)/(217)m/...`
line-by-line against the PDF. The first diverging line ref is the bug.
4. **Localise to a layer.**
- cascade value present in worksheet but cascade has 0 / wrong → **calculator** gap (a spec rule not wired, or a dispatch gate).
- the input field the worksheet used isn't in `epc`**mapper** (mis-mapped) or **extractor** (didn't capture the Summary field). Audit the Summary PDF for the field first — many lodgements are incomplete and the fixture, not the calculator, is wrong.
5. **Cite the spec.** Find the SAP 10.2 / RdSAP 10 rule (page + line) that
produces the worksheet's number. Confirm the worksheet matches the
spec literal; if it diverges, it's a candidate §8 Elmhurst-mirror
(needs ≥2-cert evidence). **SAP 10.2 only — never 10.3.**
6. **Cross-check vs API (when available).** If the cert has an API JSON
too, run `from_api_response` through the same cascade. If the API path
matches the worksheet but the site-notes path doesn't (or vice-versa),
the bug is in **that mapper**, not the calculator. If both diverge
identically, it's the **calculator/cascade**.
7. **Fix one cause, re-pin smaller.** TDD: one failing AAA test → one
impl → re-pin the (now smaller) residual. A spec-correct fix often
**exposes** the next residual that an offsetting bug was masking —
that's the next slice, not a regression. Don't conflate
`main_heating_category` (often `None` on Elmhurst Table 4b boilers)
with `sap_main_heating_code`.
### Worked shape (real example: oil 6)
Residual +3.05 SAP. (1) HW + space both off. (2) §4 HW efficiency. (3)
worksheet (210) space eff = 75 but Table 4b code 126 = 80; (217)m summer
= 63 = 685 → a 5pp penalty. (4) the Summary lodges control `2101` ("no
thermostatic control of room temperature") → no room thermostat → P960
header "Boiler Interlock: No". (5) RdSAP 10 §3 + SAP 10.2 Table 4c(2):
no room thermostat ⇒ not interlocked ⇒ 5pp Space+DHW. Fix the
`no_interlock` gate → space+HW fuel EXACT, residual collapses to a single
exposed pump cause (Table 4f footnote a) ×1.3) → next slice. Two slices,
fully closed.
---
## 4. Run the suite
```bash
PYTHONPATH=/workspaces/model python -m pytest \
tests/domain/sap10_calculator/ \
backend/documents_parser/tests/ \
--no-cov -q -p no:cacheprovider
```
Conformance pins only:
```bash
PYTHONPATH=/workspaces/model python -m pytest \
tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py \
tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py \
backend/documents_parser/tests/test_heating_systems_corpus.py \
--no-cov -q
```
Notes:
- `load_cells` tests pin against the gitignored `*.xlsx` reference
worksheet at repo root; they **skip** when it's absent (CI), run
locally when present.
- All new code passes `pyright` strict, zero errors. Tests use literal
`# Arrange / # Act / # Assert` headers and `abs(x - y) <= tol` (not
`pytest.approx`, which strict-pyright flags).
- Commit one slice per change, with the spec citation in the message.
---
## 5. Spec PDFs on disk
```
domain/sap10_calculator/docs/specs/
sap-10-2-full-specification-2025-03-14.pdf # SAP 10.2 (the methodology)
RdSAP 10 Specification 10-06-2025.pdf # RdSAP 10 (the reduced-data rules)
pcdb10.dat / pcdb_table_*.jsonl # PCDB (boilers, HPs, postcode weather)
```
Pages worth bookmarking: SAP 10.2 §7 MIT (p.28-32), Table 4b boiler eff
(p.168), Table 4c efficiency adjustments (p.169), Table 4e controls
(p.171-174), Table 4f auxiliary energy (p.175), Table 12 factors (p.191),
Appendix U region tables (p.124-127). RdSAP 10 §10 water heating (p.54-56,
incl. §10.7 no-water-heating default), Table 28/29 cylinder defaults,
Table 32 prices (p.95).
```

View file

@ -1,5 +1,10 @@
# SAP 10.2 / RdSAP 10 calculator — module overview
> **New here? Start with [`AGENT_GUIDE.md`](AGENT_GUIDE.md)** — the
> accuracy bar (site-notes vs API), the debugging loop, and the
> tools/pipeline. This file is the deeper architecture + API reference;
> the `HANDOVER_*` files are point-in-time session notes, not onboarding.
Deterministic, bit-faithful replication of the RdSAP10 calculation engine.
Validated against the 6 Elmhurst U985 worksheet PDFs at **abs=1e-4 on
every line ref** for both the Rating cascade (UK-average climate, used