test_heating_systems_corpus.py (and one pcdb-1 cross-check in
test_cert_to_inputs.py) read the 001431 controlled-variable corpus PDFs
directly at runtime from `sap worksheets/heating systems examples/`, but
that directory was never committed — it was supplied locally on
2026-05-30 and only ever existed on dev machines. CI therefore errored
with "no Summary PDF in …" for all 57 corpus variants.
Commit the 82 corpus PDFs (41 populated variant folders × Summary +
P960, 4.7 MB) in place so the cascade-vs-worksheet residual pins run in
CI, matching the existing convention where the U985 / 000565
conformance fixtures are committed under
backend/documents_parser/tests/fixtures/ (31 PDFs already tracked).
Only the .pdf fixtures are added; the stray .DS_Store and a P960 .txt
dump in pcdb 1/ are left untracked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two unrelated breakages surfaced after merging the PR into this branch;
neither was caused by the appliances/cooking work.
test_appendix_u.py (9 failures) — signature drift + wrong methodology
label. The climate lookups were renamed `external_temperature_c(region=…)`
→ `(region_or_climate, month)` when PostcodeClimate support landed for
the demand cascade, but the tests still passed `region=`. The expected
values match our SAP 10.2 _TABLE_U1/U2/U3 exactly (UK-avg Jan 4.3 °C,
Thames Jul 17.9 °C, solar Jul 189 W/m², Shetland Jan wind 9.5 m/s), so
these are valid 10.2 coverage — fixed the call signature to positional
and corrected the mislabelled "SAP 10.3" docstrings to SAP 10.2 (we
track 10.2 deliberately). Also converted pytest.approx → abs(x-y)<=tol
per the repo convention; pyright on the file drops 48 → 0.
test_table_32.py (2 failures) — the parametrised "match PDF p.95" test
pinned heating oil (code 4) = 7.64 and FAME (code 73) = 5.44, but the
table deliberately diverges from the PDF for these two carriers: oil =
5.44 (Slice S0380.131, two independent lodging engines agree the PDF
7.64 is the outlier) and FAME = 7.64 (Slice S0380.168). Updated the two
expected values to the worksheet-canonical figures the table actually
uses, with inline citations + a docstring note on the divergence.
Full calculator + property_baseline + heating-corpus suites: 1748 pass,
0 fail. pyright net-improving on both files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.
Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
appliances_kwh_per_yr — SAP 10.2 Appendix L L13/L14/L16a annual E_A
(sum of the §5 (68) monthly appliances kWh)
cooking_kwh_per_yr — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
estimate E_cook = 138 + 28×N
Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.
Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.
HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.
New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.
Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback: the SapResult -> Performance mapping should be a method, not a
free function you must know exists in the rebaseliner. Put the factory on
the target as `Performance.from_sap_result`, beside its sibling
`lodged_performance` and mirroring `Epc.from_sap_score` (the factory this
mapping already calls).
Not a `SapResult.to_performance()`: that would make the SAP calculator
import `Performance` (a property_baseline type), re-introducing the
engine->consumer coupling removed by the SapCalculator ABC. SapResult is a
TYPE_CHECKING-only import in performance.py (the body only reads attributes),
so the calculator module is not pulled in at runtime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback: annotate locals assigned from a method-call return or
attribute access, even though pyright infers them — the type is visible at
the assignment without chasing the callee. `result: SapResult` and
`sap_version: Optional[float]` in rebaseline(). Local annotations are not
evaluated at runtime, so the TYPE_CHECKING-only SapResult import stands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback: prefer an abstract base the calculator inherits from over a
structural Protocol. Define `SapCalculator(ABC)` in the calculator package
(the engine owns its own contract) and have `Sap10Calculator` inherit it;
a future methodology is another subclass. Placing the ABC with the engine —
not in property_baseline — keeps the dependency pointing consumer -> engine
(sap10_calculator imports nothing from property_baseline). Consistent with
the repo's existing port convention (FuelRatesRepository(ABC)).
CalculatorRebaseliner keeps its reference to SapCalculator type-only (under
TYPE_CHECKING), so the module still does not import the calculator at
runtime. Test fakes now inherit the ABC since structural conformance no
longer applies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 5b: update the FE-owned migration spec so the other repo can create the
bill columns in parallel.
- Bill block: per-section delivered kWh + cost (heating, hot water, lighting,
appliances, cooking, pumps/fans, cooling) + standing_charges_gbp,
seg_credit_gbp, total_annual_bill_gbp, fuel_rates_period.
- space_heating_kwh / water_heating_kwh (RHI recorded demand) marked SUPERSEDED
by heating_kwh / hot_water_kwh (calculator delivered fuel); kept until the bill
populates, then dropped.
- Cooling section kept (mostly 0 but affects the bill, cheap to store).
- Records the calculator-load-bearing posture (effective_* may differ from
lodged_* for pre-10.2) and that columns are defined now / populated when the
SapResult->EnergyBreakdown adapter + BillDerivation wiring land.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 5a: the promotion. Replaces StubRebaseliner in production and collapses the
shadow runner into the rebaseliner (ADR-0013 amendment).
- CalculatorRebaseliner runs Sap10Calculator on every Property:
* sap_version < 10.2 -> Effective Performance IS the calculator output
(band via Epc.from_sap_score, CO2 kg->t, PEUI rounded), reason "pre_sap10".
* sap_version >= 10.2 -> Effective = lodged (API figures on-target), and the
calculator only logs divergence (SAP>0.5, PEUI/CO2 1%) as a validation signal.
* a calculator raise propagates -> batch aborts (ADR-0012); fix the cert at once.
- Rebaseliner.rebaseline gains property_id (for the divergence log).
- LoggingCalculatorShadow / the calculator_shadow seam removed from the
orchestrator; its divergence-comparison logic now lives in the rebaseliner.
- StubRebaseliner kept (signature updated) for orchestrator/repo unit tests.
The SapResult->EnergyBreakdown adapter + BillDerivation wiring (to populate the
bill block) follow once the appliances/cooking SapResult fields land.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of Bill Derivation. sap_code_to_fuel(code) maps a SAP 10.2 / Table 32
fuel code to the canonical billing Fuel — bounded to the ~47 Table 32 codes (the
carrier, orthogonal to the PCDB product index, so all PCDB heat pumps share one
electricity code). Mains gas / LPG / oil+bioliquids / coal / smokeless / wood /
electricity (standard + off-peak) / heat-network groupings; an unmapped code
(dual fuel, grid-export) raises UnmappedSapCode rather than guessing.
Also: ADR-0014 deferred/TODO section records the stubbed appliances+cooking
(pending the SapResult fields), the off-peak day/night split, the heat-network
rate gap, and regional rates / ETL.
The SapResult -> EnergyBreakdown adapter (next slice) is gated on the
appliances/cooking fields landing on SapResult.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of Bill Derivation. BillDerivation(fuel_rates).derive(breakdown) takes a
delivered-energy breakdown (per-section EnergyLine(section, fuel, kwh) +
exported_kwh) and produces a Bill: per-section kWh + cost, standing charges,
SEG credit, and total.
- Each end-use line billed at its fuel's unit rate.
- Standing charge added ONCE per distinct fuel used (a meter, not an end use);
off-gas fuels carry 0 so contribute nothing — no metered/unmetered special case.
- SEG export credit subtracted.
- Deterministic (ADR-0006); raises UnpricedFuel (via FuelRates) on an unpriced
fuel (e.g. heat network) rather than billing at a wrong default.
Pure domain — no calculator dependency; the SapResult->EnergyBreakdown adapter
is slice 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of Bill Derivation — the reference-data foundation that later slices
price the calculator's per-end-use kWh against:
- Fuel enum (canonical billing fuels; the join key between the calculator's
SAP-code fuels and the rates snapshot). COAL + HEAT_NETWORK are members with
no national rate.
- FuelRates value object: unit_rate_p_per_kwh / standing_charge_p_per_day /
seg_export_p_per_kwh; raises UnpricedFuel on a fuel it has no rate for rather
than billing at a wrong default.
- FuelRatesRepository port (ADR-0011 Repo-reads-stored-reference-data) +
StaticFileFuelRatesRepository reading a committed JSON snapshot.
- Snapshot fuel_rates_2026_q2.json: GB national, Apr-Jun 2026 Ofgem cap
(gas/electricity) + DESNZ/NEP May 2026 (off-gas). Carries the full researched
data; the value object exposes single-rate fuels this slice. Off-peak
(day/night), house coal and heat network raise UnpricedFuel until later slices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the bills design from a /grill-with-docs session:
- ADR-0014: whole-home annual bill from SAP10 Calculation's delivered kWh per
end use, re-priced at real Fuel Rates (NOT the calculator's SAP-notional
total_fuel_cost_gbp, which is RdSAP Table 32 standardised prices ~half real
electricity). Fuel enum + FuelRates + FuelRatesRepository static snapshot;
per-section + total flat columns; raise on unpriced fuel (house coal /
heat network are the named gaps).
- ADR-0013 amendment: the shadow stepping-stone is collapsed — the calculator
is load-bearing now. effective=calculated for sap_version<10.2 (StubRebaseliner
floor 10.0->10.2); >=10.2 keeps lodged + logs divergence; a strict-raise
aborts the batch (load-bearing for bills regardless of version).
- CONTEXT: EPC Energy Derivation -> Bill Derivation (no "service" suffix);
Baseline Performance energy block = per-end-use kWh + per-section bill + total;
Fuel Rates = committed static snapshot; Rebaselining trigger threshold 10.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire Sap10Calculator into PropertyBaselineOrchestrator as a non-load-bearing
shadow runner. For each property it scores the Effective EPC beside the
load-bearing Lodged/Effective write, catches any strict-raise -> log.error
(never aborts the batch), and on success log.warning's divergence from Lodged:
SAP |continuous - lodged| > 0.5; PEUI/CO2 > 1% relative (CO2 after kg->tonnes).
Every line is tagged with sap_version so SAP-10.2 signal separates from
older-spec drift (ADR-0010 Validation Cohort).
Per ADR-0013, Calculated SAP10 Performance is not a persisted third value-set:
effective = calculated in every baselining scenario, so the calculator IS the
mechanism that produces Effective Performance (the Rebaseliner). It runs in
shadow only while being hardened; when overrides/estimation land it is promoted
to drive Effective and the failure posture flips to abort (ADR-0012, calculator
now load-bearing). No table change.
- ADR-0013 + CONTEXT (Calculated SAP10 Performance / Effective Performance /
Rebaselining) record the decision.
- CalculatorShadow port + LoggingCalculatorShadow + Calculator protocol.
- FakeCalculatorShadow for orchestrator unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Factual staleness fix flagged in the handover; the calculator lives in
domain/sap10_calculator/calculator.py. Glossary term 'Baseline Performance'
deliberately left unchanged (concept vs PropertyBaselinePerformance class).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Orientation for the next chat picking up the two open fronts after the
ara_first_run rebuild shipped:
- where things stand (merged to main via per-cert; branch/worktree layout;
PRs into per-cert), authoritative ADRs/CONTEXT to read,
- current architecture + key files (post baseline→property_baseline /
FirstRun→AraFirstRun rename),
- conventions + gotchas (TDD, ephemeral PG, FakeUnitOfWork, pyright noise to
ignore, gh-credential push workaround),
- Task 1: wire Sap10Calculator into PropertyBaselineOrchestrator (Calculated
SAP10 Performance as a third value-set; failure-posture decision),
- Task 2: Modelling (stubs to build out; MaterialsRepository naming open;
needs a UoW when writing Plans),
- the raising/no-op seams not to mistake for done,
- known doc drift flagged (CONTEXT term vs PropertyBaselinePerformance class;
stale domain/sap/ path → domain/sap10_calculator).
Also banners ara_backend_design.md as superseded (architecture) by ADR-0011/0012.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the "no system" corpus variant fully (ΔSAP +1.18 → <1e-4 on all
four metrics).
The cert lodges §15.0 "Water Heating Code: NON / SapCode 999" and §15.1
"Hot Water Cylinder Present: No". Per RdSAP 10 §10.7 (PDF p.55) "No
water heating system" verbatim: "the calculation is done for an
electric immersion heater. If the electric meter is dual the immersion
heater is also dual, but is a single immersion otherwise... for a
cylinder defined by the first row of Table 28 (110 litres) and the
first row of Table 29." Table 29 row 1 gives age-band cylinder
insulation (age G -> 25 mm foam) and assumes a cylinder thermostat
present for immersion-heated DHW.
The BRE-approved Elmhurst engine confirms the substitution: the P960
worksheet header lodges "WHS: 903 Electric immersion, Single", a 110 L
cylinder, and storage loss (56) = 594.32 kWh/yr, so HW (64) = (45)
1935.37 + 594.32 = 2529.6927.
Pre-slice the cascade trusted the lodged "no cylinder" -> added no
storage loss and a spurious Table 3a keep-hot combi loss; the wrong HW
heat-gains also propagated through §5/§7, over-stating the base MIT by
+0.25 K and space fuel by +228 kWh. New
`_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at
the top of cert_to_inputs (the demand cascade delegates here too) when
water_heating_code == 999, injecting WHC 903 + electricity fuel +
110 L cylinder + Table 29 insulation + assumed cylinder thermostat.
This closes HW fuel AND the downstream space residual in one move.
Age bands A-F (12 mm loose jacket) raise UnmappedSapCode — no corpus
member exercises that and the Table 2 loss-factor dispatch only has the
factory-foam path plumbed. Gate is keyed on code 999, unique to "no
system" in the corpus; 40 other variants + 858 section pins + 6 U985
fixtures unchanged. 936 pass; pyright net-zero 32 -> 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the residual S0380.177 exposed on oil 6. The cascade's central
heating pump used the bare Table 4f age default (41 kWh for "2013 or
later") but the worksheet (230c) = 53.3 kWh.
SAP 10.2 Table 4f (PDF p.175) footnote a) on the "Circulation pump"
rows reads verbatim: "Multiply by a factor of 1.3 if room thermostat
is absent." oil 6 lodges control code 2101 ("No time or thermostatic
control of room temperature") = no room thermostat, so 41 x 1.3 = 53.3
= ws (230c) EXACTLY; pumps/fans (231) = 53.3 + 100 (liquid-fuel boiler
flue fan/pump) = 153.3 EXACT. Same root cause (absent room thermostat)
as the S0380.177 Table 4c(2) interlock fix — both keyed on the new
`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`.
`_table_4f_circulation_pump_kwh` now multiplies the resolved pump kWh
by `_TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER = 1.3` when the main's
control code is in that set.
oil 6 now FULLY EXACT on all four metrics (ΔSAP/cost/CO2/PE < 1e-4).
The sibling oil 5 (same "2013 or later" pump age but control 2106 WITH
a room thermostat) keeps the bare 41 kWh and is unaffected — as do the
other 39 corpus variants (2101/2102 appear only on oil 6). 935 pass;
pyright net-zero 32 -> 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oil 6 (B30K standard liquid-fuel boiler, Table 4b code 126 winter 80 /
summer 68) lodges Main Heating Controls Sap code 2101 ("No time or
thermostatic control of room temperature") WITH a cylinder thermostat.
The cascade's `no_interlock` gate only checked the cylinder thermostat,
so oil 6 kept raw efficiency despite the P960 worksheet header lodging
"Boiler Interlock: No".
Per RdSAP 10 §3 (PDF p.57): boiler interlock is "assumed present if
there is a room thermostat and (for stored hot water systems heated by
the boiler) a cylinder thermostat. Otherwise not interlocked." Control
code 2101 (and 2102 "Programmer, no room thermostat") provides no room
thermostat — the two Table 4e Group 1 rows carrying the "+0.6 °C /
Table 4c(2)" annotation — so the boiler is NOT interlocked regardless
of the cylinderstat. SAP 10.2 Table 4c(2) (PDF p.169) "No thermostatic
control of room temperature – regular boiler" then deducts 5pp from
BOTH the Space and DHW seasonal efficiency.
Three changes in cert_to_inputs.py:
- new `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`;
- `no_interlock` now ORs room-thermostat absence with the existing
stored-HW cylinderstat-absence test (the RdSAP §3 conjunction);
- the Space -5pp leg fires for Table 4b non-PCDB boilers (code
101-141), not only PCDB-record boilers; the DHW leg is gated on a
cylinder being present (Table 4c(2) combi DHW = 0).
Result for oil 6: space fuel (211) = 13446.3457 EXACT, HW fuel (219) =
4099.5872 EXACT. ΔSAP +3.0518 → +0.0782, Δcost -£69.79 → -£1.68,
ΔCO2 -240.66 → -1.71, ΔPE -1112.66 → -18.61.
The spec-correct fix exposes a single residual cause (per
[[feedback-software-no-special-handling]]): the central heating pump
(230c) — cascade reads pump_age=2 → Table 4f 41 kWh but ws (230c) =
53.3 kWh. The 12.3 kWh gap fully accounts for the residual across all
three metrics; pinned as the S0380.178 forcing function.
All other 40 corpus variants + 858 section pins + 6 U985 fixtures
unchanged (2101/2102 boiler codes appear only on oil 6). Pyright
net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 §4 line 7702 (PDF p.137) defines (61)m as "Combi loss for
each month from Table 3a, 3b or 3c (enter '0' if not a combi
boiler)". Table 4b sub-rows 128 / 129 / 130 are explicit combi sub-
rows per the spec row names:
128: Combi oil boiler, pre-1998
129: Combi oil boiler, 1998 or later
130: Condensing combi oil boiler
Pre-slice `_table_3a_combi_loss_default_applies` gated only on
`main_heating_category ∈ {1, 2, 3, 6}`. The Elmhurst mapper leaves
`main_heating_category=None` on Table 4b liquid-fuel boilers (FAME,
HVO, B30K) — the cascade fell through to (61)m=0 despite the lodged
SAP code being a combi sub-row, under-counting (62)m by 600 kWh/yr
for FAME combi certs.
Extended the helper with a `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-
through (set already exists for the symmetric `_primary_loss_
applies` Table 4b non-combi branch — see S0380.146). The set carries
the canonical combi + CPSU sub-row codes (103/104/107/108/112/113/
118/120-123/128-130). For cylinder-lodged certs the existing
`if epc.has_hot_water_cylinder: combi_loss_override = zero_monthly`
guard in `_water_heating_worksheet_and_gains` still pre-empts the
combi-loss fall-through correctly — non-combi codes with cylinders
remain (61)m=0.
Closures (heating-systems corpus 001431):
oil 3 (code 128, FAME, no cylinder) ALL EXACT (±0.0000):
ΔSAP_c +2.5863 → -0.0000
Δcost -£61.89 → -£0.00
ΔCO2 -14.58 → +0.00
ΔPE -967.10 → +0.00
oil 4 (code 129, FAME, no cylinder) ALL EXACT (±0.0000):
ΔSAP_c +2.5603 → +0.0000
Δcost -£56.66 → +£0.00
ΔCO2 -13.35 → +0.00
ΔPE -884.90 → +0.00
Oil 6 (code 126, NOT a combi, with cylinder) unchanged — the fix
is gated on the combi sub-row set. Cohort moves from 9 pinned
residuals to 7.
933 pass + 0 fail (+1 new mapper test). Pyright net-zero on cert_
to_inputs.py + tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4e Group 3 (PDF p.173) — heat-network control codes
2301-2314 dispatch to control_type 1, 2, or 3. Code 2306 = "Charging
system linked to use of heating, programmer and TRVs" →
control_type=3, temperature_adjustment=0. Per Table 9 the elsewhere-
zone off-hours depend on control_type: type 1/2 → (7, 8); type 3 →
(9, 8). The two extra off-hours change the §7 (90) T_rest mean by
~0.6 K → (92) MIT by ~0.4 K → (98) SH demand by ~390 kWh/yr.
Pre-slice diagnosis: cascade defaulted `main_heating_control=2`
(modal RdSAP) when the §14.0 "Main Heating Controls Sap" field was
empty. The 5 community heating corpus variants ALL lodge the SAP
code in §14.1 Community Heating "Heating Controls SAP" instead
(format: bare 4-digit integer, e.g. "2306"). The extractor was
storing this in `CommunityHeating.heating_controls_sap` but the
mapper only read `mh.heating_controls_sap` (§14.0).
Two changes:
1. `_elmhurst_sap_control_code` extended to accept bare 4-digit form
("2306") in addition to the §14.0 narrative form ("SAP code 2106,
Programmer, room thermostat and TRVs"). Empty-string returns None
instead of swallowing through the original `re.match` regex.
2. `_map_elmhurst_sap_heating` falls through to
`mh.community_heating.heating_controls_sap` when the §14.0 main
block leaves `heating_controls_sap` empty.
Closures (heating-systems corpus 001431):
CH1 ΔSAP_c -1.0572 → +0.0000 EXACT
Δcost +£24.36 → -£0.00 EXACT
CH3 ΔSAP_c -1.0572 → +0.0000 EXACT
Δcost +£24.36 → -£0.00 EXACT
CH2/CH4 SAP-side flip ±0.42 → ±0.53 (CHP-split blend reacts to
the now-lower SH demand × CHP rate)
CH6 ΔSAP_c -8.4406 → -7.4942 (DLF=1.0 P960 quirk untouched)
Remaining CH1/CH3 ΔCO2 -23.60 / ΔPE -208.23 is the §13a (372)
"Electrical energy for heat distribution" line (118.38 kWh × electric
factors 0.1993 CO2 / 1.760 PE). Cascade doesn't currently meter this
electricity overhead separately from heat-network heat — next slice.
932 pass + 0 fail (+5 new mapper tests). No regressions on the other
36 corpus variants — the mapper change is gated on `mh.community_
heating is not None` and only fires when §14.0 leaves the control
field empty. Pyright net-zero on mapper.py + corpus test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482):
"Primary circuit loss for insulated pipework and cylinderstat
should be included (see Table 3)."
SAP 10.2 Table 2b note b (PDF p.159) verbatim:
"Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems)."
The Table 2b note b ×0.9 multiplier is restricted to "boiler / warm
air / heat pump systems" — community heating is omitted from that
verbatim list. Pre-slice the cascade applied the ×0.9 reduction
unconditionally when DHW was separately timed, AND omitted the Table
3 primary-loss path for heat-network mains entirely. Combined the
two gaps under-counted (62)m HW total demand by ~320 kWh/yr for
heating-systems corpus 001431 community heating 1 (8164 + 0 vs
448.74 + 273.90 spec losses).
Three changes:
1. New `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION = 1.0` constant.
`_primary_loss_override` selects this for heat-network mains
instead of the RdSAP §3 age-band default, per the spec's literal
"insulated pipework" + back-solve from worksheet (59) Jan = 23.26
= 31 × 14 × (0.0091×3 + 0.0263).
2. Extended `_primary_loss_applies` with a new branch: heat-network
main + WHC ∈ {901, 902, 914} + cylinder present → primary loss
applies.
3. New `_table_2b_note_b_multiplier_applies(epc, main)` predicate
that gates the ×0.9 storage-loss reduction on the spec's verbatim
system-type list, returning False for heat-network mains. The
primary-loss `_separately_timed_dhw` continues to return True for
community heating (Table 3's "separately timed" row is system-
type-agnostic and gives h=3 all year).
Closures (heating-systems corpus 001431):
CH1 HW kWh 3391.90 → 3854.12 (= ws 3854.1175, abs Δ < 1e-3)
CH1 HW cost £143.82 → £163.41 (= ws £163.41, EXACT)
CH1 (65)m heat gains 793.51 → 1221.62 (= ws 1221.62, EXACT)
CH2/CH3/CH4/CH6 same shape — HW path closes against ws (310).
§4 fix is spec-correct on all 5 CH variants. The closure surfaces a
separate §7 MIT (92)m over-count of +0.46 K (cascade Jan = 17.22 vs
ws 16.76) that the pre-slice (65)m gain under-count was masking. Per
[[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; new pinned residuals reflect the exposed MIT gap.
New residuals (vs pre-slice):
CH1 ΔSAP -0.5273 → -1.0572 ΔPE -9.15 → +408.67
CH2 ΔSAP -0.0076 → -0.4187 ΔPE +1506 → +1779
CH3 ΔSAP -0.5273 → -1.0572 ΔPE -387.03 → -239.03
CH4 ΔSAP -0.0076 → -0.4187 ΔPE +494.61 → +767.13
CH6 ΔSAP -8.0295 → -8.4406 ΔPE +7864.60 → +8137.11
927 pass + 0 fail (+1 new test). No regressions on the other 36
corpus variants — the gate is narrow on `_is_heat_network_main`.
Pyright net-zero (43 → 43) on cert_to_inputs.py + tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes CH1 (boilers) + CH3 (HP) HW CO2 / PE residuals by routing
the HW cost / CO2 / PE factor lookups through the heat-network main
when WHC ∈ {901, 902, 914} ("HW from main heating system"). Pre-
slice the cascade honoured Elmhurst Summary §15.0's
`water_heating_fuel_type = "Mains gas"` placeholder on community-
heated certs, mis-routing HW through Table 12 code 1 (mains gas,
3.48 p/kWh / 0.21 CO2 / 1.13 PE) instead of the heat-network code
(4.24 p/kWh + Table 12 code 41 / 51 / 53 / 54 with Table 4a heat-
source-eff scaling per S0380.172).
Per SAP 10.2 §C1 + RdSAP 10 §C (PDF p.49 + p.58) the HW heat
delivered by a heat-network main is supplied through the same
network as SH: spec block 10b (342a)/(342b) computes HW cost as
`(310a) × CHP_price + (310b) × boiler_price`, mirroring SH's
(340a)/(340b) split. Block 12b (365)/(366) and 13a (465)/(466)
likewise apply the heat-source-eff division on HW.
Three layers wired:
1. New `_is_community_heating_hw_from_main(epc)` predicate. Gates
on WHC ∈ {901, 902, 914} + heat-network main + SAP code in
`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` table (S0380.172 — only
301 boilers + 304 HP). SAP 302 (CHP+boilers) is excluded
because the 35%/65% split needs the displaced-electricity
credit cascade per spec block 13b (464)/(466) on BOTH SH and HW
paths — both converge in a single follow-up slice.
2. `_hot_water_fuel_cost_gbp_per_kwh` gains a keyword-only
`inherit_main_for_community_heating: bool = False` parameter.
When True, returns `_fuel_cost_gbp_per_kwh(main, prices)` —
same helper that already applies the S0380.171 CHP blend +
heat-network rate. The orchestrator passes
`inherit_main_for_community_heating=_is_community_heating_hw_
from_main(epc)` at the cost-rate construction site.
3. `_hot_water_co2_factor_kg_per_kwh` and `_hot_water_primary_
factor` get top-level branches: when the predicate fires, return
`Table_12_factor × _heat_network_heat_source_efficiency_scaling
(main)` — same scaled-factor return as the SH path in S0380.172.
Closures (heating-systems corpus block 11b):
CH1 (Boilers/Gas) ΔPE −967 → −9 (essentially closed)
CH1 ΔCO2 −126 → +52 (shifted across worksheet)
CH3 (HP/Elec) ΔPE +1749 → −387 (~78% closure)
CH3 ΔCO2 +473 → −86 (~82% closure)
Cost / SAP signs flip on CH1 / CH3 (was −£14 / +0.59 SAP, now
+£12 / −0.53 SAP) — HW cost now matches the worksheet's (342) line
exactly, exposing a +£12 lighting / standing overage that was
previously masked by the HW under-charge. Per [[feedback-software-
no-special-handling]] the pre-slice near-zero on CH1 / CH3 cost was
an offsetting-bugs artifact; the spec-correct fix surfaces the real
lighting / standing gap as the next forcing function.
CH2 / CH4 / CH6 (SAP 302) unchanged from S0380.171 / S0380.172 pins
— gated out per the heat-source-eff-table membership check.
Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at
predecessor 36d4bf87). Pyright net-zero on affected files
(cert_to_inputs.py, test_heating_systems_corpus.py): 32 → 32.
Per [[feedback-spec-citation-in-commits]] the rule cites SAP 10.2
§C1 verbatim ("heat from CHP + back-up boilers, via a heat main")
and RdSAP 10 §C defaults (PDF p.58).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the CO2 / PE residuals for CH1 (boiler community heating, SAP
code 301) and CH3 (HP community heating, SAP code 304) via SAP 10.2
Table 4a (PDF p.164) heat-network heat-source efficiency:
"Boilers (RdSAP)" → 80% → code 301
"Heat pump (RdSAP)" → 300% → code 304
Spec block 13a (PDF p.153) (467) "PE associated with heat source 2"
= [(307b)+(310b)] × 100 / (467b) — i.e. fuel input = network_input ×
100 / heat_source_eff before applying Table 12 PE factor. Block 12b
(367) mirrors for CO2. The cascade meters network_input directly
(eff = 1/DLF for the cost path via Table 12 heat-network rate), so
PE / CO2 factors are scaled by 1/heat_source_eff at lookup time —
mathematically equivalent to spec's (network_input / eff) × factor.
Three changes:
1. New `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]]`
keyed on SAP code: 301 → 0.80, 304 → 3.00. SAP 302 (CHP+boilers)
is omitted — the 35%/65% split + displaced-electricity credit per
spec block 13b (464)/(466)/(364)/(366) needs the .171 follow-up.
2. New `_heat_network_heat_source_efficiency_scaling(main)` helper
returning 1.0 for non-heat-network mains + SAP 302, and
1/heat_source_eff for SAP 301 / 304.
3. Wired into `_main_heating_co2_factor_kg_per_kwh` and
`_main_heating_primary_factor` non-electric branches (heat
networks are non-electric per `_is_electric_main`). Both functions
return `Table_12_factor × scaling` so the cascade's
`network_input × scaled_factor` lands on the spec
`(network_input / eff) × Table_12_factor`.
Closures vs pre-S0380.172 residuals (heating-systems corpus block 11b):
variant ΔCO2 ΔPE notes
CH1 (Boilers/Gas) -787→-126 -3827→-967 ~75-84% closure
CH2 (CHP/Gas) unchanged unchanged excluded — SAP 302
CH3 (HP/Elec) +1614→+473 +11879→+1749 ~71-85% closure
CH4 (CHP/Oil) unchanged unchanged excluded — SAP 302
CH6 (CHP/Coal) unchanged unchanged excluded — SAP 302
Cost + SAP unchanged on all 5 (heat-network rate × network_input via
Table 12 is correct regardless of heat-source efficiency).
Residual CH1 / CH3 gap drivers (follow-up scope):
- WHC=901 HW path: cascade reads cert-lodged "Mains gas" as HW fuel
on community-heating certs; should fall through to main fuel for
the heat-network so the scaling applies on HW side too.
- Elmhurst 0.8523 multiplier on heat-network energy column (worksheet
(467) energy = spec_formula × 0.8523 uniformly across non-CHP
heat-network rows; mechanism not yet identified — spec divergence
candidate for SAP_CALCULATOR.md §8).
Cohort no-regression verified: 9 ASHP + 38 cohort-2 golden fixtures
pass unchanged; the 41-variant heating-systems corpus has identical
residuals for non-heat-network certs. The 2 closed CH variants are
re-pinned at their new sub-1000 magnitudes.
Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at
predecessor a4b5f4e7; pin updates net to 0). Pyright net-zero on
affected files (cert_to_inputs.py, test_heating_systems_corpus.py):
32 → 32.
Per [[feedback-spec-citation-in-commits]] the dispatch table cites
SAP 10.2 Table 4a (PDF p.164) verbatim row labels.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the +£104 cost / +4.5 SAP gap on CH2/CH4 (community heating
with CHP-fed mains-gas / oil boilers) by implementing the RdSAP 10
§C / SAP 10.2 Appendix C (PDF p.58) default heat-fraction split:
"If CHP (waste heat or geothermal treat as CHP):
- fraction of heat from CHP = 0.35
- CHP overall efficiency 75%
- heat to power ratio = 2.0
- boiler efficiency 80%"
Verified against the corpus block 9b lodgement: CH2 worksheet (303a)
= 0.3500 + (303b) = 0.6500 + (305) = 1.00 + (306) DLF = 1.45. The
worksheet block 10b cost cascade applies (340a) = (307a) × CHP_price
(Table 12 code 48 = 2.97 p/kWh) + (340b) = (307b) × boiler_price
(Table 12 codes 51-58 = 4.24 p/kWh) with (307a) = 0.35 × (307),
(307b) = 0.65 × (307).
Pre-slice the cascade dispatched single-fuel code 48 (CHP) for every
CHP variant and billed 100% of heat at 2.97 p/kWh, under-charging by
~£104/yr versus the worksheet's 35% × 2.97 + 65% × 4.24 = 3.7945
p/kWh blended rate.
Three layers wired:
1. Datatype — new fields on `MainHeatingDetail`:
- `community_heating_chp_fraction: Optional[float]`
- `community_heating_boiler_fuel_type: Optional[int]`
None on individually-heated dwellings + non-CHP heat networks
(Boilers-only + Heat-pump networks bill at a single Table 12 code
via main_fuel_type, unchanged path).
2. Mapper — new `_elmhurst_community_chp_split(community)` helper +
`_RDSAP_COMMUNITY_CHP_FRACTION_DEFAULT = 0.35` constant. When the
§14.1 Community Heat Source is "Combined Heat and Power": returns
(0.35, boiler_fuel_code) where boiler_fuel_code is resolved from
the §14.1 Community Fuel Type via the existing
`_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12` dispatch (gas → 51,
oil → 53, coal → 54).
3. Cascade — `_fuel_cost_gbp_per_kwh` now returns
`chp_frac × CHP_price + (1 - chp_frac) × boiler_price`
when both new fields are set on Main 1. Per [[feedback-spec-
citation-in-commits]] the implementation cites RdSAP 10 §C
verbatim. Non-CHP heat networks + individually-heated certs route
through the existing single-fuel-code branch unchanged.
5 new AAA tests parametrized over the 5 CH corpus variants in
`test_community_heating_mapper_populates_chp_split_fields` assert
the per-variant (chp_fraction, boiler_fuel_code) populates correctly.
Closures vs pre-S0380.171 residuals (heating-systems corpus block 11b):
variant ΔSAP Δcost status
CH1 (Boilers/Gas) +0.5915 -£13.63 unchanged (no CHP split)
CH2 (CHP/Gas) +4.50→-0.0076 -£104→+£0.17 ✓ CLOSED
CH3 (HP/Elec) +0.5915 -£13.63 unchanged (no CHP split)
CH4 (CHP/Oil) +4.50→-0.0076 -£104→+£0.17 ✓ CLOSED
CH6 (CHP/Coal) -3.52→-8.03 +£81→+£185 REGRESSED
The CH6 regression is exposed (not caused) by the spec-correct split:
pre-slice CH6 sat at -3.52 SAP / +£81 by coincidence — the cascade's
CHP-only pricing (2.97 p/kWh) cancelled with cascade DLF=1.45
(Table 12c age G default) against the CH6 worksheet's lodged DLF=1.0.
Per [[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; the pre-fix near-zero was an offsetting-bugs artifact,
not a deliberate non-spec rule.
The CH6 worksheet (306) DLF=1.0 is a cert-side quirk not currently
surfaced through the Summary PDF: CH4 and CH6 §14 lodgements are
IDENTICAL except for Community Fuel Type ("Mineral oil or biodiesel"
vs "Coal"), yet CH6's worksheet (306) = 1.0000 while CH4's = 1.4500.
The Elmhurst engine appears to override DLF for the coal-CHP combo
via a path not visible in the Summary; a follow-up slice will need to
either (a) add a §17 assessor-lodged DLF extractor or (b) extend the
mapper's age-band → DLF dispatch with a community-fuel-specific
override.
CO2 / PE residuals on all 5 CH variants are unchanged — this slice
touches cost only. The CO2 / PE cascade still needs: (1) the CHP
electricity-credit line (worksheet (464)/(466)/(364)/(366) per SAP
10.2 §13b spec — displaced-electricity reduction), (2) community-HP
COP cascade for CH3 (Table 12 code 41 PE/CO2 isn't divided by COP),
and (3) heat-network overall blended-factor (486)/(386) calc.
Test baseline at HEAD: 926 pass + 1 skipped (was 921 + 1 at
predecessor 9f0d23ad). Pyright net-zero on affected files
(epc_property_data.py, mapper.py, cert_to_inputs.py,
test_heating_systems_corpus.py + elmhurst_site_notes.py): 65 → 65.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the 5 community-heating variants in the heating-systems corpus
(community heating 1/2/3/4/6 on property 001431). Pre-slice the
mapper returned `MainHeatingDetail.main_fuel_type=''` for every
community-heating cert because §14.0 lodges no Fuel Type — only EES
'COM' + a Table 4a heat-network SAP code (301/302/304). The cascade
strict-raised `MissingMainFuelType` per S0380.132. The actual fuel
that bills the cascade lives in the §14.1 Community Heating/Heat
Network block, which the extractor was skipping entirely.
SAP 10.2 Table 12 (PDF p.189) defines the heat-network fuel codes:
Boilers + Mains Gas → 51 (heat from boilers — mains gas)
Boilers + Mineral oil → 53 (heat from boilers — oil)
Boilers + Coal → 54 (heat from boilers — coal)
Boilers + Biomass → 43 (heat from boilers — biomass)
Combined Heat and Power → 48 (heat from CHP; fuel-agnostic)
Heat pump + Electricity → 41 (heat from electric heat pump)
Per spec text the upstream fuel determines the boiler-side code; CHP
is fuel-agnostic at the Table 12 cost / CO2 / PE level.
Three layers wired:
1. Survey schema — new `CommunityHeating` dataclass alongside
`MainHeating2` carrying the §14.1 fields (heating_type,
community_heat_source, community_fuel_type, heating_controls_ees,
heating_controls_sap, chp_fuel_factor). Mutually exclusive with
`main_heating_2` at the §14.1 level. Attached as
`MainHeating.community_heating: Optional[CommunityHeating] = None`.
2. Extractor — new `_extract_community_heating()` method bracketed by
"14.1 Community Heating/Heat Network" / "14.2 Meters". Returns
None on individually-heated dwellings (no Community Heat Source
lodged). Wired into `_extract_main_heating()`.
3. Mapper — new `_resolve_community_heating_fuel_code(heat_source,
fuel)` dispatch helper + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`
constant for the boiler upstream-fuel split. Wired in
`_map_elmhurst_sap_heating` after the EES-code-to-fuel dispatch
and before the strict-raise on absent SAP code.
Per the standard slice workflow + [[feedback-aaa-test-convention]]:
- 5 new AAA tests in `test_community_heating_mapper_resolves_table_12_
fuel_code` parametrized over the 5 corpus variants, asserting the
mapper resolves the expected Table 12 code per variant.
- The existing parametrized residual-pin test in
`test_heating_systems_corpus_residual_matches_pin` picks up the
5 community-heating variants with cascade-side residuals pinned as
forcing functions for follow-up slices:
variant dSAP dcost dCO2 dPE
CH1 (Boilers/Gas) +0.59 -£14 -787 -3827
CH2 (CHP/Gas) +4.50 -£104 -1430 +1506
CH3 (HP/Elec) +0.59 -£14 +1614 +11879
CH4 (CHP/Oil) +4.50 -£104 -4397 +495
CH6 (CHP/Coal) -3.52 +£81 -2935 +7865
These reflect open cascade-side work (SAP 10.2 Appendix C CHP/
boiler heat-fraction split missing — cascade treats CHP+Boilers as
100% CHP; community-HP COP cascade missing — cascade doesn't divide
delivered heat by COP for Table 12 code 41; heat-network overall
CO2/PE blended-factor cascade missing — cascade doesn't compute
worksheet rows (386)/(486)). Pinned per [[feedback-zero-error-strict]];
follow-up slices close gaps and re-pin smaller residuals.
- `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` tuple now empty; the
blocked-tier test pytest-skipped via `pytest.mark.skipif` with a
reason naming this slice.
Test baseline at HEAD: 921 pass + 1 skipped (was 916 + 0 at
predecessor 7e08e7af). Pyright net-zero on affected files
(elmhurst_site_notes.py, elmhurst_extractor.py, mapper.py,
test_heating_systems_corpus.py): 32 → 32.
Per [[feedback-spec-citation-in-commits]] the dispatch is grounded
in SAP 10.2 Table 12 (PDF p.189). Per
[[feedback-bigger-slices-for-uniform-work]] all 5 variants land in
one slice — the work is uniform (single Elmhurst label dict + single
dispatch helper) and the per-variant residuals surface together
because of cascade-side gaps, not mapper-side variation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `"NON": 30` to `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` so the
mapper can derive the main heating fuel for the Elmhurst "no main
heating system" lodging (§14.0 Main Heating EES = NON + SAP code
699 + §14.1 Heating Type = None).
SAP 10.2 §A.2.2: "When no main heating system is identified, the
calculation is for the assumed system consisting of portable electric
heaters." Routes the fuel to Table 32 standard-electricity code 30
(tariff resolved separately from `meter_type` per `_rdsap_tariff`).
Pre-slice the cascade raised `MissingMainFuelType` per S0380.132.
Post-slice the cascade closes most of the way:
no system: ΔSAP_c +1.18, Δcost −£27, ΔCO2 −50, ΔPE −562
The residuals are cascade-side (likely §A.2.2 portable-electric
efficiency / responsiveness / control-type defaults differ slightly
from Elmhurst) — pinned at observed values as forcing function for
follow-up.
Moves `no system` out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into
`_EXPECTATIONS`. Blocked tier now: 5 community-heating variants.
Tests:
- test_elmhurst_main_heating_ees_maps_no_system_code_to_electricity
- corpus pin: no system expected residuals at observed values
916 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mapper extensions (`_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`):
"BFD": 71, # HVO — corpus variant oil 2 (SAP 127)
"BXE": 73, # FAME — corpus variant oil 3 (SAP 128)
"BXF": 73, # FAME alt — corpus variant oil 4 (SAP 129)
"BZC": 76, # Bioethanol — corpus variant oil 5 (SAP 126)
"B3C": 75, # B30K — corpus variant oil 6 (SAP 126)
`_ELMHURST_MAIN_FUEL_TO_SAP10` water-side labels:
"Bio-liquid HVO from used cooking oil": 71,
"Bio-liquid FAME from animal/vegetable oils": 73,
"Bioethanol": 76,
"B30K": 75,
Values are direct Table 32 codes (the bio-liquid codes 71/73/75/76
don't collide with any API enum value so they pass through
`unit_price_p_per_kwh` etc. unchanged). Spec: SAP 10.2 Table 12
(PDF p.189) notes (d)/(e)/(f).
Pre-slice all 5 oil 2-6 variants raised `MissingMainFuelType` per
S0380.132. Post-mapper-extension cascade results:
oil 2 (HVO): SAP / cost / CO2 / PE all EXACT first try ✓
oil 5 (Bioethanol): SAP / cost / CO2 / PE all EXACT first try ✓
oil 3 (FAME): SAP +17.34, cost −£398
oil 4 (FAME alt): SAP +16.06, cost −£367
oil 6 (B30K): SAP +3.05, cost −£70
Slice S0380.131 had left a deferred TODO in `table_32.py` for FAME
code 73 ("worksheet 7.64 vs spec 5.44 — flipping has no measurable
cascade effect today, deferred until a cert that exercises it
surfaces"). Now exercised — flipping `73: 5.44 → 7.64` closes 85 %
of the oil 3/4 cost gap:
oil 3 (FAME): SAP +17.34 → +2.59, cost −£398 → −£62
oil 4 (FAME alt): SAP +16.06 → +2.56, cost −£367 → −£57
The Elmhurst-engine canonical 7.64 ↔ spec PDF 5.44 divergence is the
same pattern S0380.131 applied to heating oil (code 4: 7.64 → 5.44)
per [[feedback-software-no-special-handling]].
Remaining residuals on oil 3 / oil 4 / oil 6 are cascade-side
(HW kWh under by ~250-900, SH demand small diff, CO2/PE blend
artifacts) — pinned at observed values as forcing functions for
follow-up slices. Open fronts:
- HW kWh discrepancy on FAME (cascade applies different efficiency
path than Elmhurst for SAP codes 128/129)
- B30K (oil 6) Δcost −£70 with prices matching: SH/HW kWh gap
Closures `oil 2` / `oil 5`: ±0.0000 on all 4 metrics. Moves all 5
oil variants out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into
`_EXPECTATIONS`.
Blocked tier now: 6 variants (community heating × 5, no system).
Cascade-OK tier: 32 variants (up from 30), 30 EXACT + 3 (oil 3/4/6)
pinned with non-zero residuals + 1 (pcdb 1 SH residual closed in
S0380.165).
Tests:
- test_elmhurst_main_heating_ees_maps_bio_liquid_codes_to_table_32_fuel_codes
- test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels
- corpus pins: oil 2/3/4/5/6 expected residuals
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds three Elmhurst EES (Energy Efficiency Standard) codes to
`_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` so the mapper can derive the
main heating fuel for electric storage / direct-acting certs whose
Elmhurst Summary §14.0 does not lodge a "Main Heating Fuel Type"
string (same pattern as the solid-fuel block above):
"WEA": 30, # electric warm-air storage
"REA": 30, # resistive electric (corpus electric 12 SAP 691)
"OEA": 30, # other electric (corpus electric 13/14 SAP 701)
All route to Table 32 standard-electricity code 30; the cascade
resolves the actual price tier (high vs low rate) downstream via
`_rdsap_tariff(epc)` keyed off `meter_type`.
The corpus carries 4 electric-storage variants on the 18-hour tariff:
electric 11 — WEA + SAP 515 (warm-air electric)
electric 12 — REA + SAP 691
electric 13 — OEA + SAP 701
electric 14 — OEA + SAP 701 (differs from 13 by emitter / controls)
Pre-slice all 4 raised `MissingMainFuelType` per S0380.132. Post-slice
all 4 EXACT on first try across all 4 metrics:
electric 11: ΔSAP_c +0.0000 Δcost +£0.0000 ΔCO2 −0.0000 ΔPE −0.0000
electric 12: ΔSAP_c +0.0000 Δcost +£0.0000 ΔCO2 −0.0000 ΔPE −0.0000
electric 13: ΔSAP_c +0.0000 Δcost −£0.0000 ΔCO2 +0.0000 ΔPE −0.0000
electric 14: ΔSAP_c +0.0000 Δcost −£0.0000 ΔCO2 +0.0000 ΔPE −0.0000
Closure on first try because the cascade was already wired for the
electric-storage path (SAP 10.2 Table 4a codes 515 / 691 / 701, Table
4e Group 4 storage controls, Table 5a pump-gain wet-gate from S0380.160,
S0380.144 secondary-fraction by sub-row); only the Elmhurst EES → fuel
mapping was missing.
Moves electric 11/12/13/14 out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE`
into `_EXPECTATIONS` at ±0.0000. Blocked tier now: 11 variants
(community heating × 5, no system, oil 2-6).
Tests:
- test_elmhurst_main_heating_ees_maps_electric_storage_codes_to_electricity
- corpus pins: electric 11/12/13/14 expected residuals = ±0.0000
Cascade-OK tier: 30 variants (up from 25), all SAP / cost / CO2 / PE
EXACT (< 1e-4) vs Elmhurst worksheet on every metric.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the single missing dict entry that lets cert `pcdb 3` cascade:
`_ELMHURST_MAIN_FUEL_TO_SAP10["Bulk LPG"] = 27`
API code 27 = "LPG (not community)" — routes via:
- `API_FUEL_TO_TABLE_12[27] = 2` (SAP 10.2 Table 12 bulk LPG: £62
standing, 6.74 p/kWh, 0.241 CO2, 1.141 PE; spec PDF p.189)
- `API_FUEL_TO_TABLE_32[27] = 2` (RdSAP 10 Table 32 bulk LPG: £70
standing, 7.60 p/kWh; spec PDF p.95)
Pre-slice the mapper produced `main_fuel_type=''` for any Elmhurst
fixture lodging "Bulk LPG" as fuel type, so the cascade strict-raised
`MissingMainFuelType` per S0380.132. The legacy `"LPG bulk"` label
(different word order) maps to API code 6 = wood logs — a pre-existing
oddity unexercised by any live fixture; left untouched per
[[feedback-bigger-slices-for-uniform-work]] (different label, different
fix).
Cascade closure `pcdb 3` (Vokera Linea LPG combi 83.10 %, PCDB index
8262, no cylinder, 18-hour tariff) — EXACT on first try across all 4
metrics:
cascade SAP_c = 49.2953 worksheet = 49.2953 Δ = +0.0000
cascade cost = £1165.81 worksheet = £1165.81 Δ = +0.0000
cascade CO2 = 3367.95 worksheet = 3367.95 Δ = +0.0000
cascade PE = 13936.60 worksheet = 13936.60 Δ = +0.0000
Closure on first try because the cascade was already fully wired for
the gas/oil/LPG path; the Elmhurst label was the only gap. Moves
pcdb 3 out of `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` into `_EXPECTATIONS`
at ±0.0000.
Blocked tier now: 15 variants (community heating × 5, electric storage
11-14, no system, oil 2-6).
Tests:
- test_elmhurst_main_fuel_to_sap10_maps_bulk_lpg_to_api_code_27
- corpus pin: pcdb 3 expected residuals = ±0.0000 on all 4 metrics
912 pass / 0 fail; pyright net-zero 43 → 43.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §9.4.11 (PDF p.30): "The efficiency of gas and liquid fuel
boilers for both space and water heating is reduced by 5% if the
boiler is not interlocked for space and water heating."
S0380.141 had subtracted the -5pp from BOTH `Pwinter` and `Psummer`
PCDB / Table 4b seasonal efficiencies BEFORE running the SAP 10.2
Appendix D §D2.1 (2) Equation D1 monthly cascade. The Elmhurst P960
worksheet for `pcdb 1` (PCDB 716 oil boiler, Pwinter 65 / Psummer 53,
Cylinder Stat=No → no interlock) shows the -5pp is applied to the
η_water,monthly OUTPUT of Eq D1, NOT to its inputs. The two
interpretations diverge because Eq D1's reciprocal weighting (1/η_w
and 1/η_s) is non-linear in η.
Worked example for pcdb 1 Jan (Q_space=1409.77, Q_water=387.86):
Old cascade: Eq D1(60, 48, …) = 56.9292 % (off −0.04 pp)
Worksheet: Eq D1(65, 53, …) = 61.9725 %
−5pp = 56.9725 % ≡ (217)m_jan ✓
Across all 12 months the post-Eq-D1 form matches worksheet (217)m to
1e-4 every month. Cascade HW kWh: 7068.41 → 7063.96 (= worksheet (219)
total exactly), Δ −4.45 kWh.
The spec text "reduced by 5%" does not explicitly state pre- vs post-
Eq D1 ordering. Per [[feedback-software-no-special-handling]] mirror
the Elmhurst engine — the worksheet output is unambiguous.
Changes:
- `_apply_water_efficiency` gains a `interlock_penalty_pp: float = 0.0`
kwarg. Eq D1 branch runs on raw (Pwinter, Psummer), then subtracts
`interlock_penalty_pp / 100` from each monthly efficiency before
dividing.
- Caller (`cert_to_inputs` orchestrator) now passes the raw seasonal
efficiencies in `eq_d1_winter_summer_pct` + the penalty separately.
The pre-Eq-D1 `eq_d1_winter_summer_pct[0] -= 5` block is removed.
- SH-side `eff -= 0.05` (line 5349) is unchanged — the SH cascade
doesn't go through Eq D1, just `(98c)m / eff_sh`.
Closures `pcdb 1`:
ΔSAP_c −0.0108 → +0.0000 (1e-4)
Δcost +£0.24 → +£0.0000
ΔCO2 +1.33 → +0.0000
ΔPE +5.70 → −0.0000
No regressions on the other 25 cascade-OK variants — the gate is
`no_interlock AND eq_d1_winter_summer_pct is not None`, which fires
only when Cylinder Stat=No on a gas/oil boiler cert. The 6 Elmhurst
U985 cohort + cohort-2 Elmhurst fixtures all lodge Cylinder Stat=Yes
(interlock present) → no penalty fires; cohort-1 ASHP certs lodge no
cylinder thermostat at all but route through Appendix N3 instead of
Eq D1. 38 cohort-2 + 9 ASHP golden fixtures all PASS unchanged.
The 41-variant heating-systems corpus cascade-OK tier is now CLOSED:
all 25 variants SAP / cost / CO2 / PE EXACT vs Elmhurst worksheet at
abs < 1e-3 (most < 1e-4). Σ|ΔSAP_c| = 0.0001 (= floating-point noise).
Tests:
- test_apply_water_efficiency_applies_interlock_penalty_after_equation_d1
- test_apply_water_efficiency_interlock_penalty_zero_keeps_raw_eq_d1
911 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): "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."
The spec-literal CO2 / PE formula multiplies summer immersion fuel by
the Table 12d / 12e monthly cascade (per Table 12 footnotes (s) and
(t): "monthly factors in Table 12d/12e should be used in the SAP
worksheet"). The BRE-approved Elmhurst engine adds an extra
`summer_fuel × Table 12 annual electric` term ON TOP of the monthly
cascade for dual-rate tariffs — same Elmhurst-mirror shape as S0380.163
(§8.1) but additive rather than substitutive. Cost is computed
cleanly per spec — the double-count quirk only affects the (264) HW
CO2 and (278) HW PE factor lines.
Worksheet evidence (heating-systems corpus property 001431,
`solid fuel 2` — Table 4a code 158 closed-room-heater + back boiler,
65 % winter η + 100 % summer η, anthracite, 18-hour off-peak tariff):
(62)m heat 303.12 .. 168.95 .. 175.91 .. 300.40 kWh
winter fuel (W) = 2205.80 / 0.65 = 3393.51 kWh anthracite
summer fuel (S) = 684.55 / 1.00 = 684.55 kWh immersion
total fuel = (219) = 4078.06 kWh
(264) HW CO2 = 4078.06 × 0.3710 = 1513.15 kg/yr
= W × 0.395 + S × (0.116 monthly_summer + 0.136 annual)
= 1340.43 + 79.61 + 93.10 = 1513.14 ✓ within rounding
(278) HW PE = 4078.06 × 1.3771 = 5616.04 kWh/yr
= W × 1.064 + S × (1.429 monthly_summer + 1.501 annual)
= 3610.69 + 977.84 + 1027.51 = 5616.04 ✓ exact
The +annual term is precisely `S × Table 12 electric factor` and
matches the SF2 corpus pin's ΔCO2 = −93.10 and ΔPE = −1027.51 exactly.
Per [[feedback-software-no-special-handling]] mirror the engine.
Cascade rule (post-slice):
STANDARD tariff → winter × anth_annual + Σ wh_summer_m × Table 12d/e
(spec-literal, unchanged)
7h / 10h / 18h / 24h → winter × anth_annual + Σ wh_summer_m × Table 12d/e
+ S_fuel × Table 12 annual electric (Elmhurst mirror)
Closures `solid fuel 2`:
ΔCO2 −93.10 → +0.0000 EXACT
ΔPE −1027.51 → +0.0000 EXACT
ΔSAP and Δcost remain EXACT (cascade cost path was already correct).
The 41-variant heating-systems corpus is now closed on its 25-variant
cascade-OK tier: all 25 SAP / cost / CO2 / PE EXACT (|Δ| < 1e-3) vs
the Elmhurst worksheet. Only `pcdb 1` carries a sub-tolerance gap
(−0.011 SAP / +5.7 PE — PCDB Eq D1 cascade gap on PCDF index 716, a
separate small slice).
⚠ Single-cert evidence
SF2 is the only §12.4.4 fixture in the corpus (`solid fuel 1` =
code 156 is an empty folder; no other variant exercises a back-boiler
combo with summer immersion). Per the handover ≥2-cert rule for new
§8 divergence rows, this slice was admitted under an explicit
exception: the divergence shares its shape with §8.1 (S0380.163's
Table 12 annual mirror for dual-rate HW), and the math matches the
worksheet to within rounding. The new §8.2 row is tagged with a
"⚠ Single-cert evidence" subsection so future agents know to revisit
if a second §12.4.4 cert worksheet ever diverges from this rule.
Tests:
- test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count
- test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade
909 pass / 0 fail; pyright net-zero 43 → 43.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps the four slices closing the heating-systems corpus from
Σ|ΔSAP_c| 1.24 → 0 (25/25 cascade-OK variants SAP/cost/CO2/PE
EXACT, except solid fuel 2 summer-immersion-blend artifact).
Highest-leverage next slice: close solid fuel 2 (the only remaining
open variant in the cascade-OK tier) via the S0380.154 blend code
path — likely a parallel Elmhurst-mirror gate for the summer-
immersion CO2/PE factors.
Other open fronts: 16 blocked-tier mapper extensions; pcdb 1 sub-
tolerance -0.011 SAP; cohort-2 golden residuals tightening per
[[feedback-golden-residuals-near-zero]].
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 12 footnote (t) (PDF p.189): "PE factors for grid
electricity vary by month. The average figure given in this table is
therefore not used directly. Instead the monthly factors given in
Table 12e should be used in the SAP worksheet." Footnote (s) says the
same for CO2 / Table 12d. Read literally, monthly factors apply to
every electric end-use including dual-rate HW.
The BRE-approved Elmhurst rdSAP engine doesn't follow that reading
for HW. The 41-variant heating-systems corpus controlled-variable
fixture lodges worksheet (278) "Water heating (low-rate cost)" with
factor **1.5010 PE / 0.136 CO2** (Table 12 annual flat) across every
dual-rate tariff cert, while applying the monthly Table 12e/12d
cascade to lighting (1.5338 winter-weighted) and secondary heating
(1.5715) on the same certs. It's an engine implementation choice,
not a documented spec exception.
Per [[feedback-software-no-special-handling]] the calculator
contract is bit-faithful replication of the engine, not literal
compliance with the spec text. This slice flips cascade
`_hot_water_primary_factor` + `_hot_water_co2_factor_kg_per_kwh` to
accept a `tariff: Tariff` parameter:
- STANDARD tariff → Table 12e/12d monthly cascade weighted
by HW demand seasonality (unchanged from
S0380.71 / .72, matches cohort-1 ASHP
standard-tariff worksheet)
- 7-hour / 10-hour /
18-hour / 24-hour → Table 12 annual flat (1.501 / 0.136)
matching the Elmhurst worksheet (278)
"Water heating (low-rate cost)" row
Per-line walk on electric 3 (18-hour tariff, electric immersion HW,
2384.116 kWh annual):
worksheet (278) factor = 1.5010
cascade pre-slice = 1.5214 delta = +0.0204
(1.5214 - 1.5010) × 2384.116 = +48.66 kWh/yr PE — EXACT match
the corpus residual pin.
Same shape for CO2: worksheet 0.1360, cascade pre-slice 0.1410,
delta +0.0050 × 2384.116 = +11.95 kg/yr.
Closures across the 18-variant deferred lighting-PE cohort
(electric 1/2/3/5/6/7/8/9 + solid fuel 4/5/6/7/8/9/10/11 + ashp +
gshp):
ΔCO2 +6.31 / +11.95 → ±0.0000 EXACT
ΔPE +25.51 / +48.66 → ±0.0000 EXACT
ΔSAP_c / Δcost unchanged at ±0.0000 EXACT (already closed
pre-slice by S0380.156..162).
All 25 cascade-OK variants in the heating-systems corpus now
SAP / cost / CO2 / PE EXACT vs worksheet on all 4 metrics, with
solid fuel 2 as the only remaining open residual (separate
S0380.154 summer-immersion-blend CO2/PE artifact — deferred).
Documented in
`domain/sap10_calculator/docs/SAP_CALCULATOR.md §8.1
"HW PE/CO2 factors on dual-rate tariffs use Table 12 annual"` —
the master doc now carries a new §8 "Elmhurst-mirrored spec
divergences" section for cases like this. Validation tally
refreshed from stale "930/930" to current "941/941".
No regressions on the 6 Elmhurst U985 fixtures (gas combi
STANDARD tariff — unaffected) or the cohort-1 ASHP certs
(STANDARD tariff — unaffected). The dual-rate gate fires only
on the 4 off-peak tariffs.
Verbatim spec quote retained for reference (SAP 10.2 Table 12
footnote (t), PDF p.189):
"PE factors for grid electricity vary by month. The average
figure given in this table is therefore not used directly.
Instead the monthly factors given in Table 12e should be used
in the SAP worksheet."
Tests: 907 pass (+1), 0 fail. Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Appendix N3.1 (PDF p.105) "Circulation pump and fan":
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). **The default heat gain from Table 5a is included
via worksheet (70).**"
This rule applies the Table 5a row "Central heating pump in heated
space" GAIN (3 / 10 / 7 W per pump-age bucket) to electric heat
pumps even though the pump ELECTRICITY is hidden in the COP and
excluded from (230c). The "Not applicable for electric heat pumps
from database" clause in Table 5a footnote a) scopes only to the
PCDB-Table-362 cascade case (Appendix N1.2.1: "For heat pumps held
in the PCDB ... a single water circulation pump serving the heat
emitters is sufficient" — pump kWh AND gain embedded in COP).
S0380.160 over-stripped the gain by zeroing pump_w for every HP
category-4 main, conflating the PCDB-Table-362 case with the Table-4a
default cascade. This slice refines the HP gate in
`_any_main_system_has_central_heating_pump`:
- Cat 4 HP WITH `main_heating_index_number` lodged (PCDB Table
362) → continue (skip; pump in COP per N1.2.1);
- Cat 4 HP with SAP code in `_TABLE_4A_WARM_AIR_SAP_CODES` (Cat 5
warm-air HPs distribute via ducted air, no water circulation
pump; warm-air fan handled separately by Table 5a "Warm air
heating system fans" row, S0380.161) → continue;
- Otherwise (Cat 4 HP, Table 4a default cascade, water-emitter)
→ apply Table 5a default per Appendix N3.1.
Per-line walk on ashp (SAP code 214 air-to-water HP, Cat 4, no PCDB,
"Post 2013" pump age):
worksheet (70)[Jan] = 3.0000 W
cascade pre-slice = 0.0000 W delta = -3.000 W
The -3 W winter gain shortfall over-stated cascade (84) Total gains
by -3 W in heating months → cascade SH demand +12.27 kWh/yr
(cascade 9302 vs worksheet 9290), pushing continuous SAP down 0.024
because the cost residual was driven by the +1.5 kWh × 12 month
shortfall flowing through the £0.0741 low-rate cost.
Closures:
ashp: ΔSAP -0.0240 → +0.0000 EXACT, Δcost +£0.55 → +£0.00 EXACT
gshp: ΔSAP -0.0178 → -0.0000 EXACT, Δcost +£0.41 → -£0.00 EXACT
ΔPE +36 → +25.51 (and ΔCO2 +7.33 → +6.31) — residuals narrow to the
Elmhurst-vs-spec HW PE annual-vs-monthly Table 12e/12d quirk only
(same pattern as the 16-variant lighting-PE deferred cohort,
scaled by HW kWh = 1138 vs 2384 → 25.51 vs 48.66). Cohort
Σ |ΔSAP_c| 0.07 → 0.03; all 25 cascade-OK variants now SAP+cost EXACT.
Cohort-1 (cert 0380 et al.) golden fixtures unaffected — those certs
lodge `main_heating_index_number` (PCDB Table 362) → HP gate skips
correctly → (70) = 0 preserved. Cert 000565 (HP main 1 + gas boiler
main 2) unaffected — wet-boiler branch fires for main 2.
Verbatim spec quote (SAP 10.2 Appendix N3.1, PDF p.105):
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). The default heat gain from Table 5a is
included via worksheet (70)."
Tests: 906 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans
a) c)" computes the gain as SFP × 0.04 × V (W). Footnote c) sets
the default SFP to 1.5 W/(l/s) when no PCDB warm-air-unit record
is lodged; footnote a) applies the heating-season-only mask
(zero in summer months). Footnote c) further omits the gain when
the dwelling has balanced whole-house mechanical ventilation
(MVHR / MV) — same omission as the Table 4f kWh-side footnote e).
Pre-slice the cascade's `internal_gains_from_cert` only wired the
central-heating-pump row of Table 5a; the warm-air-fan gain helper
(`warm_air_heating_fan_w`) existed but was unwired. The kWh-side
parallel (Table 4f, 136.35 kWh/yr) was wired in S0380.158 — this
slice closes the symmetry on the gain side.
Per-line walk on electric 2 (SAP code 524 = Cat 5 ASHP with
warm-air distribution, V = 227.25 m³, no balanced MV):
worksheet (70)[Jan] = 13.6350 W
cascade (70)[Jan] = 0.0000 W delta = -13.635 W
worksheet (98c)[Jan] = 1600.43 kWh
cascade (98c)[Jan] = 1608.12 kWh delta = +7.69 kWh
13.635 W = 1.5 × 0.04 × 227.25 exactly. The -13.6 W winter gain
shortfall propagates through the §7 utilisation cascade and over-
states cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
9426), under-charging cost by ~£2.50 with opposite sign to the
S0380.156-.158 closures.
Fix: new `_any_main_system_has_warm_air_distribution(epc)` +
`_has_balanced_mechanical_ventilation(epc)` predicates in
`internal_gains.py`, mirroring `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES`
+ `_BALANCED_MV_KIND_NAMES` (kept here as siblings so the worksheet
layer stays free of rdsap deps). Orchestrator wires
`warm_air_heating_fan_w(sfp=1.5, dwelling_volume_m3)` into the
heating-season term of `pumps_fans_monthly_w` when warm-air
distribution is present and balanced MV is not.
Closures electric 2:
ΔSAP_c -0.1087 → -0.0000 EXACT
Δcost +£2.50 → -£0.00 EXACT
ΔCO2 +16.54 → +11.95 (joins lighting-PE deferred cohort)
ΔPE +97.69 → +48.66 (joins lighting-PE deferred cohort)
Electric 2 joins the 15-variant lighting-PE deferred cohort
(electric 1 + electric 3/5/6/7/8/9 + solid fuel 5/6/7/8 + solid
fuel 4/9/10/11 + electric 2) where SAP/cost are EXACT but PE/CO2
carry an Elmhurst-vs-spec MONTHLY-factor offset (cohort uses
Table 12 annual factors on the off-peak HW immersion line; spec
mandates Table 12d/12e monthly per the header).
Verbatim spec quote (SAP 10.2 Table 5a row "Warm air heating
system fans a) c)", PDF p.177):
"Warm air heating system fans a) c) SFP × 0.04 × V"
Footnote c): "SFP is the specific fan power from the database
record for the warm air unit if applicable; otherwise
1.5 W/(l/s). These values of SFP include an in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the gains for the warm air
system should not be included."
Footnote a): "... Set to zero in summer months. ..."
Σ |ΔSAP_c| across 25-variant cohort: 0.18 → 0.07 (~60% reduction).
No regressions on the other 24 variants or any golden fixture —
gate keyed on Table 4a warm-air SAP code frozenset (only electric
2 in the corpus has a code in that set).
Tests: 905 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
space" only applies to mains with a water-loop circulation pump.
Footnote a) names two exclusions verbatim ("Does not apply if a
heating system used solely for domestic hot water. ... Not applicable
for electric heat pumps from database."), and the row's name carries
the implicit third: dry mains with no central heating pump (electric
storage heaters, electric direct-acting, solid-fuel room heaters
without back-boilers) — the row simply doesn't list them.
Pre-slice `internal_gains_from_cert` gated only on Note a) (HP
exclusion), applying `central_heating_pump_w(date_category=...)` to
every non-HP main. The default UNKNOWN-date branch added 7 W of pump
gain to (70)m for every dry-system fixture in the controlled-variable
corpus, even though the worksheet (70)m = 0 every month.
Per-line walk on electric 3 (SAP code 401 "Manual charge control"):
cascade (73)[Jan] = 640.21 W
worksheet (73)[Jan] = 633.21 W delta = +7.00 W
cascade (70)[Jan] = 7.00 W
worksheet (70)[Jan] = 0.00 W Table 5a inapplicable
The +7 W winter-month gain lowered cascade SH demand by ~38 kWh/yr
(cascade 11050 vs worksheet 11088). At Table 32 18-hour low-rate
~7.4 p/kWh that's £2.50/yr under-charging — matching the cluster's
uniform Δcost = -£1.96..-£2.80 pattern. Continuous SAP rose ~+0.10
because cost dominates the ECF.
Fix: new `_any_main_system_has_central_heating_pump(epc)` predicate
in `internal_gains.py`, mirroring `cert_to_inputs._is_wet_boiler_main`
(S0380.149 — Table 4f kWh side). Wet if any non-HP main lodges:
- sap_main_heating_code in {101-141, 151-161, 191-196} (gas/oil/
solid-fuel/electric boilers per Table 4a/4b),
- main_heating_index_number (PCDB Table 322 record),
- main_heating_category in {1, 2} (RdSAP central heating), OR
- heat_emitter_type in {1, 3} (radiators / fan-coil per Table 4d).
Dead `_all_main_systems_are_heat_pumps` helper removed (the new
predicate subsumes its role).
Cluster closures (10 variants):
electric 3: SAP +0.1215 → -0.0000, cost -£2.80 → -£0.00
electric 5: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 6: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 7: SAP +0.1017 → -0.0000, cost -£2.34 → -£0.00
electric 8: SAP +0.0941 → -0.0000, cost -£2.17 → -£0.00
electric 9: SAP +0.1199 → -0.0000, cost -£2.76 → -£0.00
solid fuel 4: SAP +0.0850 → -0.0000, cost -£1.96 → -£0.00
solid fuel 9: SAP +0.1072 → -0.0000, cost -£2.47 → -£0.00
solid fuel 10: SAP +0.1134 → +0.0000, cost -£2.61 → -£0.00
solid fuel 11: SAP +0.0912 → +0.0000, cost -£2.10 → +£0.00
Σ |ΔSAP_c| across 25-variant cohort: 1.24 → 0.18. All 10 cluster
variants now join the lighting-PE +48.66 / CO2 +11.95 deferred
cohort (Elmhurst-vs-spec monthly factor quirk, same shape as
electric 1 + solid fuel 5/6/7/8 from prior closures).
Verbatim spec quote (SAP 10.2 Table 5a row 1, PDF p.177):
"Central heating pump in heated space, 2013 or later 3 a)"
"Central heating pump in heated space, 2012 or earlier 10 a)"
"Central heating pump in heated space, unknown date 7 a)"
The row name ("Central heating pump") gates by construction: dry
systems have no central heating pump and the row's three sub-rows
don't apply.
No regressions on the other 31 variants or any golden fixture; the
6 Elmhurst U985 fixtures lodge PCDB index → the new predicate
returns True → pump_w unchanged.
Tests: 904 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the per-line walk discipline used to close electric 2 + 5
across four slices (.156 Table 3 WHC=903 primary-loss, .157 Table 2b
note b) WHC=903 ×0.9, .158 Table 4f warm-air heating fans, .159
Table 4a Cat 7 R tariff-aware dispatch). Σ |ΔSAP_c| across the
25-variant heating-systems corpus dropped from 2.87 → 1.21 (58%
reduction). All variants now sit under 0.3 SAP.
Next-slice candidate: the 9-variant cluster at ±0.09..0.12 SAP
(electric 3/5/6/7/8/9 + sf 4/9/10/11) — uniform pattern suggesting
a shared shave-the-residual fix. Worth a per-line walk on one
cluster variant before accepting the prior "Elmhurst-vs-spec quirk"
framing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters"
splits the responsiveness R between two sub-tables:
Off-peak tariff:
Slimline storage heaters ... R = 0.2 402
Convector storage heaters ... R = 0.2 403
Slimline + Celect-type control ... R = 0.4 405
Convector + Celect-type ctrl ... R = 0.4 406
24-hour heating tariff:
Slimline storage heaters ... R = 0.4 402
Convector storage heaters ... R = 0.4 403
Slimline + Celect-type control ... R = 0.6 405
Convector + Celect-type ctrl ... R = 0.6 406
Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff has electricity
at low rate for 18 hours per day with at most 6h of interruption /
2h max each — operationally equivalent to 24-hour for storage-heater
charging. The cascade therefore routes EIGHTEEN_HOUR + TWENTY_FOUR_
HOUR through the 24-hour Table 4a sub-row.
Pre-slice `_responsiveness` keyed on `sap_main_heating_code` only
and returned R=0.2 for code 402 regardless of tariff. The existing
docstring already flagged the gap:
402: 0.20, # Slimline storage heaters (24-hr tariff: 0.40)
... "promote to (sap_code, tariff) lookup when 24-hour fixture
surfaces; until then the off-peak default applies (under-shoots
R for the 24-hour case)."
Per-line walk on electric 5 (sap_main_heating_code=402 +
meter_type="18 Hour"): cascade T_living (87)[Jan] = 20.1213 vs
worksheet 19.6519, (92)[Jan] = 18.6996 vs worksheet 18.2063, (93)
[Jan] = 19.0996 vs worksheet 18.6063 (cascade +0.4933 K throughout
the cascade). Back-solve from worksheet T_living=19.6519 via the
Table 9b Tsc formula:
Tsc(R=0.4) = 0.6 × (21-2) + 0.4 × (4.3 + 0.9933 × 705.4/210.23)
= 11.4 + 0.4 × 7.6325 = 14.4528
ΔT = 21 - 14.4528 = 6.5472
u_sum = 0.5 × 6.5472 × (7² + 8²) / (24 × 11.43) = 1.3481
T_living = 21 - 1.3481 = 19.6519 EXACT match.
Adds:
- `_CONTINUOUS_CHARGING_TARIFFS: frozenset[Tariff]` = {EIGHTEEN_
HOUR, TWENTY_FOUR_HOUR} — the tariffs treated as "24-hour
heating" for Table 4a R selection.
- `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: dict[int, float]`
— the override table for codes 402/403/405/406 (404, 407, 409
keep the same R in both sub-tables).
- `tariff: Optional[Tariff]` parameter to `_responsiveness`, with
the override consulted before the off-peak default.
- Tariff threaded through both call sites of MIT cascade (rating
+ demand paths) via `tariff_from_meter_type`.
Closures electric 5:
ΔSAP −1.1759 → +0.1081 (91% reduction)
Δcost +£27.09 → −£2.49
ΔCO2 +62.72 → +7.30 kg
ΔPE +438.03 → +0.07 kWh (essentially EXACT)
Electric 5 now joins the same residual cluster as electric 3/6/7/8/
9 (+0.09..+0.12 SAP, −£2..−£3 cost, +£7 CO2) — the cluster that
the prior handovers suspected was a shared shave-the-residual gap.
No regressions on the other 24 cohort variants. Extended handover
suite: 903 pass / 0 fail (was 902 — +1 from the new AAA test).
Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.30 → 1.24 (~46%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) — verbatim:
Warm air heating system fans e) SFP × 0.4 × V
e) SFP is the specific fan power from the database record for the
warm air unit if applicable; otherwise 1.5 W/(l/s). These values
of SFP include the in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the electricity for warm
air circulation should not be included in addition to the
electricity for mechanical ventilation. However it is included
for a warm air system and MEV or PIV from outside.
V is the volume of the dwelling in m³.
Per Table 4a (PDF p.165-166), warm-air systems are:
- Category 5: heat pumps with warm-air distribution (codes 521,
523, 524 electric; 525, 526, 527 gas-fired)
- Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
fired; 512-514 liquid-fired; 515 Electricaire electric)
Pre-slice the cascade's `_table_4f_additive_components` docstring
explicitly listed "(230b) Warm-air heating fans + (230c) for warm-
air pump" as "Not yet wired" — every Cat 5 / Cat 9 warm-air corpus
variant resolved `pumps_fans_kwh_per_yr` to 0. For electric 2 (code
524 Cat 5 air-source warm-air HP, no MV, V = 227.25 m³), the P960
worksheet block 11a (249) lodges 136.35 kWh × 13.67 p/kWh = £18.64
where the cascade computed 0.
New `_TABLE_4A_WARM_AIR_SAP_CODES` frozenset (22 codes) + leaf helper
`_table_4f_warm_air_heating_fans_kwh(main, dwelling_volume_m3,
has_balanced_mv)` wired at the orchestrator pumps_fans summation
alongside the existing circulation-pump and gas-flue-fan helpers.
Footnote-e balanced-MV omission reads `epc.sap_ventilation.
mechanical_ventilation_kind` via the new
`_has_balanced_mechanical_ventilation` predicate (returns True for
MVHR / MV; False for MEV / PIV / NATURAL).
Per-line walk evidence: cascade `pumps_fans_kwh_per_yr` = 0.0000 vs
worksheet (249) = 136.3500 = 1.5 × 0.4 × 227.25 exactly. Default SFP
from footnote e matches; PCDB warm-air-unit SFP lookup deferred
until a fixture exercises it.
Closures electric 2:
pumps_fans_kwh_per_yr: 0 → 136.35 (EXACT match to worksheet)
ΔSAP +0.7002 → −0.1087 (residual swung past worksheet — the +0.70
pre-slice was an under-counted-fan offset; spec-correct fix lands
just past zero, exposing a small upstream SH cascade gap likely
in the Cat 5 warm-air HP Table 4a SH efficiency or Table 9c MIT
cascade for warm-air mains — follow-up slice)
Δcost −£16.14 → +£2.50
ΔCO2 −2.37 → +16.54 kg
ΔPE −108.58 → +97.69 kWh
No regressions on the other 24 cohort variants — the warm-air-code
gate fires only when `sap_main_heating_code` is in the new frozenset
and only electric 2 has a warm-air SAP code in the corpus. Extended
handover suite: 902 pass / 0 fail (was 901 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.87 → 2.30 (~20%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 2b note b) (PDF p.159) — verbatim:
Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems).
The parenthetical list restricts the rule to systems where the heat
generator (boiler / warm-air / HP) is the device heating the
cylinder. Electric immersion is NOT in that list because the
immersion isn't a heat-generator system feeding DHW — it sits inside
the cylinder. The ×0.9 multiplier reflects shorter cylinder-heating
periods when a boiler / HP / warm-air operates on a separate timer
for DHW vs SH; if the heat generator doesn't feed the cylinder at all
(because the immersion does), there's no such timing effect.
Pre-slice `_separately_timed_dhw` returned True for any Cat 4 HP
main BEFORE consulting WHC (line 3872 `if main.main_heating_category
== 4: return True`). For electric 2 (sap_main_heating_code=524 Cat 5
warm-air ASHP, main_heating_category=4 per Elmhurst mapper, WHC=903
electric immersion + cylinder + cylinder thermostat lodged), the
cat-4 branch fired before the existing `_is_electric_water` check
could route the cert to False. The cascade applied ×0.9 to the
Temperature Factor (53), pulling (55) from 1.2294 → 1.1064 → cascade
annual (56) = 403.87 vs worksheet (56) annual = 448.73.
Same WHC=903 principle as the prior slice S0380.156 (Table 3 zero-
loss list for electric immersion): when HW is independent of the
main heating, main-heating-specific DHW rules don't apply — even
when the main happens to be a HP / boiler / warm-air system.
Fix: new top-of-function `if epc.sap_heating.water_heating_code ==
_WHC_ELECTRIC_IMMERSION: return False` guard in
`_separately_timed_dhw`. Reuses the constant introduced in S0380.156.
Closures electric 2:
Cylinder (56) storage loss annual 403.87 → 448.73 (matches
worksheet 1.2294 × 365 = 448.73 EXACT within rounding)
HW kWh demand 2339.24 → 2384.12 (matches worksheet (62)/(64) =
2384.116 EXACT)
ΔSAP +0.8118 → +0.7002
Δcost −£18.71 → −£16.14
ΔCO2 −7.21 → −2.37 kg
ΔPE −161.68 → −108.58 kWh
The remaining +0.70 SAP residual is a separate upstream gap (likely
warm-air-HP SH cascade or Table 4a SH efficiency for code 524) —
follow-up slice.
No regressions on the other 24 cohort variants. Cohort-1 ASHP certs
(Cat 4 HP + WHC=901 = HW from HP + cylinder) keep ×0.9 as before
because their WHC=901 doesn't trigger the new guard. Extended
handover suite: 901 pass / 0 fail (was 900 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) verbatim:
Primary loss is set to zero for the following:
Electric immersion heater
Combi boiler ...
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
The Elmhurst WHC=903 lodging signals exactly the first row: "HW from
a separate electric immersion heater" — the cylinder is heated by an
immersion element inside the tank, no primary pipework between any
heat generator and the cylinder. The rule is universal: regardless
of what main heating exists for space heating, electric immersion
means no primary circuit means no primary loss.
Pre-slice `_primary_loss_applies` only consulted `water_heating_code`
in the Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat
4 HP branch returned True unconditionally when no PCDB record was
lodged; the Cat 1/2 boiler branch returned True unconditionally; the
PCDB Table 322 + Table 4b non-PCDB branches likewise. For the
electric 2 corpus variant (sap_main_heating_code=524 Cat 5 warm-air
ASHP, main_heating_category=4 per Elmhurst mapper, no PCDB record,
WHC=903 + cylinder), the Cat-4 branch falsely returned True and the
cascade added ~510 kWh/yr primary loss to a system with no primary
circuit at all.
Per-line walk discipline applied: cascade `water_heating_from_cert`
output dump showed `primary_loss_monthly_kwh_annual = 509.98` while
worksheet (59)m = 0 every month → spec lookup found Table 3 verbatim
"Electric immersion heater" zero-loss line.
Adds `_WHC_ELECTRIC_IMMERSION: Final[int] = 903` constant + a
top-of-function `if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False` guard that fires before any of the system-type-keyed
branches.
Closures electric 2:
HW kWh 2849.22 → 2339.24 (matches worksheet (62)/(64) = 2384.12
within the residual ~45 kWh storage-loss gap)
ΔSAP −0.4584 → +0.8118 (cascade swung past the worksheet by +1.27
— the pre-slice 'near-correct' value was offsetting cascade bugs
per [[feedback-software-no-special-handling]]; the +0.81 residual
exposes a separate upstream gap to chase in a follow-up slice)
Δcost +£10.56 → −£18.71
ΔCO2 +47.89 → −7.21 kg
ΔPE +443.13 → −161.68 kWh
No regressions on the other 24 cohort variants — only electric 2 has
the (Cat 4 HP, no PCDB, WHC=903) combination in the corpus.
Extended handover suite: 900 pass / 0 fail (was 899 — +1 from the
new AAA test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>