This branch's objective is the SAL ingestion handler
(applications/SAL/handler.py) and its dependency tree. Drop work
that crept in but is unreferenced by it:
- EPC feature: domain/epc, infrastructure/epc (gov_uk + historical
clients), tests/infrastructure/epc
- datatypes/epc edits (instantaneous_wwhrs Optional) reverted to main
- asset_list/app.py local data-file/column tweak reverted to main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the composition with its entry point (the `ara_first_run` lambda +
`AraFirstRunTriggerBody`): clearer what the file does.
- orchestration/first_run_pipeline.py → ara_first_run_pipeline.py
- FirstRunPipeline → AraFirstRunPipeline; FirstRunCommand → AraFirstRunCommand
- test files renamed to match
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`property` is an FE-owned table the backend only ever reads — every row read
carries an id — so the autoincrement-PK `Optional[int]` idiom doesn't apply
here. Make it `int` and drop the now-redundant None guard in get_many.
(Contrast: solar_table keeps Optional id — the backend DOES insert those, so
id is genuinely None pre-flush.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the stored units explicit on the property_baseline_performance columns:
- `*_co2_emissions` → `*_co2_emissions_t_per_yr` (tonnes CO₂/yr, whole dwelling)
- `*_primary_energy_intensity` → `*_primary_energy_intensity_kwh_per_m2_yr`
Column names only; the domain `Performance` VO stays unit-suffix-free (units are
a storage concern, mapped in from_domain/to_domain). Migration doc updated.
Round-trip stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final slice of ADR-0012: collapse the per-property read round-trips a batch
made (Baseline hydrated ~8 queries x 30 properties one at a time) into a
handful of per-table IN queries.
- EpcPostgresRepository: extracted a shared `_compose(rows)` from `get` (the
windows + floor-dim fetches are now passed in, not fetched inline), so both
`get` and the new `get_for_properties(property_ids)` build EpcPropertyData
from pre-fetched rows. `get_for_properties` fetches each child table once
(`WHERE epc_property_id IN ...`), groups in memory, and composes — load-whole
per ADR-0002.
- PropertyRepository.get_many(property_ids) -> Properties: one query for the
property rows + one bulk EPC hydration, composed in input order.
- BaselineOrchestrator / IngestionOrchestrator read the batch via get_many
instead of N x get.
- Ports + fakes gain the bulk methods.
The #1129 round-trip fidelity test stays green (the compose extraction is
behaviour-preserving). New tests: bulk hydration correctness + round-trips are
constant w.r.t. batch size (one-per-table, proven by query count). 123 pass;
pyright strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the handler's whole-pipeline Session (one transaction across all
three stages, connection pinned during Ingestion's external IO) with a
Unit-of-Work per stage (ADR-0012, added here). Each stage runs its batch in
one unit and commits once; any property raising aborts the batch and the
subtask fails noisily.
- BaselineOrchestrator(unit_of_work, rebaseliner): one unit for the batch,
commit once. Raise on a pre-SAP10 property leaves the unit uncommitted.
- IngestionOrchestrator(unit_of_work, epc_fetcher, geospatial_repo,
solar_fetcher): fetch/write split — phase 1 fetches the whole batch (EPC /
coords / solar) with NO unit open; phase 2 writes in one unit and commits.
The connection is never held during external IO. Geospatial S3 repo stays
injected (reference data, not transactional).
- Handler: module-scoped engine (pool reused across warm invocations) + a UoW
factory; whole-pipeline `with Session` gone. `build_first_run_pipeline`
composes on the factory. Source clients still behind the raising seam.
- ADR-0012 records the decision (per-stage boundary, all-or-nothing batch,
idempotent re-run, fetch/write split, module-scoped engine). Modelling stub
left untouched (no-op, no DB) per the ADR.
Tests: orchestrators on a shared FakeUnitOfWork (assert persisted batch +
exactly-once commit + no-commit-on-raise). New real-DB E2E integration test:
real PostgresUnitOfWork, Ingestion writes the EPC → Baseline reads it back
through the repo → re-run replaces, not duplicates (1 EPC row, 1 baseline row
after two runs). 121 pass in tests/; pyright strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-runs of a First Run batch re-save a property's data; that must replace,
not duplicate (ADR-0012 idempotent batch writes).
- `EpcPostgresRepository.save` deletes the property's existing EPC graph
(parent + all child tables, floor-dims via their building parts) before
inserting, when a `property_id` is given. Anonymous saves still insert.
- `BaselinePostgresRepository.save` deletes the existing row for the
`property_id` before inserting — no more unique-constraint violation on
re-save; also what the re-score-on-override path needs.
- Solar already upserts, so it's unchanged.
The #1129 round-trip fidelity test stays green (delete-first is a no-op on
a first save). 2 new tests (re-save replaces, not duplicates). pyright
strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the per-stage batch-transaction refactor (ADR-0012). A
UnitOfWork is the single transaction a stage runs its batch in: a context
manager exposing the DB repos bound to one session, committing once on
`commit()` and rolling back on exception or exit-without-commit
(all-or-nothing per batch, fail noisily).
- `UnitOfWork` (port): `property` / `epc` / `solar` / `baseline` repos +
`commit()` / `rollback()`; `__exit__` rolls back uncommitted work.
- `PostgresUnitOfWork(session_factory)`: opens a Session from an injected
factory (a module-scoped engine + sessionmaker in prod, so the pool is
reused across warm invocations), binds the Postgres repos to it, closes
on exit.
Not yet wired into any orchestrator — that lands in the Baseline /
Ingestion refactor slices. 3 tests against ephemeral PG (commit durable
across units; exception rolls back; no-commit persists nothing). pyright
strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the First Run spine. Replaces the #1130 stub FirstRunPipeline
with the real three-stage composition and wires it into the handler.
- `FirstRunPipeline.run(command)` sequences Ingestion → Baseline →
Modelling, threading **only** `property_ids` between stages (and
`scenario_ids` into Modelling, off the command — never a prior stage's
output). Stages are injected behind thin `IngestionStage` /
`BaselineStage` / `ModellingStage` Protocols (the EpcFetcher/SolarFetcher
idiom), so the handler owns wiring and tests substitute fakes (ADR-0011).
- `ModellingOrchestrator` stub + `ScenarioRepository` / `MaterialsRepository`
seam ports — `run(property_ids, scenario_ids)` reads through repos, does
no scoring yet. Method shapes deferred to the Modelling per-service grills
(Scenario / Scenario Phase / Snapshot / Optimised Package / Plans are rich
— not pre-empted here).
- Handler delegates to the real pipeline via `build_first_run_pipeline`
(Postgres-backed repos off the session). The Ingestion source clients
(EPC API / Google Solar / geospatial S3) are isolated behind one
`_source_clients_from_env` seam that raises until the deploy/Terraform
config settles — out of scope for this slice. Subtask complete/failed +
CloudWatch URL still come from `@subtask_handler`.
Integration test (the criterion's centrepiece): wires REAL Ingestion +
REAL Baseline + stub Modelling through a shared fake EPC repo, with a
repo-backed PropertyRepo composing the Property from that slice. Proves
Baseline reads the very EPC Ingestion persisted — the through-repos
hand-off, no in-memory coupling. Plus a composition test pinning stage
order + only-property_ids threading.
TDD, one test → one impl. pyright strict clean; AAA layout. 116 pass in
the tests/ tree, no regressions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 2 of First Run. Establishes each Property's Baseline Performance
from persisted source data and writes it back — reads only from repos,
never a Fetcher or HTTP (ADR-0003), so it is byte-identical whether
Ingestion ran milliseconds ago or last week.
Domain (`domain/baseline/`):
- `Performance` VO — the four rated quantities: SAP / EPC Band / CO2 /
Primary Energy Intensity. `lodged_performance(epc)` reads them off the
EPC's recorded fields (PEUI = `energy_consumption_current`).
- `BaselinePerformance` (ADR-0004) — the paired `lodged` + `effective`
Performance + `rebaseline_reason`, plus the no-derivation part of the
energy block (`space_heating_kwh` / `water_heating_kwh`, off the RHI,
deterministic per ADR-0006). Both halves always populated.
- `Rebaseliner` port + `StubRebaseliner`: the re-score-on-override seam
(ADR-0011). SAP10 certs pass through (effective == lodged, reason
"none"); a pre-SAP10 cert raises `RebaselineNotImplemented` rather
than fabricating a plausible-but-wrong "none" — ML rebaselining is not
wired yet. Mirrors the repo's strict-raise culture.
Persistence: new `BaselineRepository` port + `BaselinePostgresRepository`
+ flat-column `baseline_performance` SQLModel (one row per Property). Per
ADR-0004's amendment this is a standalone table, NOT columns on the
retiring `property_details_epc`. Production migration is FE-owned
(Drizzle) — docs/migrations/baseline-performance-table.md.
Docs (grill-with-docs): corrected CONTEXT.md Lodged/Effective Performance
to Primary Energy Intensity (the term collided with its own _Avoid_ entry
under "heat demand") + fixed stale RHI field names; amended ADR-0004
Consequences for the standalone-table decision.
Fuel split + bills (rest of EPC Energy Derivation) deferred to a
follow-up — they need a Fuel Rates source (Ofgem-cap ETL) that does not
exist yet.
TDD, one test -> one impl: 7 tests (lodged read, rebaseliner pass-through
+ raise, orchestrator establish-and-persist + pre-SAP10 raise, Postgres
round-trip + absent). pyright strict clean; AAA layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage-2 entry point for the First Run use case. Adds the
`ara_first_run` Lambda package mirroring the `postcode_splitter`
template, its typed trigger contract, and a stub `FirstRunPipeline`.
- `AraFirstRunTriggerBody`: thin command of five fields — `task_id`,
`sub_task_id` (UUID, lifecycle), `portfolio_id`, `property_ids`,
`scenario_ids` (int business IDs). No `model_config` override, so
Pydantic's default `extra="ignore"` lets the FastAPI backend add
fields without breaking deployed lambdas. UPRNs / Scenario defs are
deliberately off the event — read from source-of-truth tables.
- Thin `handler.py`: validate-and-delegate only, via a named
`dispatch_first_run` seam (testable without the Lambda runtime).
Subtask status (in-progress/complete/failed) + CloudWatch log URL
come for free from the existing `@subtask_handler()` decorator.
- `FirstRunPipeline` (orchestration/) stub: `run(command)` receives the
validated command. Declares a structural `FirstRunCommand` Protocol
(the three business fields) that `AraFirstRunTriggerBody` satisfies,
so orchestration needs no application-layer import — rhymes with the
`EpcFetcher`/`SolarFetcher` Protocols on IngestionOrchestrator
(ADR-0011). Full Ingestion→Baseline→Modelling composition lands in
#1136.
- Dockerfile / requirements.txt / local_handler/ mirror postcode_splitter.
TDD: 7 new tests (trigger-body validation incl. forward-compat +
id-types, pipeline seam, handler delegation). pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1 of the pipeline: per property, read its UPRN from the property row,
fetch its EPC, resolve coordinates from the Geospatial reference repo, thread
those into the Solar fetcher, and persist EPC + solar via repos. Fetchers never
call each other — the orchestrator threads the coordinate (ADR-0011). Coordinates
are reference data (deterministic from UPRN), resolved transiently to drive the
solar fetch rather than persisted per-property.
Depends on thin EpcFetcher/SolarFetcher Protocols (EpcClientService and
GoogleSolarApiClient satisfy them structurally). Unit-tested against fakes — no
DB, gov API, or network: persists EPC, threads coords into solar, skips
UPRN-less properties and skips solar when coordinates are absent. pyright clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Coordinates value object + GeospatialRepository port + GeospatialS3Repository
adapter. Resolves a Property's lon/lat from the partitioned Ordnance Survey
Open-UPRN parquet (filename_meta -> partition -> UPRN row). A Repo, not a
Fetcher (ADR-0011): no live OS API call. The parquet reader is injected, so it's
unit-tested against fixture parquets with no S3/network; returns None when the
UPRN is uncovered or absent. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the EpcClientService package (client + _retry + exceptions + tests) from
the dying backend/ tree to infrastructure/epc_client/ as the New-EPC-API Fetcher;
update the two callers (address2UPRN, a script). All 14 client tests pass.
Add SolarRepository port + SolarPostgresRepository persisting Google Solar
building insights as JSONB (solar_building_insights table), one row per Property.
The EPC repo half of this slice already landed in #1129. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the Ara modelling aggregate root (ADR-0002): domain/property/ with
PropertyIdentity, SiteNotes, Property, Properties. Property.source_path
implements the two disjoint source paths + Recency Tie-Break (ADR-0001;
survey wins on an equal date); effective_epc resolves to the surveyed data
(Site Notes path) or the public EPC (epc_with_overlay path — Landlord
Overrides overlay is a later slice). Pure dataclasses, no infrastructure imports.
PropertyRepository port + PropertyPostgresRepository hydrate the aggregate
whole from a defensive view of the FE-owned 'property' table (identity columns)
plus the EPC slice via EpcRepository.get_for_property. Reads only from repos
(ADR-0003). 8 domain + 1 hydration test; pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add epc_renewable_heat_incentive table (space_heating_kwh, water_heating_kwh +
the three insulation-impact kWh fields), wired into EpcPostgresRepository
save/get. This is the P0 gap: RenewableHeatIncentive carries the baseline
space-heating/hot-water kWh that EPC Energy Derivation consumes.
The round-trip test now asserts full deep-equality (dropped the
renewable_heat_incentive exclusion) and passes for RdSAP 21.0.0 + 21.0.1.
DB migration for the new table documented in
docs/migrations/epc-property-round-trip-fidelity.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relocate EpcPropertyModel + child tables from the dying backend/ tree to
infrastructure/postgres/epc_property_table.py (re-export shim keeps
documents_parser working). Add EpcRepository port + EpcPostgresRepository with
a full reverse mapper (epc_property tables -> EpcPropertyData).
Round-trip test surfaced two fidelity gaps:
1. Union[int,str] SAP code fields were str()-coerced on save, losing the int
(API) vs str (Site Notes) distinction. Now stored as JSONB (type-preserving).
2. The schema was a partial projection. Closed the cheap gaps on the model
(heating shower/bath counts, roof_construction_type, curtain_wall_age,
addendum, mechanical_vent_duct_insulation_level, SAP 10.2 §2 ventilation
fields + a ventilation_present flag). Structural gaps tracked as follow-ups;
renewable_heat_incentive (P0, #1137) excluded from the assertion until landed.
Round-trip passes for RdSAP-Schema-21.0.0 and 21.0.1; pyright strict clean.
Migration inventory for the DB: docs/migrations/epc-property-round-trip-fidelity.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the grill-with-docs outcomes for the ara_first_run rebuild: three
composable stage orchestrators (Ingestion/Baseline/Modelling), one lambda per
use case chaining them through repos (not in-memory), and the Fetcher-vs-Repo
data-source taxonomy. Amends ADR-0003's chaining rule to generalise beyond
RefreshOrchestrator. Adds the pipeline-composition + First Run vocabulary to
CONTEXT.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency into
two columns — "space" and "water":
Code System space water
211 Ground source HP with flow temp <= 35°C 230 170
213 Water source HP with flow temp <= 35°C 230 170
215 Gas-fired GSHP with flow temp <= 35°C 120 84
216 Gas-fired WSHP with flow temp <= 35°C 120 84
217 Gas-fired ASHP with flow temp <= 35°C 110 77
521 Warm-air electric GSHP 230 170
523 Warm-air electric WSHP 230 170
525 Warm-air gas-fired GSHP 120 84
526 Warm-air gas-fired WSHP 120 84
527 Warm-air gas-fired ASHP 110 77
The split reflects real physics: heat pumps lose efficiency raising
water to ~55°C DHW temperatures vs ~35°C space-heating flow. ASHP
"in other cases" (codes 214, 221, 223, 224) and the "other cases"
gas-fired rows (225-227) have space == water = 170 / 84 / 77 — no
distinct DHW column.
Pre-slice the cascade routed WHC ∈ {901, 902, 914} ("HW from main
heating") through `seasonal_efficiency(main_code)`, which only consults
the Space column. For SAP code 211 the cascade returned 2.30 (= space)
when the spec requires 1.70 (= water). HW fuel kWh undercounted by
26% on the heating-systems corpus gshp variant: cascade 841.47 kWh vs
worksheet 1138.46 kWh.
New `_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY` dict (10 codes where Space
≠ Water) consulted in `_water_efficiency_with_category_inherit` before
falling through to the existing `seasonal_efficiency` path. Codes
where Space == Water keep the legacy inheritance — no behaviour
change. Non-HP main heating (boilers, storage heaters) likewise
unchanged.
Closures (gshp variant — SAP code 211 + WHC=901 + cylinder):
HW fuel kWh: 841.47 → 1138.45 (matches worksheet 1138.46)
ΔSAP_c: +0.9373 → -0.0178
Δcost: -£21.60 → +£0.41
ΔCO2: -34.98 → +7.06 kg/yr
ΔPE: -418.92 → +33.52 kWh/yr
No regressions on 40 other corpus variants — gshp is the only fixture
that lodges a heat-pump code with diverging Space/Water columns.
Cohort-1 ASHP closure (S0380.28 reciprocal interpolation) is unaffected
because that path runs through `heat_pump_record` PCDB Appendix N3
when a PCDB Table 362 record is lodged; this fix is the Table 4a
fallback for cases without a PCDB record.
Extended handover suite: 899 pass / 0 fail. Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §12.4.4 (PDF p.36-37):
"Independent boilers that provide domestic hot water usually do so
throughout the year. With open fire back boilers or closed room
heaters with boilers, an alternative system (electric immersion)
may be provided for heating water in summer. In that case water
heating is provided by the boiler for months October to May and by
the alternative system for months June to September."
Scope is verbatim Table 4a codes 156 (Open fire with back boiler to
radiators) and 158 (Closed room heater with boiler to radiators). Range
cooker boilers (160, 161), pellet stoves with boilers (159), and
independent solid-fuel boilers (151, 153, 155) are NOT covered.
Pre-slice, the cascade treated the back-boiler cohort identically to
year-round solid-fuel mains: (59)m primary loss applied Jun-Sep, HW
fuel kWh was billed entirely at the boiler's solid-fuel rate, the HW
CO2 / PE factors used the boiler fuel's annual factor, and the off-peak
electric standing charge (£40 for 18-hour tariff) was not added because
the cert's lodged water-heating fuel code was anthracite.
Implementation (4 wired pieces):
1. `_section_12_4_4_summer_immersion_applies(epc, main)` — predicate
gate keyed on back-boiler SAP code (156, 158) + WHC ∈ {901, 902, 914}
"HW from main heating" + cylinder present.
2. `_primary_loss_override` zeroes (59)m for Jun-Sep when the predicate
fires — matches the Elmhurst P960 worksheet which has (59) Jun-Sep =
0 for SF2 (vs ~42 kWh/month for SF3 range cooker).
3. `_section_12_4_4_hw_blend(...)` — returns the 5-tuple
(annual_hw_fuel_kwh, blended_cost_gbp_per_kwh, blended_co2_factor,
blended_pe_factor, extra_standing_charge_gbp). The blend is kWh-
weighted across:
- Winter Oct-May: boiler fuel at the boiler's Table 32 unit price /
Table 12 annual CO2 / Table 12 annual PE factor
- Summer Jun-Sep: standard electricity (Table 12d/12e monthly
factors weighted by summer (62)m demand) priced at the tariff's
off-peak low rate per Table 13 note 2 (the 6.8 - 0.036V × N -
0.105V dual-immersion formula clamps to zero high-rate for
normal V/N combos on tariffs with ≥18 hrs low rate; SF2 has
V=110, N≈2 → 100% low-rate)
- The Table 32 off-peak electric standing charge that fires when
hot water uses off-peak electricity per Table 12 note (a). For
EIGHTEEN_HOUR tariff this is Table 32 code 38 = £40.
4. Orchestrator (`cert_to_inputs`) resolves the blend once and overrides
`hot_water_kwh_per_yr`, `hot_water_fuel_cost_gbp_per_kwh`,
`hot_water_co2_factor_kg_per_kwh`, `hot_water_primary_factor`, and
`standing_charges_gbp` when the predicate fires. Other certs fall
back to the existing single-fuel HW helpers (no behaviour change).
Worksheet evidence (heating-systems corpus property 001431 SF2 — code
158 + WHC=901 + cylinder thermostat + 18-hour tariff):
- (62) Oct-May = 2205.80 kWh, Jun-Sep = 684.55 kWh
- (217)m = 65 winter / 100 summer, (219) = 3393.5 anthr + 684.55 elec
= 4078.06 fuel kWh
- (247) HW cost = 4078.06 × 4.27 p/kWh blended = £174.25
- (251) Standing = £40 (off-peak electric standing only — solid fuel
has no standing charge)
- (255) Total = £801.13
Closures (SF2):
ΔSAP_c +1.86 → -0.0000 (EXACT)
Δcost -£42.84 → -£0.00 (EXACT)
ΔCO2 +346.87 → -93.10 kg/yr (residual: Elmhurst CO2 blend uses a
different summer-month weighting that
the SAP 10.2 Table 12d cascade does
not reproduce — spec-correct per
Table 12d header).
ΔPE -605.76 → -1027.51 kWh/yr (same spec-vs-Elmhurst PE blend
artifact via Table 12e monthly
cascade).
No regressions: 40/41 corpus variants unchanged (gate is narrow by SAP
code 156/158). Extended handover suite 898 pass / 0 fail. Pyright net-
zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off
the DHW timing arrangement, the middle row giving winter h=5 / summer
h=3 for "Cylinder thermostat, water heating NOT separately timed".
Solid-fuel boiler systems (Table 4a codes 151-161 — independent boilers,
open-fire + back boilers, closed room heaters with boilers, range cooker
boilers, stoves with boilers) do not ship with dual programmers. Per
SAP 10.2 §9.2.4 (PDF p.27) these are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler" — the
appliance itself is the timer. DHW timing follows the burn schedule,
not a separate cylinder programmer, so the middle Table 3 row applies.
Pre-slice `_separately_timed_dhw` returned True for any cylinder +
non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel
boilers through h=3 year-round (the third row, "Cylinder thermostat,
water heating separately timed"). That under-counted winter (59)m
by ~21 kWh/month × 8 winter months across the affected cohort, with
the under-counted water-heating gain propagating into MIT / SH / SAP.
New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES`
(frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before
the existing cylinder-present fallback. The post-S0380.140 electric-
immersion / heat-pump / no-main branches are unchanged. Table 4b
liquid-fuel boilers (101-141) keep the True default — modern gas/oil
installations standardly include dual programmers and the worksheet
confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at
h=3 year-round.
Worksheet evidence (heating-systems corpus property 001431):
- solid fuel 3 (SAP code 160 range cooker boiler + WHC=901
cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0)
and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30
→ −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact).
- solid fuel 2 (SAP code 158 closed room heater + back boiler):
same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP
is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers
(codes 156, 158) — the worksheet has summer (59)m = 0 because the
Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes
Jun-Sep HW through an electric immersion at η=100%. That's a
bigger lift (monthly HW efficiency + fuel-split plumbing) and is
a follow-up slice.
Other corpus variants: no impact (verified via cohort sweep). The
gate is narrow by SAP code so only the 2 affected variants move.
Extended handover suite: 897 pass / 0 fail (+1 from new AAA test).
Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData`
import on the new test's `_cylinder_epc_for` return annotation).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three slices closed:
- S0380.150 18-hour tariff for pumps+lighting (§12 + App F2)
- S0380.151 RdSAP 10 §4.1 Table 5 extract-fans default
- S0380.152 Table 3 primary loss for solid-fuel back-boilers
Cluster A closed; Cluster B partial (SF3 done, SF2 partial); Cluster
C open. Σ|ΔSAP| 14.5 → 6.4 across the 25 cascade-OK cohort variants.
Mid-session pivot documented: my Cluster B hypothesis was wrong
(Table 9c step 12), the actual gap was Table 3 primary loss for
solid-fuel boilers. Discipline added: dump per-line worksheet data
before forming a spec hypothesis.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:
"Primary circuit loss applies when hot water is heated by a heat
generator (e.g. boiler) connected to a hot water storage vessel
via insulated or uninsulated pipes (the primary pipework)."
The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:
- WHC=901/902/914 (HW from main heating system) + wet boiler +
cylinder → primary loss applies (back-boiler / wet boiler heats
cylinder via primary loop).
- WHC=903 (HW from a separate electric immersion / secondary) → no
primary loss even when the main is a wet boiler.
Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.
Worksheet evidence on the 001431 corpus (all age G, same cylinder):
- solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr → apply
- solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr → apply
- solid fuel 5 (code 153, WHC=903): ws (59) = 0 → skip
- solid fuel 4..11 (633/636 non-boilers, WHC=903): skip
The fix:
- `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
parameter (default None for back-compat with synthetic tests).
- New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
+ `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
- Call site `_primary_loss_override` passes
`epc.sap_heating.water_heating_code`.
Heating-systems corpus impact:
- solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
PE -918.6 → -214.3 kWh/yr
- solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
PE -1241.7 → -754.1 kWh/yr
- All other variants: unchanged
SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).
Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 Specification §4.1 Table 5 "Ventilation parameters" (PDF p.28)
verbatim — "Extract fans" entry:
• Number of extract fans if known
• If number is unknown:
Not park home:
Age bands A to E all cases → 0
Age bands F to G all cases → 1
Age bands H to M up to 2 hab. rooms → 1
3 to 5 hab. rooms → 2
6 to 8 hab. rooms → 3
more than 8 hab. rooms → 4
Park home:
Age band F all cases → 0
Age bands G onwards all cases → 2
The Elmhurst Summary §12.0 renders "No. of intermittent extract fans: 0"
as the form for *unknown*; every other §2 chimney/flue line item follows
"number if known, or 0 if not present" and the cascade trusts the lodged
value verbatim. Only extract fans have a non-zero age-band default.
Pre-slice the cascade read the lodged 0 verbatim → cohort-wide -0.044
ACH ventilation deficit (= -2.6 W/K HLC, = -1.2% SH demand, = ~-0.3 SAP
per variant). All 25 cascade-OK corpus variants are age G + 4 habitable
rooms + not park home → Table 5 default = 1 fan.
New helper `_rdsap_extract_fans_default(age_band, habitable_rooms, *,
is_park_home)` + wiring in `ventilation_from_cert` applies
`max(lodged, table_5_default)` so the spec minimum fires when lodging
is below it.
Heating-systems corpus impact (25 cascade-OK variants):
oil 1, oil pcdb 1/2/3 +0.27..+0.29 → EXACT (<1e-4)
electric 1, solid fuel 5/6/7/8 +0.28..+0.43 → EXACT
pcdb 1, ashp +0.41 / +0.18 → ±0.02
electric 3/6/7/8/9, sf 4/9/10/11 +0.39..+0.60 → +0.08..+0.12
electric 5 -0.74 → -1.18 (Cluster B over-shoot)
electric 2 -0.24 → -0.46 (Cluster C HW gap)
gshp +1.09 → +0.94 (Cluster C HW gap)
solid fuel 2/3 +3.08 / +1.76 → +2.77 / +1.31
Cluster A (cohort-wide HLC deficit) is closed. The four remaining open
fronts (Clusters B + C) are now visible without offsetting bugs:
- Cluster B (Table 9c step 12 R sign): electric 5, solid fuel 2/3
- Cluster C (HW kWh cascade): gshp + electric 2 (Appendix N3)
solid fuel 2/3 (Table 4b HW efficiency)
Golden-fixture re-pins:
cert 0240 (age J, TFA 118): PE +2.18 → +5.80, CO2 +0.13 → +0.32
cert 0390-2954 (age F, TFA 360): PE -28.27 → -27.97, CO2 -2.74 → -2.71
Pyright net-zero (44 → 44). Extended handover suite: 893 → 895 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §12 (PDF p.45 lines 2280-2283):
"The 18-hour tariff is only for use with electric CPSUs with
sufficient energy storage to provide space (and possibly water)
heating requirements for 2 hours. Electricity at the low-rate price
is available for 18 hours per day, with interruptions totalling 6
hours per day, with the proviso that no interruption will exceed 2
hours. The low-rate price applies to space and water heating, while
electricity for all other purposes is at the high-rate price."
SAP 10.2 Appendix F2 (PDF p.63 lines 3809-3812):
"F2 Electric CPSUs using 18-hour electricity tariff. The 18-hour
low rate applies to all space heating and water heating provided
by the CPSU. The CPSU must have sufficient energy stored to provide
heating during a 2-hour shut-off period. The 18-hour high rate
applies to all other electricity uses."
Table 12a Grid 2 omits 18-hour / 24-hour from its 7-hour / 10-hour
table; pre-slice the cascade's `_other_fuel_cost_gbp_per_kwh` fell
through Grid 2's `NotImplementedError` to
`prices.standard_electricity_p_per_kwh` (Table 32 code 30 = 13.19
p/kWh). Per §12 + Appendix F2 the 18-hour rule is explicit fraction =
1.0 at the high rate — pumps, fans, and lighting bill at the 18-hour
high rate (Table 32 code 38 = 13.67 p/kWh).
All 41 heating-systems corpus variants lodge `meter_type='18 Hour'`,
so this gap was cohort-wide. Pre-slice the cascade undercounted
pumps + lighting cost by (13.67 − 13.19) × kWh on every variant:
oil 1 Δcost -£9.31 → -£6.69 (closed £2.62, pumps 265 +
lighting 282 × £0.0048)
oil pcdb 1/2 Δcost -£8.32 → -£6.29 (closed £2.03)
oil pcdb 3 Δcost -£8.91 → -£6.29 (closed £2.62)
pcdb 1 Δcost -£11.10 → -£9.07 (closed £2.03)
ashp Δcost -£5.57 → -£4.22 (closed £1.35, lighting only)
electric 1..9 Δcost shift ~ -£1.35..+£1.35 (lighting only;
storage / room-heater
certs carry pumps_fans
= 0)
solid fuel 4..11 Δcost ~ -£1.55 (lighting only)
gshp Δcost -£26.48 → -£25.12 (closed £1.35)
Pyright net-zero (43 → 43). Extended handover suite: 892 → 893 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the four slices that closed the oil-cohort Table 4f gap:
.146 primary loss for Table 4b regular boilers, .147 Eq D1 for
non-PCDB Table 4b, .148 liquid fuel boiler aux 100 kWh, .149
per-pump-age circulation + wet-boiler gate.
Documents the cohort-wide ~-£10/yr cost residual that S0380.149's
spec correctness exposed — the new next-slice front. Highlights the
user directive [[feedback-software-no-special-handling]] that
surfaced during S0380.147 and continued to apply through .149.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" row:
Liquid fuel boiler — flue fan and fuel pump 100 kWh/yr c) d)
Note c): "Applies to all liquid fuel boilers that provide main heating,
but not if boiler provides hot water only. Where there are two main
heating systems include two figures from this table."
Pre-slice the cascade's `_table_4f_additive_components` only wired:
- (230a) MEV / MVHR
- (230e) Main 2 gas-boiler flue fan (45 kWh)
- (230g) Solar HW pump
The liquid-fuel sibling row was missing — oil 1 worksheet (230d) and
oil pcdb 3 worksheet (230d) both lodge 100 kWh/yr "oil boiler pump"
that the cascade was silently skipping.
Implementation:
- Add `_LIQUID_FUEL_CODES = frozenset({4, 71, 73, 75, 76})` and new
`is_liquid_fuel_code(fuel_code)` helper in
`domain/sap10_calculator/tables/table_32.py`. Mirror of
`is_electric_fuel_code` — routes through `_to_table_32_code`
normalisation so Elmhurst-derived Table 32 codes (e.g. code 23
= bulk wood pellets, solid) don't collide with API enum codes
(where 23 = B30D community).
- Extend `_table_4f_additive_components` to add 100 kWh for Main 1
when `is_liquid_fuel_code(main.main_fuel_type)` returns True
(`isinstance(int)` guard for the `Union[int, str]` field). Mirror
the same gate for Main 2 per Note c) "Where there are two main
heating systems include two figures".
- LPG is GAS (Table 4b/4f convention, Ecodesign classification) —
`_LIQUID_FUEL_CODES` deliberately excludes 2/3/5/9 LPG codes.
Cascade impact across heating-systems corpus:
| Variant | SAP Δ | Cost Δ | PE Δ |
|-----------|-------------|-------------|-------------|
| oil 1 | +1.18→+0.60 | -£27→-£14 | -276→-124 |
| oil pcdb 1| +0.42→-0.15 | -£10→+£3.4 | -84→+67 |
| oil pcdb 2| +0.42→-0.15 | -£10→+£3.4 | -84→+67 |
| oil pcdb 3| +1.16→+0.59 | -£27→-£14 | -271→-120 |
| pcdb 1 | +0.57→-0.03 | -£13→+£0.6 | -109→+42 |
Cohort closures: pcdb 1 EXACT (-0.03), oil pcdb 1/2 closed to -0.15.
Golden fixtures impact:
- cert 0240 (dual-main oil combi 130): SAP integer 73→72 (resid
+0→-1), PE +1.02→+2.52, CO2 +0.11→+0.14. Dual-main certs add
2 × 100 = 200 kWh aux per Note c). Cert's published SAP 73
suggests the dual-main Q_space split (main_heating_fraction)
may also need wiring — slice candidate.
- cert 0390 (Firebird PCDF 9005 oil combi): PE -28.50→-28.08
(CLOSER to zero), CO2 -2.75→-2.73 (CLOSER to zero), SAP +7
unchanged.
Test:
test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
100_kwh — asserts oil pcdb 3 inputs.pumps_fans_kwh_per_yr ≥ 230
(130 base + 100 liquid fuel boiler aux).
Extended handover suite: 891 pass, 0 fail. Pyright net-zero (44=44).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the two slices that closed oil 1 from +2.66 → +1.18 SAP via
Table 3 primary-loss extension (.146) + Appendix D §D2.1 (2) Equation
D1 wiring for non-PCDB Table 4b boilers (.147). Highlights the user
directive that surfaced this session ("BRE/Elmhurst software follows
spec exactly; no special non-spec handling") and the resulting pin
shifts on cert 0240 + 6035 (combi-no-cylinder golden fixtures
re-pinned per spec correctness).
Ranks next-slice candidates: oil 1 Table 4f auxiliary energy (~+0.4
SAP closure remaining), electric 5 -1.43 regressed by .145, solid
fuel 2/3 anthracite outliers, community heating + electric storage
unblocking.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):
If the boiler provides both space and water heating, and the summer
seasonal efficiency is lower than the winter seasonal efficiency,
the efficiency is a combination of winter and summer seasonal
efficiencies according to the relative proportion of heat needed
from the boiler for space and water heating in the month concerned:
Q_space + Q_water
η_water,m = ───────────────────────────────
Q_space/η_winter + Q_water/η_summer
where Q_space (kWh/month) is the quantity calculated at (98c)m
multiplied by (204) or by (205);
Q_water (kWh/month) is the quantity calculated at (64)m;
η_winter and η_summer are the winter and summer seasonal
efficiencies (from Table 4b).
Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the
`pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB
Table 4b boilers (`sap_main_heating_code` 101-141) where the cert
lodges no `main_heating_index_number`, the cascade fell through to
the scalar `water_efficiency_pct` divisor — which resolved via WHC
901 inherit to Table 4b WINTER eff (wrong direction; spec wants the
monthly Eq D1 blend).
This slice:
- Adds `domain/sap10_calculator/tables/table_4b.py` with the full
41-row Table 4b (winter, summer) pair dict for codes 101-141
verbatim from SAP 10.2 PDF p.168 (Table 4b).
- Refactors `_apply_water_efficiency` parameter from
`pcdb_record: Optional[GasOilBoilerRecord]` to
`eq_d1_winter_summer_pct: Optional[tuple[float, float]]` —
decouples the Eq D1 input from the PCDB record so a Table 4b
fallback can populate it without faking a PCDB record.
- Resolves Eq D1 inputs at the call site with priority order:
1. PCDB Table 105 winter/summer (existing path)
2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB
absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form
of "boiler provides both space and water heating").
§9.4.11 -5pp interlock applies symmetrically to both columns of
whichever (winter, summer) tuple is resolved.
Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May
79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with
Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) =
Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice.
Cascade impact:
Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid):
oil 1 SAP +1.76 → +1.18 (Δ -0.59)
cost -£40.60 → -£27.12 (Δ +£13.48)
CO2 -129.22 → -55.36 (Δ +73.86 kg/yr)
PE -590.02 → -275.52 (Δ +314.50 kWh/yr)
Remaining oil 1 residual is Table 4f auxiliary energy (cascade
pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler
pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice.
Golden fixtures (cert-pinned, integer-rounded PE):
cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02
cert 6035 (gas combi 104, no cylinder): PE +46.10 → +47.29
Both shifts reflect spec-correct Eq D1 now firing for non-PCDB
combi-no-cylinder configs. The pre-slice near-zero pin on cert
0240 was masking offsetting cascade gaps (likely Table 4f
auxiliary energy and/or dual-main Q_space split per (98c)m ×
(204) which the cascade currently treats as full demand).
Following [[reference-unmapped-sap-code]] discipline, the new Table
4b dict is the canonical spec-source — `domain.sap10_ml.sap_
efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for
the ML feature cascade and is left in place per the sap10_ml
deprecation plan (separate migration).
Test:
test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_
table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel
annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)).
Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss":
"Primary circuit loss applies when hot water is heated by a heat
generator (e.g. boiler) connected to a hot water storage vessel via
insulated or uninsulated pipes (the primary pipework). Primary loss
is set to zero for the following:
Electric immersion heater
Combi boiler ...
CPSU ..."
A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a cylinder is in neither zero-loss list, so primary loss must
apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies`
only covered PCDB Table 322 records (S0380.142) — when the cert lodges
a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil
boiler") with no PCDB index and no `main_heating_category` lodgement,
primary loss silently fell through to zero.
This slice extends the Elmhurst-path fallback in `_primary_loss_applies`
to fire when `sap_main_heating_code` is in the Table 4b code range
(101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3:
Combi codes: 103, 104, 107, 108, 112, 113, 118, 128, 129, 130
CPSU codes: 120, 121, 122, 123
Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 ×
[0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat +
separately timed DHW → h=3 winter & summer per Table 3 split). Annual
sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual.
Cascade impact on heating-systems corpus:
- oil 1 SAP residual +2.66 → +1.76 (Δ -0.90)
cost -£61.24 → -£40.60 (Δ +£20.64)
CO2 -242.27 → -129.22 (Δ +113.05 kg/yr)
PE -1050.49 → -590.02 (Δ +460.47 kWh/yr)
Only the oil 1 variant moves — every other cascade-OK variant either
already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/
2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil
codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for
free by the same dispatch when their certs surface in future cohorts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five slices closing pcdb 1 (+6.95→+0.57 via §9.4.11 + §4 cylinder
gates + RdSAP10 Table 29) and the electric storage cluster (e3/e6/e7
+2.5/+1.3 SAP → <0.21 each via Table 4e (92)m→(93)m). Cumulative
|ΔSAP| 18.0 → 12.2 (-32%). Open fronts ranked + spec-source index.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4e (PDF p.170-173) "Heating system controls":
3. The 'Temperature adjustment' modifies the mean internal
temperature and is added to worksheet (92)m.
SAP 10.2 Table 9c step 8 (PDF p.184): "Apply adjustment to the mean
internal temperature from Table 4e, where appropriate".
Pre-slice the cascade hardcoded `control_temperature_adjustment_c
=0.0` at all three call sites of `mean_internal_temperature_monthly`
and `space_heating_section_with_results`. The §8 heat loss calc
therefore drove off (92)m unchanged → §8 SH demand under-counted on
every cert whose `main_heating_control` lodges a non-zero adjustment.
Table 4e adjustments by code (full p.170-173 coverage):
Group 0 — No heating system:
2699: +0.3
Group 1 — Boilers with radiators/UFH (+ micro-CHP):
2101, 2102: +0.6 (no thermo / programmer-only)
2103..2113: 0
Group 2 — Heat pumps:
2201, 2202: +0.3
2203..2210: 0
Group 3 — Heat networks:
2301, 2302: +0.3
2303..2314: 0
Group 4 — Electric storage:
2401 (Manual charge): +0.7
2402 (Automatic charge): +0.4
2403 (Celect): +0.4
2404 (HHR controls): 0
Group 5 — Warm air:
2501, 2502: +0.3
2503..2506: 0
Group 6 — Room heaters:
2601: +0.3
2602..2605: 0
Group 7 — Other systems:
2701, 2702: +0.3
2703..2706: 0
New `_control_temperature_adjustment_c(main)` helper consults
`_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE` (52 entries, full Table 4e
coverage). Strict-raises `UnmappedSapCode` on present-but-unmapped
codes per [[reference-unmapped-sap-code]] so spec-coverage gaps
surface at test time. The helper is wired to all three call sites
of the MIT/SH orchestrators in cert_to_inputs.
Corpus impact — closes the +2.5 SAP cluster substantially:
Variant | control | pre → post | delta
------- | ------- | -------------- | -----
e3 (401)| 2401 | +2.55 → -0.09 | -2.46 (massive close)
e6 (404)| 2402 | +1.33 → -0.17 | -1.50
e7 (408)| 2402 | +1.29 → -0.20 | -1.49
e2 (524)| 2502 | +0.47 → -0.18 | -0.65
e5 (402)| 2402 | +0.07 → -1.43 | -1.50 (regressed —
previously net-zero
from offsetting bugs)
Cumulative |ΔSAP| across these 5: 5.71 → 2.07 (-3.64 pts closed).
electric 3 / 6 / 7 / 8 / 9 now all within 0.20 SAP of worksheet.
Golden fixtures unchanged (API certs in those tests don't lodge
non-zero-adjustment control codes; suite stays 888 pass).
Extended handover suite: 888 pass, 0 fail (was 887 + 1 new AAA test).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 11 (PDF p.188) "Fraction of heat supplied by
secondary heating systems" — the "Electric storage heaters (not
integrated)" row splits by Table 4a sub-type:
- not fan-assisted: 0.15
- fan-assisted: 0.10
- high heat retention (as defined in 9.2.8): 0.10
Plus separate rows:
Integrated storage/direct-acting electric systems: 0.10
Electric room heaters: 0.20
Other electric systems (e.g. underfloor): 0.10
Cross-referenced with SAP 10.2 Table 4a (PDF p.166) Electric
storage codes:
401: Old (large volume) storage heaters — not fan-assisted
402: Slimline storage heaters — not fan-assisted
403: Convector storage heaters — not fan-assisted
404: Fan storage heaters — fan-assisted
405: Slimline + Celect — not fan-assisted
406: Convector + Celect — not fan-assisted
407: Fan + Celect — fan-assisted
408: Integrated storage + direct-acting — "Integrated"
409: High heat retention — HHR
421: Underfloor heating — "Other electric"
Pre-slice the cascade defaulted `_secondary_fraction` to 0.10 for
every forced electric-storage code (Elmhurst mapper leaves
`main_heating_category=None`, dispatch falls through to the
`_SECONDARY_HEATING_FRACTION_DEFAULT` 0.10), missing the 0.15
not-fan-assisted sub-row on codes 401/402/403/405/406.
Two compounding spec-citable fixes:
(a) New `_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE` dispatch dict
consulted before the category-based lookup in
`_secondary_fraction`. Routes each Table 4a 4xx code to its
Table 11 sub-row fraction.
(b) Code 408 removed from `_FORCE_SECONDARY_FOR_MAIN_CODES`.
SAP 10.2 §A.2.2 (PDF p.~189) verbatim: "This applies to main
heating codes 401 to 407, 409 and 421" — 408 is explicitly
NOT in the spec's forced list. The integrated storage+direct-
acting heater's direct-acting element acts as the secondary
already, so the calculation doesn't add another.
Corpus impact (electric variants — Elmhurst mapper path):
- electric 3 (SAP 401): sec_frac 0.10 → 0.15; CO2 -117.84 →
-108.88; PE -1121.97 → -1093.18. SAP / cost residual unchanged
because the off-peak meter routes the cost calc through the
`_ZERO_FUEL_COST_FOR_OFF_PEAK` sentinel + legacy scalar-field
math which bills main and secondary at the same off-peak low
rate (7.41 p/kWh) — main-vs-secondary split is cost-neutral.
- electric 5 (SAP 402): sec_frac 0.10 → 0.15; CO2 -11.08 → -2.48;
PE -161.03 → -133.36. Same cost-invariance.
- electric 7 (SAP 408): forced-secondary removed → cascade secondary
fuel kWh 891 → 0 (matches worksheet); CO2 -37.86 → -53.57;
PE -498.47 → -549.37. SAP residual unchanged (same off-peak
cost-invariance).
- electric 4/6/8/9: no change (categories 404/409/421 keep their
existing 0.10 dispatch).
The remaining +2.55 SAP residual on electric 3 (+1.29 on electric 7)
is now confirmed to be driven by space-heating DEMAND undercount
(cascade SH demand 10083 kWh vs worksheet 11088 kWh for electric 3;
8914 vs 9529 for electric 7), not by sec_frac dispatch. That's a
separate slice — likely §9 MIT calc or §8 gains/HLC for storage-
heater R values, follow-up after this slice.
Extended handover suite: 887 pass, 0 fail (was 886 + 1 new AAA test).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three slices on top of `8ee877e4` closed cert pcdb 1 from SAP +6.95
to +0.57 (-92% magnitude) via spec-citable fixes in three distinct
cascade areas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 Specification §10.11 Table 29 page 56 — "Heating and hot
water parameters" → row "Hot water cylinder insulation if not
accessible":
Age band of main property A to F: 12 mm loose jacket
Age band of main property G, H: 25 mm foam
Age band of main property I to M: 38 mm foam
Pre-slice the Elmhurst mapper passed through cylinder_insulation_type
and cylinder_insulation_thickness_mm as None whenever §15.1 lodged
"Cylinder Size: No Access" (the inaccessible-cylinder lodging form)
because the Summary doesn't carry the measured insulation label /
thickness on inaccessible cylinders. The cascade's §4 (56)m water
storage loss override at `_cylinder_storage_loss_override` then
returned None (gates on `insulation_type == _CYLINDER_INSULATION_
TYPE_FACTORY` + thickness lodged), so the worksheet's (56)m sum was
dropped entirely from (62)m.
Cert pcdb 1 (corpus 001431, Potterton KOA PCDB 716 + 110 L cylinder
+ §15.1 "No Access" + age G 1983-1990) exposes the gap: worksheet
(56)m monthly ≈ 59.06 kWh ((51) factor 0.024 from Note 1 formula
L = 0.005 + 0.55 / (t + 4) at t = 25 mm) × (52) volume factor 1.0294
× (53) Table 2b temperature factor 0.702 — annual sum ≈ 695 kWh,
missing from the pre-slice cascade entirely.
New helper
`_resolve_elmhurst_inaccessible_cylinder_insulation(age_band)` in
`datatypes/epc/domain/mapper.py` returns the
`(insulation_type_code, thickness_mm)` tuple for age G/H (factory
foam, 25 mm) and I/J/K/L/M (factory foam, 38 mm). Age bands A-F
(loose jacket, 12 mm) raise `UnmappedElmhurstLabel` — no current
Elmhurst corpus member is age A-F with §15.1 = "No Access", and the
loose-jacket SAP10 cylinder_insulation_type enum value is not yet
plumbed into the calculator's `cylinder_storage_loss_factor_table_2`
dispatch (only factory=1 is exercised). The strict-raise mirrors the
[[reference-unmapped-sap-code]] pattern so a future fixture forces
the loose-jacket extension explicitly.
`_map_elmhurst_sap_heating` calls the resolver before constructing
SapHeating; the accessible-cylinder path stays unchanged
(measured label + thickness from §15.1).
Corpus impact:
- pcdb 1 (only "No Access" cylinder variant in the corpus):
SAP +2.86 → +0.57; cost -£63.22 → -£12.55; CO2 -328.74 → -51.19;
PE -1257.97 → -109.46. The remaining residual is a ~1.3% cascade-
side undercount on space-heating demand (cascade SH 7900 kWh vs
worksheet (98c) 8004 kWh) plus minor pumps/fans rate noise — well
within the spec-cascade floor.
Combined with S0380.141 (§9.4.11 -5pp interlock on SH + Eq D1) and
S0380.142 (§4 lines 7700/7702 cylinder-presence gates), the
pre-slice pcdb 1 residual SAP +6.95 closes to +0.57 (-92% magnitude),
cost -£157.61 to -£12.55, PE -3135.30 to -109.46.
Extended handover suite: 886 pass, 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §4 line 7702 (PDF p.137):
Combi loss for each month from Table 3a, 3b or 3c (enter '0' if
not a combi boiler)
SAP 10.2 Table 3 (PDF p.160) zero-loss list for primary circuit loss:
Electric immersion heater
Combi boiler (including when it is part of a combined heat pump and
boiler package and provides all the hot water)
CPSU (including electric CPSU)
Boiler and thermal store within a single casing
Separate boiler and thermal store connected by no more than 1.5 m
of insulated pipework
Direct-acting electric boiler
Heat pump (...) with hot water vessel integral to package
Combi boilers are defined by Table 3's zero-loss list entry: they
provide instantaneous DHW with no storage vessel. A cert that lodges
a hot-water cylinder therefore has a non-combi heat generator —
the cylinder bypasses any instantaneous-DHW capability and the
boiler acts as a regular boiler for the DHW circuit.
Two compounding gaps for PCDB Table 322 (gas/oil boiler) records
with a lodged cylinder:
(a) (61)m combi loss: pre-slice the cascade routed every PCDB record
through `pcdb_combi_loss_override` regardless of cylinder
presence. For PCDB regular boilers (subsidiary_type=0, store_
type=0, separate_dhw_tests=0) this dispatched to Table 3a row 1
"Instantaneous without keep-hot" — 600 kWh/yr. Cert pcdb 1
(Potterton KOA PCDB 716 + 110 L cylinder) exposed this: worksheet
(61)m = 0 ; cascade was lodging 600 kWh/yr keep-hot loss on a
regular oil boiler.
(b) (59)m primary loss: `_primary_loss_applies` gated on
`main_heating_category in {1, 2}`. The Elmhurst path leaves
`main_heating_category=None`, so the gate returned False even
when the cert lodged a PCDB Table 322 (gas/oil boiler) record +
a cylinder. Worksheet (59)m sum ~1177 kWh ; cascade was zero.
Fix:
- `_water_heating_worksheet_and_gains` now zeroes combi_loss_override
whenever `epc.has_hot_water_cylinder` is True (top-level gate
preceding the `pcdb_combi_loss_override` dispatch). Preserves the
existing non-cylinder fallback for HP / no-PCDB / community-heat
certs that lack a main_heating_category lodgement.
- `_primary_loss_applies` extends the Elmhurst-path fallback: when
`main_heating_index_number` resolves to a PCDB Table 322 record,
return True (the cert is implicitly a boiler — Table 3 row 1 covers
any "heat generator (e.g. boiler) connected to a hot water storage
vessel via insulated or uninsulated pipes").
Corpus impact:
- pcdb 1 (Potterton KOA + cylinder, the only PCDB Table 322 + cylinder
combination in the corpus): SAP +3.40 → +2.86; cost -£75.68 →
-£63.22; CO2 -397.02 → -328.74; PE -1601.74 → -1257.97.
- Golden cert 0390-2954-3640-2196-4175 (Firebird oil combi PCDF 9005
+ cylinder): PE -26.37 → -28.50; CO2 -2.55 → -2.75. Combi-loss
removal (-600 kWh/yr) exceeded the primary-loss gain (~5-10 kWh
given the cert's insulated pipework + thermostat lodging), so the
net (62) shifted down. Direction is more spec-correct: the spec
treats a combi feeding a cylinder as a regular boiler for DHW,
matching the (61)m=0 + (59)m>0 worksheet behaviour.
Extended handover suite: 885 pass, 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock":
For the purposes of the SAP, an interlocked system is one in which
both the space and stored water heating are interlocked. If either
is not, the 5% seasonal efficiency reduction is applied to both
space and water heating; if both are interlocked no reductions are
made.
Table 4c (PDF p.169-170) lodges -5 for both Space and DHW columns on
the "No boiler interlock — regular boiler" row. Pre-slice the cascade
applied the -5pp adjustment ONLY to the `water_eff` scalar fallback
(`cert_to_inputs.py:4354`) and missed:
(a) the SH efficiency path (cascade kept the raw PCDB winter eff for
space heating);
(b) the PCDB Equation D1 monthly cascade (Eq D1 received raw
winter/summer values without the -5pp adjustment).
RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present if
there is a room thermostat and (for stored hot water systems heated
by the boiler) a cylinder thermostat. Otherwise not interlocked."
Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder + Cylinder Stat:
No) reproduces the pattern: worksheet (210) = 60% = PCDB winter
65 - 5; worksheet (217)m monthly Eq D1 pivots on (winter 60,
summer 48) not (65, 53).
The SH path is further gated on `pcdb_main is not None` because
§9.4.11 only applies to "gas and liquid fuel boilers" — cert 000565
(ASHP Main 1) keeps its raw SH eff. The combi-fed-cylinder DHW path
(cert 000565 WHC 914 to PCDB combi Main 2) continues to receive its
existing -5pp via the `water_pcdb_main` gate (unchanged).
Corpus impact: pcdb 1 SAP residual +6.95 → +3.40; cost -£157.61 →
-£75.68; CO2 -845.81 → -397.02; PE -3135.30 → -1601.74. No other
variant has PCDB main + cylinder + no thermostat, so the other 24
corpus pins are unchanged.
Extended handover suite: 884 pass, 0 fail (was 883 + 1 new AAA test
pinning the §9.4.11 SH eff path).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>