diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index ddf7b719..ad4a81aa 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -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 `
_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_", "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__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. diff --git a/docs/sap-spec/SAP_CALCULATOR.md b/docs/sap-spec/SAP_CALCULATOR.md new file mode 100644 index 00000000..be6e6af4 --- /dev/null +++ b/docs/sap-spec/SAP_CALCULATOR.md @@ -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_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 +``` diff --git a/packages/domain/src/domain/sap/README.md b/packages/domain/src/domain/sap/README.md index f6ba20ab..7efd44d3 100644 --- a/packages/domain/src/domain/sap/README.md +++ b/packages/domain/src/domain/sap/README.md @@ -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 diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 6c8e0cc9..a2385ad6 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -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 diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 8a5bdd7d..f73bf104 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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