mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-22 08:48:38 +00:00
146 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a8e5563ace |
fix(warm-air): Table 11 secondary fraction for category 9 → 0.10
main_heating_category=9 (warm-air systems, NOT heat pump) had no entry in _SECONDARY_HEATING_FRACTION_BY_CATEGORY, so a warm-air main with a lodged secondary raised UnmappedSapCode in _secondary_heating_fraction_for_category — the last calc_raise in the API sample (cert 0380-2197-2590-2996-2715: warm air mains gas code 506 + electric room-heater secondary). SAP 10.2 Table 11 (p.188): a gas/oil warm-air unit falls under "All gas, liquid and solid fuel systems" (0.10), and electric warm air under "Other electric systems" (also 0.10) — so 0.10 regardless of fuel. The warm-air efficiency (Table 4a code→eff: 506→0.70) and Table 4f fan energy were already wired; this was the only missing dispatch entry. 0380 now computes: SAP 78.1 vs lodged 77 (+1.1; the residual is per-cert fabric/PV, not the warm-air dispatch — a faithful 0380 worksheet isn't available, sim case 28 diverges at SAP 57 / code 502 / condensing unit). Eval: zero raises remain, computed 908→909; mean|err| 1.703→1.702. Regression green (2448 pass incl. golden 6035 + cohort); pyright net-zero (44=44). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
449d8c5b95 |
fix(hw): direct-acting electric boiler (191) → zero primary circuit loss
SAP 10.2 Table 3 (PDF p.160) names "Direct-acting electric boiler"
verbatim in the primary-loss zero list (alongside electric immersion,
combi, CPSU, integral-vessel heat pump). RdSAP 10 §12 (p.62) classifies
SAP code 191 as the direct-acting electric boiler. Its cylinder is
immersion-heated with no primary pipework, so no primary circuit loss
applies — but `_primary_loss_applies` had no 191 branch, so a 191 main
(main_heating_category 2, "Boiler and radiators, electric") fell through
to the cat-{1,2} boiler branch and accrued ~1177 kWh/yr of phantom
primary loss on the electric-flat segment.
Validated against the cert-2474 worksheet: §4 (59) primary loss = 0,
(64) HW output 1760 (cylinder) + (64a) shower 581. Cert 2474 HW kWh
3585 → 2408; SAP 64.66 → 70.35 (the residual to the lodged 78 is an
Unknown-meter data-fidelity artifact — the register recorded meter_type=3
"Unknown" but the lodged rating used an 18-hour off-peak meter, per RdSAP
§12 / the example worksheets).
Eval mean|err| 1.720 → 1.708 (headline 45.0%, flat ±1 cert — the
electric-flat segment is dominated by the meter data-fidelity artifact).
Regression green (2448 pass incl. golden 6035 + ASHP cohort 1e-4);
pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
2bc73fb08d |
fix(cost): HP-DHW from PCDB heat pump bills Table 12a ASHP_APP_N WH split
When DHW is heated by the main heat pump (WHC 901/902/914 = "from main
system") and the main carries a PCDB Table 362 record,
`_hot_water_fuel_cost_gbp_per_kwh` billed the electric HW at 100% off-peak
low rate (its long-standing TODO). SAP 10.2 Table 12a Grid 1 WH column
(PDF p.191) puts HP-DHW on the ASHP/GSHP-from-database row: 0.70
high-rate fraction at 7-hour and 10-hour → 0.70×14.68 + 0.30×7.50 =
12.526 p/kWh (10-hour), not 7.50 p. The low-rate collapse over-credited
the cat-4 HP-DHW cluster.
Fix: pass the cert WHC into the helper and, for HP-DHW (WHC ∈ {901,902,
914} + PCDB-HP main), bill at the ASHP_APP_N WH blended rate. Electric
IMMERSION (WHC 903) is a different Table 12a row (off-peak immersion 0.17
/ Table 13) and stays on the 100%-low-rate fallback until that slice
lands.
cat-4 cluster (20 certs): mean|err| 2.43→2.11, mean signed +0.06→-0.52
(now per-cert scatter, no systematic bias); cert 9472 +6.4→+3.2, 2789
+6.8→+4.0, 4135 +2.7→within 0.5. Headline mean|err| 1.727→1.720.
Regression green (2447 pass incl. golden 6035 + ASHP cohort at 1e-4);
pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
e41a0bc0d7 |
fix(cost): PCDB heat pump without SAP code bills Table 12a ASHP_APP_N split
A heat pump that resolves via its PCDB Table 362 index alone (API path, data_source=1, no Table-4a SAP code) had sap_main_heating_code=None, so `_table_12a_system_for_main` fell through the 211-227/521-524 code-range gate to None → the "100% off-peak low-rate" fallback. On a Dual meter (RdSAP §12 Rule 3 routes heat pumps to the 10-hour tariff) this billed space heating at 7.50 p/kWh instead of the SAP 10.2 Table 12a Grid 1 (PDF p.191) ASHP/GSHP-from-database row: 0.80 high-rate fraction → 0.80×14.68 + 0.20×7.50 = 13.244 p/kWh. The collapse over-credited the whole cat-4 heat-pump cluster. Fix: route any main with a PCDB heat-pump record to ASHP_APP_N regardless of SAP code (a Table 362 record IS an Appendix-N heat pump by definition). ASHP_APP_N and GSHP_APP_N share the 0.80 SH fraction at 7h/10h, so ASHP_APP_N is the canonical Appendix-N row for the SH split. cat-4 cluster (20 certs): within-0.5 45%→50%, mean signed +1.43→+0.06, mean|err| 3.81→2.43; cert 9472 +15.0→+6.4, 2789 +13.4→+6.8. Headline 45.0%→45.1%, mean|err| 1.757→1.727. Regression green (only the pre-existing test_total_floor_area fails); pyright net-zero. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4d1a58b828 |
fix(tariff): Unknown meter + storage/CPSU main → off-peak (§12)
Electric storage heaters (and CPSU) charge overnight and cannot run economically on a single rate, so their presence is physical evidence the dwelling is on an off-peak tariff. RdSAP 10 §12 (PDF p.62) applied Rules 1-4 only for a Dual meter; an "Unknown" (code 3) meter returned STANDARD without consulting the heating type, so a cat-7 storage main billed its overnight charge at the standard 13.19 p/kWh instead of the 7-hour low rate (5.50 p/kWh) — ~2.4x too high → large under-rate. Two coupled fixes: - `rdsap_tariff_for_cert`: for an Unknown meter, infer the off-peak tariff from a Rule-1 CPSU (→10-hour) or Rule-2 storage (→7-hour) main; keep STANDARD otherwise. Direct-acting/room heaters/heat pumps (Rule 3) are NOT off-peak evidence (run on demand, exist on single-rate meters) so they stay STANDARD — billing them 100% at the low rate over-credits. - `_fuel_cost` now resolves its tariff via the §12-aware `_rdsap_tariff` (not the raw `tariff_from_meter_type`), so the off-peak branch fires for these storage certs and the legacy scalar fields bill the low rate. Mirrors `_is_off_peak_meter`'s existing Unknown+electric heuristic (which already routes HW/secondary off-peak), closing the main-space-heating gap. Meter-3 electric cluster: mean |err| 11.18 → 6.52, within-1.0 3 → 5 (cert 7336 -26.1 → -0.16, 0380 -19.9 → +1.0). Eval headline 44.9% → 45.0%, mean |err| 1.82 → 1.76, mean signed -0.08 → +0.02. A few storage certs overshoot (other residuals the standard rate was masking). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
7dfe3f2c99 |
feat(test): case-20 cascade fixture + close its CO2 via E7 per-end-use codes
Locks sim case 20 (storage heaters + Detailed RR + loose-jacket cylinder)
as a golden vector: _elmhurst_worksheet_001431_case20.build_epc() routes the
Summary PDF through extractor → mapper → calculator, registered in
test_e2e_elmhurst_sap_score with all 11 SapResult headline pins at 1e-4.
10 pinned exact off slices 1-2 (window extractor, RR stud walls); this slice
closes the last one, co2_kg_per_yr (was 3797.62 vs (272) 3815.4060).
Root cause: on a dual-rate (E7) meter the CO2 path ignored the tariff's
high/low Table-12 electricity codes that the cost path already uses:
- Secondary (direct-acting portable heaters, on-peak) keyed the monthly
Table 12d cascade on standard code 30 (0.15405) instead of the E7 HIGH
code 32 → (263) 0.1616. SAP 10.2 Table 12a Grid 1 direct-acting electric
is 100% high-rate; mirrors the cost side billing it at 15.29 p/kWh.
- Main storage heaters fell through `_table_12a_system_for_main`=None to
the FLAT annual factor (0.136) rather than the dual-rate LOW code: per
the Table 12a design intent ("storage … 100% low rate") they charge
off-peak → E7 LOW code 31 → (261) 0.1357.
case-20 co2 now EXACT. 2433 calculator + 112 golden + documents_parser tests
pass — no dual-meter/storage cohort regression; pyright strict net-zero (32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
efa10fe6cb |
S0380.239: system-build walls take masonry structural infiltration (0.35)
RdSAP 10 §2 (Ventilation, "Walls" row): "Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction ... System build: treated as masonry." `_is_timber_or_steel_frame` wrongly included wall_construction code 6 (system build) alongside code 5 (timber frame), handing system-build dwellings the 0.25 structural ACH instead of 0.35. On the cat-10 room-heater fixture (ref 001431, walls SY System Build → code 6) this under-stated the infiltration rate (18) by exactly 0.10 (0.45 vs worksheet 0.55), dropping the effective air change (25), the ventilation heat loss (38)m = 0.33 × (25)m × (5), and the heat-transfer coefficient (39) — so space-heating demand (98) came out 404 kWh low ((211) 11158.6 vs worksheet 11563.2). Restrict the 0.25 branch to code 5 only; code 6 (and everything else) is masonry at 0.35. Pins the rating-block (38)m ventilation heat loss mean = 83.3613 W/K at abs 1e-4 and asserts the classifier treats the system-build wall as masonry. §4 suite green (2415 passed, 1 skipped); no existing fixture relied on system-build → 0.25. Residual after this slice: SAP +0.03 / cost -£0.95 — a small fabric (33) gap (-0.15 W/K) plus lighting (232) +1.0 kWh remain as separate causes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cbdee9ec3c |
S0380.238: single-point instantaneous water heaters incur no distribution loss
Water heating SAP code 909 (electric instantaneous) and 907 (single-point
gas) heat water at the point of use, serving one outlet with no
distribution pipework. Per SAP 10.2 §4 (p.23, l.1416): "'Single-point'
heaters, which are located at the point of use and serve only one outlet,
do not have distribution losses either." So worksheet (46)m = 0 and the
heat-required line collapses to SAP 10.2 worksheet l.7704
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
= 0.85 × (45)m (all loss terms zero for a no-cylinder system).
`distribution_loss_monthly_kwh` already supported the
`is_instantaneous_at_point_of_use` flag (and its docstring already named
codes 907/909), but `water_heating_from_cert` hard-coded it to False, so
the cascade applied (46)m = 0.15 × (45)m to single-point heaters. That
0.15 distribution loss exactly cancelled the 0.85 reduction, leaving
(62)m = (45)m. On the cat-10 room-heater fixture (ref 001431, code 909)
that over-stated the water fuel (219) as 2082.6250 instead of the
worksheet's 1770.2313, and inflated the (65)m heat gains (692.47 vs
worksheet 442.55) which in turn suppressed space-heating demand.
Thread the cert's existing instantaneous flag (`_INSTANTANEOUS_WATER_CODES`
= {907, 909}) through `_water_heating_worksheet_and_gains` into both the
demand-pass and final `water_heating_from_cert` calls.
Pins (219) water fuel = 1770.2313 at abs 1e-4 via the extractor → mapper →
rating cascade. §4 suite green (2414 passed, 1 skipped); no existing
fixture exercised the 907/909 path. The residual space-heating fuel gap
((211) 11158.59 vs worksheet 11563.17) this exposes is a separate cause —
next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
3c0ac98122 |
feat(calculator): thread per-end-use fuel codes + PV export onto SapResult
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.
Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (
|
||
|
|
9521d52403 |
S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:
SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss
(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
- (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
- export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
line 5501); the onsite dwelling portion EPV,m × βm is unchanged.
Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.
Three layers:
- extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
- `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
set in both the Elmhurst and API mapper paths;
- `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
`cert_to_inputs` recomputes (219) and the PV export.
On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
d4a8c02b54 |
S0380.233: PV self-consumption credited at Table 12a weighted import rate
SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal import electricity price to PV energy used within the dwelling and the 'electricity sold to grid, PV' price from Table 12 to the energy exported. In the case of the former, use a weighted average of high and low rates (Table 12a)." `_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling credit. PV self-consumption displaces the dwelling's "all other uses" electricity (lighting / appliances / pumps), which on an off-peak tariff bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low rate. On simulated case 19 the worksheet (252)/(269) credits PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every off-peak PV cert. Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32 code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass); off-peak returns the weighted high/low blend. Call sites now pass the resolved `_rdsap_tariff(epc)`. The now-unused `_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller) is removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
212b0c92ab |
S0380.232: D_PV excludes low-rate portion of off-peak electric main heating
SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m = (211)m + (213)m + (215)m, where (211), (213) and/or (215) should be included only where the fuel code applied to them in Section 10a of the SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)." The PV-eligible demand D_PV,m was adding 100% of the main space-heating fuel (211)m whenever the main's Table-12 code was in the eligible set (30, …), ignoring the off-peak high/low split that §10a already bills via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211) may enter D_PV — but the cascade counted it all, inflating R_PV,m = E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating months. Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_ fraction(main, tariff)` returns the high-rate portion (1.0 for non-electric / STANDARD, the published Grid 1 SH fraction otherwise, 0.0 when the Grid 1 SH row is unwired → 100% low rate), and `_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it. Backward-compatible: STANDARD-tariff electric mains and the gas-main / electric-secondary PV cohort are unchanged (fraction 1.0). On simulated case 19 (electric storage heaters, 7-hour, PV) this takes β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months (no main heating) already pinned exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0476b4b235 |
S0380.230: electric room heaters (cat 10) on off-peak bill at Table 12a direct-acting high rate
SAP 10.2 Table 12a Grid 1 (PDF p.191): an electric room heater (RdSAP main_heating_category 10, e.g. SAP code 691) is direct-acting electric, so it sits on the "Other systems including direct-acting electric" row — 7-hour high-rate fraction 1.00, 10-hour 0.50. It runs on demand, mostly at the HIGH rate; it does NOT earn the 100%-low-rate of overnight storage charging (which is category 7). `_table_12a_system_for_main` only mapped ASHP, so an electric room heater fell through to the "100% low-rate" fallback (5.50 p, £0.0550), under- charging space heating by ~9.79 p/kWh and systematically OVER-rating the cluster. Now maps electric cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC (gated on `_is_electric_main`, so gas/solid-fuel cat-10 room heaters are excluded). The same Table 12a fraction flows through cost, CO2 (Table 12d) and PE (Table 12e) — all three callers already pre-gate on electric. Mirror of S0380.228 (same fallback bug for electric SECONDARY heating). 1,000-cert 2026 API sample (no worksheet for this cluster — ±0.5-vs-lodged fallback bar): cat-10 mean |err| 9.49 → 7.11, %<0.5 10.4% → 16.7%; headline %<0.5 42.5% → 42.9%, overall mean |err| 2.29 → 2.16. cat-7 (storage) and cat-2 (gas) unchanged. Full §4 suite green (2405 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0f6b402345 |
S0380.229: primary loss applies for a dedicated water-heating boiler/circulator (WHS 911-931)
SAP 10.2 Table 3 (PDF p.160) row 1: 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 Table 4a hot-water-only codes (PDF p.166) 911 gas / 912 liquid / 913 solid boiler-circulator + 921-931 range cooker with boiler are each a heat generator feeding the cylinder through a primary loop. `_primary_loss_applies` keyed only off the resolved DHW `main` — but for these certs `_water_heating_main` returns the SPACE main (e.g. electric storage heaters, SAP code 402, which has no primary loop), so every boiler branch missed the gas water-boiler's primary circuit and (59)m went to zero. New branch keys off `water_heating_code` ∈ `_WATER_HEATING_BOILER_CIRCULATOR_CODES`. 941 (electric HP for water only) is excluded — HP DHW vessels follow the Table 3 integral-vessel rules. Simulated case 19 (electric storage main + WHS 911 + 210 L cylinder): (62)m total HW demand 2493.30 → 3169.98 kWh/yr, matching the worksheet (the missing 676.68 kWh/yr = the worksheet's (59) primary-loss annual sum, h=5/p=0). The remaining (64)/(219) gap is the PV diverter (63b), deferred to its own slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4911c56200 |
S0380.228: electric secondary on off-peak bills at Table 12a direct-acting high rate
SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating is a direct- acting electric room heater (RdSAP 10 §A.2.2 default), on the "Other systems including direct-acting electric" row — 7-hour high-rate fraction 1.00, 10-hour 0.50. A room heater runs on demand, mostly at the high rate; it does NOT earn the 100%-low-rate of overnight storage charging. `_secondary_fuel_cost_gbp_per_kwh` previously returned the flat off-peak LOW rate (5.50 p, £0.0550) for every off-peak electric secondary, under- charging by 9.79 p/kWh. New `_secondary_off_peak_rate_gbp_per_kwh` mirrors `_space_heating_fuel_cost_gbp_per_kwh`: it blends the Table 12a high-rate fraction (OTHER_DIRECT_ACTING_ELECTRIC) against the Table 32 high/low rates, with the 18-/24-hour fallback to the low rate. Simulated case 19 (electric storage main + electric secondary, Dual/7-hour meter) is the worksheet case (242): "Space heating - secondary (1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. This was the primary cat-7-cluster cost driver: total cost 1485.68 → 1835.53 (worksheet 1816.58), SAP cont 60.11 → 50.67 (worksheet ~51.22). Remaining +19 cost is HW/space-heating kWh (next slices). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5d4b55d7f9 |
S0380.227: dedicated DHW-only system is not separately timed (Table 2b note b)
SAP 10.2 Table 2b note b (PDF p.159) applies the ×0.9 temperature-factor
reduction only when DHW is "separately timed" relative to space heating
on a SHARED heat generator ("boiler systems, warm air systems and heat
pump systems"). Per RdSAP 10 §10.5.1 (PDF p.55) a separate boiler/
circulator providing DHW only (water-heating code 911 = "Gas boiler/
circulator for water heating only") is NOT the main space-heating system
— so there is no shared timer to apply the ×0.9 against. `_separately_
timed_dhw` now returns False when water_heating_code is not "from main /
2nd-main system" ({901,902,914}), mirroring the existing WHC 903 electric-
immersion carve-out.
Simulated case 19 (electric storage main SAP 402 + WHS 911 + 210 L
loose-jacket cylinder) is the worksheet case. The single flag drives both:
- (53) Temperature factor: 0.54 → 0.6000 (worksheet base, no ×0.9)
- (55) storage loss/day: → 3.4531; (56)/(57)m Jan → 107.0456 (1e-4)
- (59)m primary loss: h=3 (43.31) → h=5 (Jan 64.5792), worksheet-exact
This also worksheet-pins S0380.224's loose-jacket storage loss magnitude
at 1e-4, previously only direction-validated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9c0a373f7d |
S0380.225: §10.7 no-water-heating default — A-F → 12mm loose jacket
The §10.7 no-water-heating default cylinder raised UnmappedSapCode for age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's "A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket storage-loss branch didn't exist. S0380.224 added it, so this slice completes the Table 29 lookup. Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry (cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket, 12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29 (PDF p.56) — and have the default read both, setting the loose-jacket type for A-F instead of hardcoding factory. The strict-raise is retained only for an absent / out-of-A-M age band (no Table 29 row). Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C, 11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus "no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py pyright unchanged at 32; new test suppresses reportPrivateUsage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2e351be957 |
S0380.224: compute storage loss for loose-jacket cylinders (Table 2 Note 1)
`_cylinder_storage_loss_override` returned None for any cylinder whose cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder (code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss combi/instantaneous default — its real storage loss vanished. SAP 10.2 Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor (L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the cylinder_storage_loss_factor_table_2 helper already implements it — only the dispatch was missing. Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket; None/0/unknown → None, keeping the conservative no-loss default). The override and the HW storage call now route through it instead of hardcoding "factory_insulated". Evidence + validation: a random 2026 register sample has 22 loose-jacket certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within 0.5) — the exact signature of under-counted HW storage loss. After the fix their mean error collapses to +0.45 and 11/22 land within 0.5, with ZERO regression across the worksheet-validated cohort (§4 + golden suite 2394 passed — no validated cert lodges loose jacket, so none shifts). Also unblocks the §10.7 A-F no-water-heating default (next slice) which needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ee484d9f4a |
S0380.213: heat-network standing charge (£120) — fixes 9390 cost under-count
Cert 9390 (community mains-gas boiler, API main_fuel_type=20) drew £0 standing charge → fuel cost under-counted → SAP read +4 high (71 vs 67). Root cause: the standing-charge logic (`additional_standing_charges_gbp`) only knows the GAS branch (`_is_gas_code`) and the off-peak-electric branch. A heat-network community fuel is not a Table-32 gas code — EPC 20 = "mains gas (community)" normalises to Table-32 code 20 (biomass), so `_is_gas_code(20)` is False and the standing came out £0. The Summary path masks this because it lodges community gas as Table-32 code 1 (ordinary mains gas), which IS gas-recognised and already draws the £120 gas standing — so the CH1-6 corpus was unaffected while the API path lost the charge. Spec basis (verified against SAP 10.2 spec PDF): - Table 12 (p.191) "Heat networks" row standing charge = £120/yr, note (k). - Note (l): "Include half this value if only DHW is provided by a heat network." - §C3.2 (p.58): the full charge applies when the space heating is also a heat network. Worksheet-validated: simulated case 14 (community boilers + mains gas, space + water) → worksheet (351) Additional standing charges = £120. Fix: new `_heat_network_standing_charge_gbp(epc, main)` returns the heat-network standing (£120 full when the space main is a heat network; £60 when only DHW is on the network) or None otherwise. Applied at both fuel-cost call sites, REPLACING the fuel-based `additional_standing_charges _gbp` for heat-network mains (NOT additive) so a Summary-path community-gas main — already £120 via the gas branch — is not double-counted to £240. The CH1-6 community corpus stays exactly £120 (59 corpus tests pass). 9390 SAP +4 → -2 (cont 65.39 vs lodged 67): the spec-correct £120 standing EXPOSES a separate ~7% demand over-count (also visible as PE 220 vs lodged 205) — a heat-source-efficiency-default / fabric residual, follow-up scope. 9390 is unpinned (retired P2.2 per ADR-0010 §10); helper locked by 2 unit tests. Full suite 2386 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
08dd0b4c73 |
S0380.212: fix community fuel-code collision in heat-network CO2/PE/cost
Cert 9390-2722-3520 (community mains-gas boiler scheme, sap_main_heating_
code=301, main_fuel_type=20) emitted CO2 0.44 t vs lodged 2.8 t — 6.4x low.
Root cause: the EPC `main_fuel_type` enum and the SAP Table 12 / Table 32
fuel-code numbering COLLIDE in the 18-25 range. Per
`datatypes/epc/domain/epc_codes.csv` (RdSAP-Schema-17.0) EPC fuel
20 = "mains gas (community)", but Table 12/32 code 20 is a solid biomass
fuel (CO2 0.028, PE 1.046, wood-logs price). The factor lookups
(`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`)
check the Table-12/32 dict FIRST, so the EPC community fuel 20 silently
returned the biomass factor instead of translating 20 -> Table 12 code 51
(community mains gas: CO2 0.210, PE 1.130, mains-gas price).
Fix: new `_heat_network_factor_fuel_code(main)` translates the EPC community
fuel to its Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for
heat-network mains (`_is_heat_network_main`) — a genuine biomass boiler
(non-community) keeps its raw Table-12 factor. Applied at the five
heat-network factor sites: space-heating CO2 / PE / unit-price and
water-heating (WHC 901) CO2 / PE. The Summary path is unaffected (it maps
"Mains gas - community" to code 1, no collision), so the community-heating
corpus (CH1-6) is untouched.
Worksheet-validated against simulated case 14 (community boilers + mains
gas, SAP code 301): worksheet (367) CO2 factor 0.2100, (467) PE factor
1.1300 — exactly the Table-12 code-51 values the translator now reaches.
9390 CO2 0.44 -> 3.03 t (lodged 2.8; spec-correct factors over the API-only
register residual per [[feedback-worksheet-not-api-reference]]), PE 204 ->
220 (the spec-correct 1.13 factor; the prior 204≈205 match was the
collision coinciding with the register residual). 9390 is unpinned (retired
at P2.2 per ADR-0010 §10); the translator is locked by two unit tests.
REMAINING (separate follow-up): 9390 SAP +4 is a cost-side gap — the
heat-network cost path does not apply the 1/heat_source_eff (1/0.80)
scaling that the CO2/PE paths do, so community fuel cost under-counts.
Suite: 2616 passed, 1 skipped (community corpus green); the 2
test_rdsap_uvalues stone-formula failures are pre-existing (HEAD
|
||
|
|
d1ae87c7e9 |
S0380.206: Eq D1 Q_space uses the DHW boiler's own (204) share, not (202)
SAP 10.2 Appendix D §D2.1(2) Equation D1 blends the monthly water-heater efficiency by the ratio of the boiler's space-heating load to its water load. On a dual-main cert the DHW boiler does only its OWN share of space heating ((204) for Main 1, (205) for Main 2), but the cascade fed Eq D1 the dwelling total ((202) = 1 − secondary). That over-weighted η_winter and under-stated HW fuel — simulated case 6 (Main 1 serves DHW + 51% of space heat) was HW −78 kWh vs the worksheet. New `_water_heating_main_space_fraction` returns the DHW main's total- space share via `_water_heating_main` (WHC-901 → Main 1 (204); WHC-914 → Main 2 (205)); single-main / WHC-901 single systems get (202) = 1 − (201), so they are unchanged. Case 6 (219) HW now 4902.8601 EXACT. With S0380.205 (demand exact), case 6 now closes to 1e-4 on EVERY metric: SAP cont 71.6597, ECF 2.0316, cost 1162.5374, (211)+(213) 14736.9564, (219) 4902.8601, (231) 356, (232) 357.6571, CO2 5953.6679 (rating) / 4895.2137 (demand). Re-pin: 0240 (dual combi, WHC 901, Main 1 51%) HW rises slightly → PE +1.6893 → +1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). Single-main certs unchanged (2360 pass + 0 fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e440e2df2e |
S0380.205: SAP 10.2 p.186 two-systems-different-parts MIT (weighted R + elsewhere blend)
When two main heating systems heat different parts of a dwelling, SAP 10.2 §7 (PDF p.186) adapts the mean-internal-temperature calculation: - Table 9b weighted responsiveness: R = (1−(203))·R_sys1 + (203)·R_sys2. - Rest-of-dwelling temperature (90)m = weighted average of T2 computed under EACH system's control schedule, weights (203)/[1−(91)] for sys2 and [1−(203)−(91)]/[1−(91)] for sys1 (or sys2's control alone when (203) ≥ 1−(91)). The cascade used Main 1's control + R=1.0 for the whole dwelling, over-stating MIT by +0.037 °C on simulated case 6 (Main 1 radiators/2106 type 2 living + Main 2 underfloor/2110 type 3 elsewhere, R 1.0/0.75). That inflated (97) heat loss by ~11 W → demand +61 kWh/yr. `mean_internal_temperature_monthly` gains `main_2_control_type`, `main_2_fraction`, `main_2_responsiveness`; cert_to_inputs derives them from the second main detail (gated on main_heating_fraction > 0, so single-main / DHW-only second mains pass the defaults → unchanged). Case 6: (87) living, (90) elsewhere, (98c) demand 11991.96 and per-system fuel (211)=7741.6458 / (213)=6995.3106 all match the worksheet to 1e-4. Re-pin: golden 0240 (same 2106/2110 archetype, API-only) — PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both closer to zero; SAP 72 unchanged). Single-main certs unchanged (2360 pass + 0 fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
963db2ae23 |
S0380.201: SAP 10.2 Table 4f note c) second main-system circulation pump
Simulated case 6 (P960-0001-001431, dual oil boiler 51% rads + 49% underfloor) worksheet (231) = 356 = (230c) central-heating pump 156 + (230d) oil boiler pump 200. (230c) decomposes per SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main heating systems include two figures from this table" — Main 1 41 kWh (pump age "2013 or later") + Main 2 115 kWh (pump age unknown). The cascade summed only Main 1's circulation pump, giving (231) = 241. cert_to_inputs now adds the second main's circulation pump, gated on a lodged main_heating_fraction > 0 (a genuine second SPACE-heating main — the same test §9a uses to split space-heating demand). This excludes DHW-only second mains (cert 000565 Main 2 = gas combi via WHC 914, fraction 0); without the gate 000565's worksheet pins regressed +115 kWh. Re-pin: golden 0240 (dual-main oil combi, API-only, no worksheet) gains its Main 2 pump too (pumps_fans 315 → 430). Spec-correct per note c and validated by the case-6 worksheet; SAP cont 72.55 → 72.18 (integer 73 → 72, resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → +0.1385. The lodged 73 carries Elmhurst's own residual; the worksheet- backed case 6 is the spec authority for the archetype. Note: the boiler-interlock −5pp per-main determination the prior handover flagged as the priority is already implemented (S0380.141 cylinder-thermostat path + S0380.177 room-thermostat path) — case 6 already produces (206)=79 / (207)=84 exactly, and 0240 is a combi with no cylinder so correctly unpenalised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8ae978a646 |
S0380.200: SAP 10.2 §9a two-main-heating split (203)/(205)/(207)/(213)
The cascade lumped a dwelling with two main heating systems into one: `space_heating_fuel_monthly_kwh` hard-coded (203)=0 (a documented scope-A placeholder) and the calculator's per-month fuel read only main_1, so the full §8 space-heat demand billed against system 1's efficiency. Simulated case 6 (one oil boiler feeding radiators 51% + underfloor 49%) exposed it: main fuel ≈ demand/eff1 instead of the worksheet's (211)+(213) per-system split. Implements the SAP 10.2 §9a two-main model: (204) = (202) × (1 − (203)) → system 1 share of total heat (205) = (202) × (203) → system 2 share of total heat (211)m = (98c)m × (204) × 100 / (206) (213)m = (98c)m × (205) × 100 / (207) (203) = the second system's lodged `main_heating_fraction`; (207) = its own seasonal efficiency via the new per-detail `_main_heating_detail_ efficiency` (the core of `_main_heating_efficiency`, now reused for system 2). Calculator `_solve_month` aggregates main_1 + main_2 into `main_heating_fuel_kwh`. Cost (§10a 241), CO2 (§12 262) and PE (§13 276) main_2 paths were already wired and now activate. Site-notes gap also fixed: §14.1 Main Heating2 omits the "Fuel Type" cell when the second system shares Main 1's fuel (case 6: one oil boiler, two emitters). `_map_elmhurst_main_heating_2` now inherits Main 1's resolved fuel as a fallback. Blast radius: only dual-main certs. 0240 (2× oil code 130, identical Eq-D1 efficiency) is unchanged — its split collapses to the lumped total. Suite: 2355 passed, 1 skipped. New code: 0 pyright errors. NOTE: case 6 is not yet fully pinnable end-to-end — its two systems have DIFFERENT efficiencies (radiators 55°C → 79%, underfloor 35°C → 84%), a flow-temperature boiler-efficiency adjustment not yet modelled, and its dual-system auxiliary pumps ((230c)+(230d)=356) differ from the cascade. Both are separate follow-on features; this slice is the §9a fuel split. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
62fc27a5cc |
S0380.193: suspended-floor (12) sealed rule fires only on a SUPPLIED U-value
RdSAP 10 §5 (PDF p.29) "Floor infiltration (suspended timber ground
floor only)", age band A-E, splits on whether a floor U-value is
supplied:
a) [U-value supplied] if floor U-value < 0.5 → "sealed", (12) = 0.1
b) [no U-value supplied] retro-fitted insulation → "sealed" 0.1;
otherwise "unsealed", (12) = 0.2
`_has_suspended_timber_floor_per_spec` fed the cascade's COMPUTED default
U into rule (a), so an as-built/uninsulated suspended-timber floor whose
default U happens to be < 0.5 was marked "sealed" (0.1) where Elmhurst
uses "unsealed" (0.2). That dropped (18) infiltration 0.85 → 0.75, (25)
effective ACH, HTC, and understated space heating ~450 kWh.
Fix: gate rule (a) on `floor_u_value_known` — a computed default U is not
a supplied value, so it falls through to (b). Verified against the
cert 001431 sim-case-2 worksheet: floor "As built", U=0.43 (matches the
worksheet's (28a) 0.4300 exactly), (12)=0.2 unsealed. Golden cert 6035
(also a suspended uninsulated floor) is unaffected — its U=0.63 ≥ 0.5
already routed to unsealed.
Promotes sim case 2 to the e2e harness as `001431_rr` (Main + Extension
+ Simplified room-in-roof — the 6035 archetype). All 11 Block-1 line
refs pin at abs=1e-4, locking BOTH this fix and S0380.192 (Simplified-RR
remaining area) end-to-end: SAP 69, cost 920.5046, CO2 4566.7090, space
15269.8593, main fuel 18178.4039. 2319 passed (+11), 0 failed; pyright
net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
e03f08cdc8 |
S0380.189: thermal mass parameter per RdSAP 10 §5.16 Table 22, not hardcoded 250
The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:
100 kJ/m²K — timber frame, cob, park home (regardless of internal
insulation); OR masonry (stone/solid brick/cavity/system
built) WITH internal insulation.
250 kJ/m²K — masonry WITHOUT internal insulation.
A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.
`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.
Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.
Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
72743eb8a4 |
S0380.188: D_PV,m uses lighting ELECTRICITY (L10) not the L12 gain — closes PV cohort to 1e-4
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.
Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).
Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
a5d886187c |
S0380.187: include electric secondary heating in Appendix M1 D_PV,m — closes gas+PV PE/CO2 gap
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV generation by the monthly PV-eligible electricity demand D_PV,m. The cascade included main and water electricity (when those fuels are electric) but had no term for SECONDARY space heating. For the 10 cohort-2 gas-main + electric-secondary + PV certs, the (215)m secondary electric fuel was dropped from D_PV,m — understating demand in the heating months only, depressing the monthly β, and under-crediting onsite PV primary energy. Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric space-heating demand; for a gas-main/electric-secondary dwelling that is the secondary fuel. Diagnosis was decisive: E_PV (generation) matched the worksheet exactly every month, the onsite (233a) split diverged ONLY in heating months (Jun-Sep near-exact), and all 10 affected certs have PV while all clean gas certs have none. Empirically adding (215)m to D_PV closed cert 3136 onsite 726.9 → 790.3 (worksheet 792.1). Impact (calc − full-precision dr87 worksheet), the 10 certs: PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg. The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy. Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements). 2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
82f7315f8d |
S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3
SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):
(367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)
Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
PE factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501
Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).
Closure (CH3 was already SAP+cost EXACT):
CH3 (HP/Elec) CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
PE −249.32→−0.0000 (× (1.5569−1.501)) — FULLY EXACT
Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
803da062a2 |
S0380.183: community-heating HW bills at heat-network rate (§10b) — closes CH2/CH4 fully
SAP 10.2 §10b: hot water for a community-heating dwelling bills at the
heat-network rate, not the cert-lodged fuel. Elmhurst §15.0 lodges
`water_heating_fuel_type = "Mains gas"` (3.48 p/kWh) as a placeholder on
community certs; the worksheet (342) Water-heating cost = (310) × the
S0380.171 CHP heat-fraction blend — the SAME rate as space heating (340).
Per-line walk of the CH2 block 10b:
(340) space = 11837.83 × 0.037955 = 449.3047 (cascade EXACT)
(342) water = 3854.12 × 0.037955 = 146.2830 (cascade billed
3854.12 × 0.0348 = 134.12 → −£12.16, the whole residual)
(350) lighting + (351) standing → (355) 754.1502.
`_hot_water_fuel_cost_gbp_per_kwh`'s `inherit_main_for_community_heating`
path already routes HW cost through `_fuel_cost_gbp_per_kwh(main)` (the
CHP blend), but its gate `_is_community_heating_hw_from_main` excluded
code 302. S0380.182 wired the 302 CO2/PE credit via
`_heat_network_code_302_effective_factor`, which intercepts the HW
CO2/PE helpers ABOVE this predicate's branch — so extending the
predicate to include 302 now affects ONLY the cost path.
Closures:
CH2 (CHP/Gas) SAP +0.5277→−0.0000, cost −£12.16→−£0.00 — FULLY EXACT
CH4 (CHP/Oil) SAP +0.5277→−0.0000, cost −£12.16→−£0.00 — FULLY EXACT
CH6 (CHP/Coal) SAP −7.49→−8.02, cost +£172.68→+£184.84 — its HW now
also bills the blend, compounding the DLF=1.0 quirk
(cascade DLF=1.45); same separate CH6 DLF front.
Corpus now 39 variants EXACT on all four metrics (CH2/CH4 join). Open:
CH3 CO2/PE (code-304 community-HP COP), CH6 all-metric (DLF=1.0 manual
override the Summary doesn't carry). 2225 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 32→32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
8e86de2257 |
S0380.182: community-heating CHP+boilers CO2/PE credit (§12b/13b) — closes CH2/CH4 CO2+PE
SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating
"CHP and boilers" (SAP code 302). Per unit of network heat fuel
H = (307)+(310) the effective generation factor is:
chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel
(363)/(463) CHP fuel = chp_frac × 100/heat_eff × f_fuel
(364)/(464) less credit = −chp_frac × elec_eff/heat_eff × f_disp
(368)/(468) boiler fuel = (1−chp_frac) × 100/boiler_eff × f_fuel
f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up
boilers burn the same community fuel — verified vs CH2 gas / CH4 oil /
CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit
for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff
50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac
0.35 per-cert via community_heating_chp_fraction.
New `_heat_network_code_302_effective_factor` + Table 12f flexible
constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired
into all four factor helpers (main + HW, CO2 + PE) ahead of the existing
single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468)
boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the
(373)/(473)/(386)/(486) totals reconcile only with the boiler at the
full Table 12 factor — verified EXACT.
Two spec citations applied:
- Table 12f flexible-operation default for RdSAP community CHP is an
Elmhurst engine choice (Table 12f notes make "standard" the default);
mirrored per [[feedback-software-no-special-handling]] and documented
in SAP_CALCULATOR.md §8.3.
- Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected
0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302
oil cascade (CH4) was the first to exercise it. PE 1.180 was already
correct. No other variant uses these codes (no regression).
Closures (CO2 + PE only — the CHP credit does not touch cost/SAP):
CH2 (CHP/Gas) CO2 −1411.49→+0.0000, PE +1331.23→+0.0000 EXACT
CH4 (CHP/Oil) CO2 −4378.24→−0.0000, PE +319.81→−0.0000 EXACT
CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet
lodges a manual DLF=1.0 the Summary doesn't carry, so
cascade DLF=1.45 over-scales H; same root as the CH6
SAP −7.49 / cost +£172 (separate DLF front).
CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP
residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side,
untouched here). CH3 unchanged (code 304 community-HP COP front).
Corpus state: 37 variants EXACT on all four metrics (incl. CH1);
remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6
all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all
metrics per S0380.181); pyright net-zero 43→43.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
8452cf9e2d |
S0380.180: heat-network distribution pumping electricity (§C3.2) — closes CH1
SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and
Primary Energy associated with the electricity used for pumping water
through the distribution system are allowed for by adding electrical
energy equal to 1% of the energy required for space and water heating."
Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472)
bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity
for pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a). (307)m/(310)m = (space_demand +
hw_output) / efficiency (the cascade models a heat network's generator
efficiency as 1/DLF).
This un-defers the (372)/(472) front the post-S0380.179 handover flagged
"don't guess until the factor source is identified": the source is
§C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent
0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet
shows the (372) energy column as 0.01×(307) (space only) while computing
emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by-
line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating),
(472)=208.2267 PE (demand).
New `_heat_network_distribution_electricity` helper (gated on
`_is_heat_network_main`) precomputes the energy + effective CO2/PE
factors; three new CalculatorInputs fields + calculator.py CO2/PE
summation terms (0.0/None → no-op for individually-heated certs).
Closures:
CH1 (Boilers/Gas) CO2 −23.60→−0.00, PE −208.23→+0.00 — FULLY EXACT
CH3 (HP/Elec) CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution
component closed; code-304 community-HP COP remains)
CH2/CH4/CH6 gain their (372)/(472) component (CO2 +23.6, PE
+208.2); dominant CHP displaced-electricity credit
residual (Table 12f + block 12b/13b) is next slice.
No regression on the other 36 corpus variants (helper returns None off
heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0
fail; pyright net-zero 43→43.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
d7d5084f90 |
Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.
Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
tests.domain.sap10_calculator.worksheet (21 files incl. the external
importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
moved with the rdsap tests).
load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.
Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
2f039aeb39 |
Thread appliances + cooking annual kWh onto SapResult for ADR-0014 bills
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>
|
||
|
|
f2062a2fbe |
Slice S0380.179: RdSAP §10.7 electric-immersion default for no-system certs
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> |
||
|
|
c054d71284 |
Slice S0380.178: oil 6 circulation pump x1.3 for absent room thermostat
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>
|
||
|
|
5276282d8c |
Slice S0380.177: oil 6 boiler interlock from room thermostat absence
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>
|
||
|
|
326066ee12 |
Slice S0380.176: Table 4b combi sub-row dispatch for (61)m
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>
|
||
|
|
4876140a97 |
Slice S0380.174: §4 storage + primary loss for community heating
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>
|
||
|
|
e71987c239 |
Slice S0380.173: Community heating HW path routes through main fuel
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
|
||
|
|
36d4bf8750 |
Slice S0380.172: Heat-network heat-source-eff CO2/PE factor scaling
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> |
||
|
|
a4b5f4e74d |
Slice S0380.171: CHP heat-fraction split for community heating cost
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
|
||
|
|
9ed003a503 |
Slice S0380.169: EES "NON" → electricity (no-system unblock per SAP 10.2 §A.2.2)
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> |
||
|
|
58a9547210 |
Slice S0380.168: Bio-liquid mapper extensions + Table 32 FAME price flip
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>
|
||
|
|
7901dda455 |
Slice S0380.167: EES codes WEA/REA/OEA → electricity (electric storage 11-14 unblock)
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> |
||
|
|
589a8631b7 |
Slice S0380.166: Elmhurst "Bulk LPG" label → API code 27 (mapper unblock)
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>
|
||
|
|
3de52bcb90 |
Slice S0380.165: §9.4.11 boiler-interlock -5pp applies AFTER Eq D1, not before
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>
|
||
|
|
302db131c6 |
Slice S0380.164: Elmhurst-mirror §12.4.4 summer-immersion CO2/PE double-count
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>
|
||
|
|
9896644c4c |
Slice S0380.163: Elmhurst-mirror HW PE/CO2 factor on dual-rate tariffs
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>
|
||
|
|
fba45d1111 |
Slice S0380.159: SAP 10.2 Table 4a R tariff-aware dispatch for electric storage
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>
|