Slice 2 of Bill Derivation. BillDerivation(fuel_rates).derive(breakdown) takes a
delivered-energy breakdown (per-section EnergyLine(section, fuel, kwh) +
exported_kwh) and produces a Bill: per-section kWh + cost, standing charges,
SEG credit, and total.
- Each end-use line billed at its fuel's unit rate.
- Standing charge added ONCE per distinct fuel used (a meter, not an end use);
off-gas fuels carry 0 so contribute nothing — no metered/unmetered special case.
- SEG export credit subtracted.
- Deterministic (ADR-0006); raises UnpricedFuel (via FuelRates) on an unpriced
fuel (e.g. heat network) rather than billing at a wrong default.
Pure domain — no calculator dependency; the SapResult->EnergyBreakdown adapter
is slice 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of Bill Derivation — the reference-data foundation that later slices
price the calculator's per-end-use kWh against:
- Fuel enum (canonical billing fuels; the join key between the calculator's
SAP-code fuels and the rates snapshot). COAL + HEAT_NETWORK are members with
no national rate.
- FuelRates value object: unit_rate_p_per_kwh / standing_charge_p_per_day /
seg_export_p_per_kwh; raises UnpricedFuel on a fuel it has no rate for rather
than billing at a wrong default.
- FuelRatesRepository port (ADR-0011 Repo-reads-stored-reference-data) +
StaticFileFuelRatesRepository reading a committed JSON snapshot.
- Snapshot fuel_rates_2026_q2.json: GB national, Apr-Jun 2026 Ofgem cap
(gas/electricity) + DESNZ/NEP May 2026 (off-gas). Carries the full researched
data; the value object exposes single-rate fuels this slice. Off-peak
(day/night), house coal and heat network raise UnpricedFuel until later slices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Pin the bills design from a /grill-with-docs session:
- ADR-0014: whole-home annual bill from SAP10 Calculation's delivered kWh per
end use, re-priced at real Fuel Rates (NOT the calculator's SAP-notional
total_fuel_cost_gbp, which is RdSAP Table 32 standardised prices ~half real
electricity). Fuel enum + FuelRates + FuelRatesRepository static snapshot;
per-section + total flat columns; raise on unpriced fuel (house coal /
heat network are the named gaps).
- ADR-0013 amendment: the shadow stepping-stone is collapsed — the calculator
is load-bearing now. effective=calculated for sap_version<10.2 (StubRebaseliner
floor 10.0->10.2); >=10.2 keeps lodged + logs divergence; a strict-raise
aborts the batch (load-bearing for bills regardless of version).
- CONTEXT: EPC Energy Derivation -> Bill Derivation (no "service" suffix);
Baseline Performance energy block = per-end-use kWh + per-section bill + total;
Fuel Rates = committed static snapshot; Rebaselining trigger threshold 10.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wraps the four slices closing the heating-systems corpus from
Σ|ΔSAP_c| 1.24 → 0 (25/25 cascade-OK variants SAP/cost/CO2/PE
EXACT, except solid fuel 2 summer-immersion-blend artifact).
Highest-leverage next slice: close solid fuel 2 (the only remaining
open variant in the cascade-OK tier) via the S0380.154 blend code
path — likely a parallel Elmhurst-mirror gate for the summer-
immersion CO2/PE factors.
Other open fronts: 16 blocked-tier mapper extensions; pcdb 1 sub-
tolerance -0.011 SAP; cohort-2 golden residuals tightening per
[[feedback-golden-residuals-near-zero]].
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 12 footnote (t) (PDF p.189): "PE factors for grid
electricity vary by month. The average figure given in this table is
therefore not used directly. Instead the monthly factors given in
Table 12e should be used in the SAP worksheet." Footnote (s) says the
same for CO2 / Table 12d. Read literally, monthly factors apply to
every electric end-use including dual-rate HW.
The BRE-approved Elmhurst rdSAP engine doesn't follow that reading
for HW. The 41-variant heating-systems corpus controlled-variable
fixture lodges worksheet (278) "Water heating (low-rate cost)" with
factor **1.5010 PE / 0.136 CO2** (Table 12 annual flat) across every
dual-rate tariff cert, while applying the monthly Table 12e/12d
cascade to lighting (1.5338 winter-weighted) and secondary heating
(1.5715) on the same certs. It's an engine implementation choice,
not a documented spec exception.
Per [[feedback-software-no-special-handling]] the calculator
contract is bit-faithful replication of the engine, not literal
compliance with the spec text. This slice flips cascade
`_hot_water_primary_factor` + `_hot_water_co2_factor_kg_per_kwh` to
accept a `tariff: Tariff` parameter:
- STANDARD tariff → Table 12e/12d monthly cascade weighted
by HW demand seasonality (unchanged from
S0380.71 / .72, matches cohort-1 ASHP
standard-tariff worksheet)
- 7-hour / 10-hour /
18-hour / 24-hour → Table 12 annual flat (1.501 / 0.136)
matching the Elmhurst worksheet (278)
"Water heating (low-rate cost)" row
Per-line walk on electric 3 (18-hour tariff, electric immersion HW,
2384.116 kWh annual):
worksheet (278) factor = 1.5010
cascade pre-slice = 1.5214 delta = +0.0204
(1.5214 - 1.5010) × 2384.116 = +48.66 kWh/yr PE — EXACT match
the corpus residual pin.
Same shape for CO2: worksheet 0.1360, cascade pre-slice 0.1410,
delta +0.0050 × 2384.116 = +11.95 kg/yr.
Closures across the 18-variant deferred lighting-PE cohort
(electric 1/2/3/5/6/7/8/9 + solid fuel 4/5/6/7/8/9/10/11 + ashp +
gshp):
ΔCO2 +6.31 / +11.95 → ±0.0000 EXACT
ΔPE +25.51 / +48.66 → ±0.0000 EXACT
ΔSAP_c / Δcost unchanged at ±0.0000 EXACT (already closed
pre-slice by S0380.156..162).
All 25 cascade-OK variants in the heating-systems corpus now
SAP / cost / CO2 / PE EXACT vs worksheet on all 4 metrics, with
solid fuel 2 as the only remaining open residual (separate
S0380.154 summer-immersion-blend CO2/PE artifact — deferred).
Documented in
`domain/sap10_calculator/docs/SAP_CALCULATOR.md §8.1
"HW PE/CO2 factors on dual-rate tariffs use Table 12 annual"` —
the master doc now carries a new §8 "Elmhurst-mirrored spec
divergences" section for cases like this. Validation tally
refreshed from stale "930/930" to current "941/941".
No regressions on the 6 Elmhurst U985 fixtures (gas combi
STANDARD tariff — unaffected) or the cohort-1 ASHP certs
(STANDARD tariff — unaffected). The dual-rate gate fires only
on the 4 off-peak tariffs.
Verbatim spec quote retained for reference (SAP 10.2 Table 12
footnote (t), PDF p.189):
"PE factors for grid electricity vary by month. The average
figure given in this table is therefore not used directly.
Instead the monthly factors given in Table 12e should be used
in the SAP worksheet."
Tests: 907 pass (+1), 0 fail. Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire Sap10Calculator into PropertyBaselineOrchestrator as a non-load-bearing
shadow runner. For each property it scores the Effective EPC beside the
load-bearing Lodged/Effective write, catches any strict-raise -> log.error
(never aborts the batch), and on success log.warning's divergence from Lodged:
SAP |continuous - lodged| > 0.5; PEUI/CO2 > 1% relative (CO2 after kg->tonnes).
Every line is tagged with sap_version so SAP-10.2 signal separates from
older-spec drift (ADR-0010 Validation Cohort).
Per ADR-0013, Calculated SAP10 Performance is not a persisted third value-set:
effective = calculated in every baselining scenario, so the calculator IS the
mechanism that produces Effective Performance (the Rebaseliner). It runs in
shadow only while being hardened; when overrides/estimation land it is promoted
to drive Effective and the failure posture flips to abort (ADR-0012, calculator
now load-bearing). No table change.
- ADR-0013 + CONTEXT (Calculated SAP10 Performance / Effective Performance /
Rebaselining) record the decision.
- CalculatorShadow port + LoggingCalculatorShadow + Calculator protocol.
- FakeCalculatorShadow for orchestrator unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix N3.1 (PDF p.105) "Circulation pump and fan":
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). **The default heat gain from Table 5a is included
via worksheet (70).**"
This rule applies the Table 5a row "Central heating pump in heated
space" GAIN (3 / 10 / 7 W per pump-age bucket) to electric heat
pumps even though the pump ELECTRICITY is hidden in the COP and
excluded from (230c). The "Not applicable for electric heat pumps
from database" clause in Table 5a footnote a) scopes only to the
PCDB-Table-362 cascade case (Appendix N1.2.1: "For heat pumps held
in the PCDB ... a single water circulation pump serving the heat
emitters is sufficient" — pump kWh AND gain embedded in COP).
S0380.160 over-stripped the gain by zeroing pump_w for every HP
category-4 main, conflating the PCDB-Table-362 case with the Table-4a
default cascade. This slice refines the HP gate in
`_any_main_system_has_central_heating_pump`:
- Cat 4 HP WITH `main_heating_index_number` lodged (PCDB Table
362) → continue (skip; pump in COP per N1.2.1);
- Cat 4 HP with SAP code in `_TABLE_4A_WARM_AIR_SAP_CODES` (Cat 5
warm-air HPs distribute via ducted air, no water circulation
pump; warm-air fan handled separately by Table 5a "Warm air
heating system fans" row, S0380.161) → continue;
- Otherwise (Cat 4 HP, Table 4a default cascade, water-emitter)
→ apply Table 5a default per Appendix N3.1.
Per-line walk on ashp (SAP code 214 air-to-water HP, Cat 4, no PCDB,
"Post 2013" pump age):
worksheet (70)[Jan] = 3.0000 W
cascade pre-slice = 0.0000 W delta = -3.000 W
The -3 W winter gain shortfall over-stated cascade (84) Total gains
by -3 W in heating months → cascade SH demand +12.27 kWh/yr
(cascade 9302 vs worksheet 9290), pushing continuous SAP down 0.024
because the cost residual was driven by the +1.5 kWh × 12 month
shortfall flowing through the £0.0741 low-rate cost.
Closures:
ashp: ΔSAP -0.0240 → +0.0000 EXACT, Δcost +£0.55 → +£0.00 EXACT
gshp: ΔSAP -0.0178 → -0.0000 EXACT, Δcost +£0.41 → -£0.00 EXACT
ΔPE +36 → +25.51 (and ΔCO2 +7.33 → +6.31) — residuals narrow to the
Elmhurst-vs-spec HW PE annual-vs-monthly Table 12e/12d quirk only
(same pattern as the 16-variant lighting-PE deferred cohort,
scaled by HW kWh = 1138 vs 2384 → 25.51 vs 48.66). Cohort
Σ |ΔSAP_c| 0.07 → 0.03; all 25 cascade-OK variants now SAP+cost EXACT.
Cohort-1 (cert 0380 et al.) golden fixtures unaffected — those certs
lodge `main_heating_index_number` (PCDB Table 362) → HP gate skips
correctly → (70) = 0 preserved. Cert 000565 (HP main 1 + gas boiler
main 2) unaffected — wet-boiler branch fires for main 2.
Verbatim spec quote (SAP 10.2 Appendix N3.1, PDF p.105):
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). The default heat gain from Table 5a is
included via worksheet (70)."
Tests: 906 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans
a) c)" computes the gain as SFP × 0.04 × V (W). Footnote c) sets
the default SFP to 1.5 W/(l/s) when no PCDB warm-air-unit record
is lodged; footnote a) applies the heating-season-only mask
(zero in summer months). Footnote c) further omits the gain when
the dwelling has balanced whole-house mechanical ventilation
(MVHR / MV) — same omission as the Table 4f kWh-side footnote e).
Pre-slice the cascade's `internal_gains_from_cert` only wired the
central-heating-pump row of Table 5a; the warm-air-fan gain helper
(`warm_air_heating_fan_w`) existed but was unwired. The kWh-side
parallel (Table 4f, 136.35 kWh/yr) was wired in S0380.158 — this
slice closes the symmetry on the gain side.
Per-line walk on electric 2 (SAP code 524 = Cat 5 ASHP with
warm-air distribution, V = 227.25 m³, no balanced MV):
worksheet (70)[Jan] = 13.6350 W
cascade (70)[Jan] = 0.0000 W delta = -13.635 W
worksheet (98c)[Jan] = 1600.43 kWh
cascade (98c)[Jan] = 1608.12 kWh delta = +7.69 kWh
13.635 W = 1.5 × 0.04 × 227.25 exactly. The -13.6 W winter gain
shortfall propagates through the §7 utilisation cascade and over-
states cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
9426), under-charging cost by ~£2.50 with opposite sign to the
S0380.156-.158 closures.
Fix: new `_any_main_system_has_warm_air_distribution(epc)` +
`_has_balanced_mechanical_ventilation(epc)` predicates in
`internal_gains.py`, mirroring `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES`
+ `_BALANCED_MV_KIND_NAMES` (kept here as siblings so the worksheet
layer stays free of rdsap deps). Orchestrator wires
`warm_air_heating_fan_w(sfp=1.5, dwelling_volume_m3)` into the
heating-season term of `pumps_fans_monthly_w` when warm-air
distribution is present and balanced MV is not.
Closures electric 2:
ΔSAP_c -0.1087 → -0.0000 EXACT
Δcost +£2.50 → -£0.00 EXACT
ΔCO2 +16.54 → +11.95 (joins lighting-PE deferred cohort)
ΔPE +97.69 → +48.66 (joins lighting-PE deferred cohort)
Electric 2 joins the 15-variant lighting-PE deferred cohort
(electric 1 + electric 3/5/6/7/8/9 + solid fuel 5/6/7/8 + solid
fuel 4/9/10/11 + electric 2) where SAP/cost are EXACT but PE/CO2
carry an Elmhurst-vs-spec MONTHLY-factor offset (cohort uses
Table 12 annual factors on the off-peak HW immersion line; spec
mandates Table 12d/12e monthly per the header).
Verbatim spec quote (SAP 10.2 Table 5a row "Warm air heating
system fans a) c)", PDF p.177):
"Warm air heating system fans a) c) SFP × 0.04 × V"
Footnote c): "SFP is the specific fan power from the database
record for the warm air unit if applicable; otherwise
1.5 W/(l/s). These values of SFP include an in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the gains for the warm air
system should not be included."
Footnote a): "... Set to zero in summer months. ..."
Σ |ΔSAP_c| across 25-variant cohort: 0.18 → 0.07 (~60% reduction).
No regressions on the other 24 variants or any golden fixture —
gate keyed on Table 4a warm-air SAP code frozenset (only electric
2 in the corpus has a code in that set).
Tests: 905 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
space" only applies to mains with a water-loop circulation pump.
Footnote a) names two exclusions verbatim ("Does not apply if a
heating system used solely for domestic hot water. ... Not applicable
for electric heat pumps from database."), and the row's name carries
the implicit third: dry mains with no central heating pump (electric
storage heaters, electric direct-acting, solid-fuel room heaters
without back-boilers) — the row simply doesn't list them.
Pre-slice `internal_gains_from_cert` gated only on Note a) (HP
exclusion), applying `central_heating_pump_w(date_category=...)` to
every non-HP main. The default UNKNOWN-date branch added 7 W of pump
gain to (70)m for every dry-system fixture in the controlled-variable
corpus, even though the worksheet (70)m = 0 every month.
Per-line walk on electric 3 (SAP code 401 "Manual charge control"):
cascade (73)[Jan] = 640.21 W
worksheet (73)[Jan] = 633.21 W delta = +7.00 W
cascade (70)[Jan] = 7.00 W
worksheet (70)[Jan] = 0.00 W Table 5a inapplicable
The +7 W winter-month gain lowered cascade SH demand by ~38 kWh/yr
(cascade 11050 vs worksheet 11088). At Table 32 18-hour low-rate
~7.4 p/kWh that's £2.50/yr under-charging — matching the cluster's
uniform Δcost = -£1.96..-£2.80 pattern. Continuous SAP rose ~+0.10
because cost dominates the ECF.
Fix: new `_any_main_system_has_central_heating_pump(epc)` predicate
in `internal_gains.py`, mirroring `cert_to_inputs._is_wet_boiler_main`
(S0380.149 — Table 4f kWh side). Wet if any non-HP main lodges:
- sap_main_heating_code in {101-141, 151-161, 191-196} (gas/oil/
solid-fuel/electric boilers per Table 4a/4b),
- main_heating_index_number (PCDB Table 322 record),
- main_heating_category in {1, 2} (RdSAP central heating), OR
- heat_emitter_type in {1, 3} (radiators / fan-coil per Table 4d).
Dead `_all_main_systems_are_heat_pumps` helper removed (the new
predicate subsumes its role).
Cluster closures (10 variants):
electric 3: SAP +0.1215 → -0.0000, cost -£2.80 → -£0.00
electric 5: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 6: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 7: SAP +0.1017 → -0.0000, cost -£2.34 → -£0.00
electric 8: SAP +0.0941 → -0.0000, cost -£2.17 → -£0.00
electric 9: SAP +0.1199 → -0.0000, cost -£2.76 → -£0.00
solid fuel 4: SAP +0.0850 → -0.0000, cost -£1.96 → -£0.00
solid fuel 9: SAP +0.1072 → -0.0000, cost -£2.47 → -£0.00
solid fuel 10: SAP +0.1134 → +0.0000, cost -£2.61 → -£0.00
solid fuel 11: SAP +0.0912 → +0.0000, cost -£2.10 → +£0.00
Σ |ΔSAP_c| across 25-variant cohort: 1.24 → 0.18. All 10 cluster
variants now join the lighting-PE +48.66 / CO2 +11.95 deferred
cohort (Elmhurst-vs-spec monthly factor quirk, same shape as
electric 1 + solid fuel 5/6/7/8 from prior closures).
Verbatim spec quote (SAP 10.2 Table 5a row 1, PDF p.177):
"Central heating pump in heated space, 2013 or later 3 a)"
"Central heating pump in heated space, 2012 or earlier 10 a)"
"Central heating pump in heated space, unknown date 7 a)"
The row name ("Central heating pump") gates by construction: dry
systems have no central heating pump and the row's three sub-rows
don't apply.
No regressions on the other 31 variants or any golden fixture; the
6 Elmhurst U985 fixtures lodge PCDB index → the new predicate
returns True → pump_w unchanged.
Tests: 904 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the per-line walk discipline used to close electric 2 + 5
across four slices (.156 Table 3 WHC=903 primary-loss, .157 Table 2b
note b) WHC=903 ×0.9, .158 Table 4f warm-air heating fans, .159
Table 4a Cat 7 R tariff-aware dispatch). Σ |ΔSAP_c| across the
25-variant heating-systems corpus dropped from 2.87 → 1.21 (58%
reduction). All variants now sit under 0.3 SAP.
Next-slice candidate: the 9-variant cluster at ±0.09..0.12 SAP
(electric 3/5/6/7/8/9 + sf 4/9/10/11) — uniform pattern suggesting
a shared shave-the-residual fix. Worth a per-line walk on one
cluster variant before accepting the prior "Elmhurst-vs-spec quirk"
framing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters"
splits the responsiveness R between two sub-tables:
Off-peak tariff:
Slimline storage heaters ... R = 0.2 402
Convector storage heaters ... R = 0.2 403
Slimline + Celect-type control ... R = 0.4 405
Convector + Celect-type ctrl ... R = 0.4 406
24-hour heating tariff:
Slimline storage heaters ... R = 0.4 402
Convector storage heaters ... R = 0.4 403
Slimline + Celect-type control ... R = 0.6 405
Convector + Celect-type ctrl ... R = 0.6 406
Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff has electricity
at low rate for 18 hours per day with at most 6h of interruption /
2h max each — operationally equivalent to 24-hour for storage-heater
charging. The cascade therefore routes EIGHTEEN_HOUR + TWENTY_FOUR_
HOUR through the 24-hour Table 4a sub-row.
Pre-slice `_responsiveness` keyed on `sap_main_heating_code` only
and returned R=0.2 for code 402 regardless of tariff. The existing
docstring already flagged the gap:
402: 0.20, # Slimline storage heaters (24-hr tariff: 0.40)
... "promote to (sap_code, tariff) lookup when 24-hour fixture
surfaces; until then the off-peak default applies (under-shoots
R for the 24-hour case)."
Per-line walk on electric 5 (sap_main_heating_code=402 +
meter_type="18 Hour"): cascade T_living (87)[Jan] = 20.1213 vs
worksheet 19.6519, (92)[Jan] = 18.6996 vs worksheet 18.2063, (93)
[Jan] = 19.0996 vs worksheet 18.6063 (cascade +0.4933 K throughout
the cascade). Back-solve from worksheet T_living=19.6519 via the
Table 9b Tsc formula:
Tsc(R=0.4) = 0.6 × (21-2) + 0.4 × (4.3 + 0.9933 × 705.4/210.23)
= 11.4 + 0.4 × 7.6325 = 14.4528
ΔT = 21 - 14.4528 = 6.5472
u_sum = 0.5 × 6.5472 × (7² + 8²) / (24 × 11.43) = 1.3481
T_living = 21 - 1.3481 = 19.6519 EXACT match.
Adds:
- `_CONTINUOUS_CHARGING_TARIFFS: frozenset[Tariff]` = {EIGHTEEN_
HOUR, TWENTY_FOUR_HOUR} — the tariffs treated as "24-hour
heating" for Table 4a R selection.
- `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: dict[int, float]`
— the override table for codes 402/403/405/406 (404, 407, 409
keep the same R in both sub-tables).
- `tariff: Optional[Tariff]` parameter to `_responsiveness`, with
the override consulted before the off-peak default.
- Tariff threaded through both call sites of MIT cascade (rating
+ demand paths) via `tariff_from_meter_type`.
Closures electric 5:
ΔSAP −1.1759 → +0.1081 (91% reduction)
Δcost +£27.09 → −£2.49
ΔCO2 +62.72 → +7.30 kg
ΔPE +438.03 → +0.07 kWh (essentially EXACT)
Electric 5 now joins the same residual cluster as electric 3/6/7/8/
9 (+0.09..+0.12 SAP, −£2..−£3 cost, +£7 CO2) — the cluster that
the prior handovers suspected was a shared shave-the-residual gap.
No regressions on the other 24 cohort variants. Extended handover
suite: 903 pass / 0 fail (was 902 — +1 from the new AAA test).
Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.30 → 1.24 (~46%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) — verbatim:
Warm air heating system fans e) SFP × 0.4 × V
e) SFP is the specific fan power from the database record for the
warm air unit if applicable; otherwise 1.5 W/(l/s). These values
of SFP include the in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the electricity for warm
air circulation should not be included in addition to the
electricity for mechanical ventilation. However it is included
for a warm air system and MEV or PIV from outside.
V is the volume of the dwelling in m³.
Per Table 4a (PDF p.165-166), warm-air systems are:
- Category 5: heat pumps with warm-air distribution (codes 521,
523, 524 electric; 525, 526, 527 gas-fired)
- Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
fired; 512-514 liquid-fired; 515 Electricaire electric)
Pre-slice the cascade's `_table_4f_additive_components` docstring
explicitly listed "(230b) Warm-air heating fans + (230c) for warm-
air pump" as "Not yet wired" — every Cat 5 / Cat 9 warm-air corpus
variant resolved `pumps_fans_kwh_per_yr` to 0. For electric 2 (code
524 Cat 5 air-source warm-air HP, no MV, V = 227.25 m³), the P960
worksheet block 11a (249) lodges 136.35 kWh × 13.67 p/kWh = £18.64
where the cascade computed 0.
New `_TABLE_4A_WARM_AIR_SAP_CODES` frozenset (22 codes) + leaf helper
`_table_4f_warm_air_heating_fans_kwh(main, dwelling_volume_m3,
has_balanced_mv)` wired at the orchestrator pumps_fans summation
alongside the existing circulation-pump and gas-flue-fan helpers.
Footnote-e balanced-MV omission reads `epc.sap_ventilation.
mechanical_ventilation_kind` via the new
`_has_balanced_mechanical_ventilation` predicate (returns True for
MVHR / MV; False for MEV / PIV / NATURAL).
Per-line walk evidence: cascade `pumps_fans_kwh_per_yr` = 0.0000 vs
worksheet (249) = 136.3500 = 1.5 × 0.4 × 227.25 exactly. Default SFP
from footnote e matches; PCDB warm-air-unit SFP lookup deferred
until a fixture exercises it.
Closures electric 2:
pumps_fans_kwh_per_yr: 0 → 136.35 (EXACT match to worksheet)
ΔSAP +0.7002 → −0.1087 (residual swung past worksheet — the +0.70
pre-slice was an under-counted-fan offset; spec-correct fix lands
just past zero, exposing a small upstream SH cascade gap likely
in the Cat 5 warm-air HP Table 4a SH efficiency or Table 9c MIT
cascade for warm-air mains — follow-up slice)
Δcost −£16.14 → +£2.50
ΔCO2 −2.37 → +16.54 kg
ΔPE −108.58 → +97.69 kWh
No regressions on the other 24 cohort variants — the warm-air-code
gate fires only when `sap_main_heating_code` is in the new frozenset
and only electric 2 has a warm-air SAP code in the corpus. Extended
handover suite: 902 pass / 0 fail (was 901 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.87 → 2.30 (~20%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 2b note b) (PDF p.159) — verbatim:
Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems).
The parenthetical list restricts the rule to systems where the heat
generator (boiler / warm-air / HP) is the device heating the
cylinder. Electric immersion is NOT in that list because the
immersion isn't a heat-generator system feeding DHW — it sits inside
the cylinder. The ×0.9 multiplier reflects shorter cylinder-heating
periods when a boiler / HP / warm-air operates on a separate timer
for DHW vs SH; if the heat generator doesn't feed the cylinder at all
(because the immersion does), there's no such timing effect.
Pre-slice `_separately_timed_dhw` returned True for any Cat 4 HP
main BEFORE consulting WHC (line 3872 `if main.main_heating_category
== 4: return True`). For electric 2 (sap_main_heating_code=524 Cat 5
warm-air ASHP, main_heating_category=4 per Elmhurst mapper, WHC=903
electric immersion + cylinder + cylinder thermostat lodged), the
cat-4 branch fired before the existing `_is_electric_water` check
could route the cert to False. The cascade applied ×0.9 to the
Temperature Factor (53), pulling (55) from 1.2294 → 1.1064 → cascade
annual (56) = 403.87 vs worksheet (56) annual = 448.73.
Same WHC=903 principle as the prior slice S0380.156 (Table 3 zero-
loss list for electric immersion): when HW is independent of the
main heating, main-heating-specific DHW rules don't apply — even
when the main happens to be a HP / boiler / warm-air system.
Fix: new top-of-function `if epc.sap_heating.water_heating_code ==
_WHC_ELECTRIC_IMMERSION: return False` guard in
`_separately_timed_dhw`. Reuses the constant introduced in S0380.156.
Closures electric 2:
Cylinder (56) storage loss annual 403.87 → 448.73 (matches
worksheet 1.2294 × 365 = 448.73 EXACT within rounding)
HW kWh demand 2339.24 → 2384.12 (matches worksheet (62)/(64) =
2384.116 EXACT)
ΔSAP +0.8118 → +0.7002
Δcost −£18.71 → −£16.14
ΔCO2 −7.21 → −2.37 kg
ΔPE −161.68 → −108.58 kWh
The remaining +0.70 SAP residual is a separate upstream gap (likely
warm-air-HP SH cascade or Table 4a SH efficiency for code 524) —
follow-up slice.
No regressions on the other 24 cohort variants. Cohort-1 ASHP certs
(Cat 4 HP + WHC=901 = HW from HP + cylinder) keep ×0.9 as before
because their WHC=901 doesn't trigger the new guard. Extended
handover suite: 901 pass / 0 fail (was 900 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) verbatim:
Primary loss is set to zero for the following:
Electric immersion heater
Combi boiler ...
CPSU ...
Boiler and thermal store within a single casing
Separate boiler and thermal store connected by no more than 1.5
m of insulated pipework
Direct-acting electric boiler
Heat pump (...) with hot water vessel integral to package
The Elmhurst WHC=903 lodging signals exactly the first row: "HW from
a separate electric immersion heater" — the cylinder is heated by an
immersion element inside the tank, no primary pipework between any
heat generator and the cylinder. The rule is universal: regardless
of what main heating exists for space heating, electric immersion
means no primary circuit means no primary loss.
Pre-slice `_primary_loss_applies` only consulted `water_heating_code`
in the Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat
4 HP branch returned True unconditionally when no PCDB record was
lodged; the Cat 1/2 boiler branch returned True unconditionally; the
PCDB Table 322 + Table 4b non-PCDB branches likewise. For the
electric 2 corpus variant (sap_main_heating_code=524 Cat 5 warm-air
ASHP, main_heating_category=4 per Elmhurst mapper, no PCDB record,
WHC=903 + cylinder), the Cat-4 branch falsely returned True and the
cascade added ~510 kWh/yr primary loss to a system with no primary
circuit at all.
Per-line walk discipline applied: cascade `water_heating_from_cert`
output dump showed `primary_loss_monthly_kwh_annual = 509.98` while
worksheet (59)m = 0 every month → spec lookup found Table 3 verbatim
"Electric immersion heater" zero-loss line.
Adds `_WHC_ELECTRIC_IMMERSION: Final[int] = 903` constant + a
top-of-function `if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False` guard that fires before any of the system-type-keyed
branches.
Closures electric 2:
HW kWh 2849.22 → 2339.24 (matches worksheet (62)/(64) = 2384.12
within the residual ~45 kWh storage-loss gap)
ΔSAP −0.4584 → +0.8118 (cascade swung past the worksheet by +1.27
— the pre-slice 'near-correct' value was offsetting cascade bugs
per [[feedback-software-no-special-handling]]; the +0.81 residual
exposes a separate upstream gap to chase in a follow-up slice)
Δcost +£10.56 → −£18.71
ΔCO2 +47.89 → −7.21 kg
ΔPE +443.13 → −161.68 kWh
No regressions on the other 24 cohort variants — only electric 2 has
the (Cat 4 HP, no PCDB, WHC=903) combination in the corpus.
Extended handover suite: 900 pass / 0 fail (was 899 — +1 from the
new AAA test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Factual staleness fix flagged in the handover; the calculator lives in
domain/sap10_calculator/calculator.py. Glossary term 'Baseline Performance'
deliberately left unchanged (concept vs PropertyBaselinePerformance class).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This branch's objective is the SAL ingestion handler
(applications/SAL/handler.py) and its dependency tree. Drop work
that crept in but is unreferenced by it:
- EPC feature: domain/epc, infrastructure/epc (gov_uk + historical
clients), tests/infrastructure/epc
- datatypes/epc edits (instantaneous_wwhrs Optional) reverted to main
- asset_list/app.py local data-file/column tweak reverted to main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the composition with its entry point (the `ara_first_run` lambda +
`AraFirstRunTriggerBody`): clearer what the file does.
- orchestration/first_run_pipeline.py → ara_first_run_pipeline.py
- FirstRunPipeline → AraFirstRunPipeline; FirstRunCommand → AraFirstRunCommand
- test files renamed to match
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`property` is an FE-owned table the backend only ever reads — every row read
carries an id — so the autoincrement-PK `Optional[int]` idiom doesn't apply
here. Make it `int` and drop the now-redundant None guard in get_many.
(Contrast: solar_table keeps Optional id — the backend DOES insert those, so
id is genuinely None pre-flush.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the stored units explicit on the property_baseline_performance columns:
- `*_co2_emissions` → `*_co2_emissions_t_per_yr` (tonnes CO₂/yr, whole dwelling)
- `*_primary_energy_intensity` → `*_primary_energy_intensity_kwh_per_m2_yr`
Column names only; the domain `Performance` VO stays unit-suffix-free (units are
a storage concern, mapped in from_domain/to_domain). Migration doc updated.
Round-trip stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final slice of ADR-0012: collapse the per-property read round-trips a batch
made (Baseline hydrated ~8 queries x 30 properties one at a time) into a
handful of per-table IN queries.
- EpcPostgresRepository: extracted a shared `_compose(rows)` from `get` (the
windows + floor-dim fetches are now passed in, not fetched inline), so both
`get` and the new `get_for_properties(property_ids)` build EpcPropertyData
from pre-fetched rows. `get_for_properties` fetches each child table once
(`WHERE epc_property_id IN ...`), groups in memory, and composes — load-whole
per ADR-0002.
- PropertyRepository.get_many(property_ids) -> Properties: one query for the
property rows + one bulk EPC hydration, composed in input order.
- BaselineOrchestrator / IngestionOrchestrator read the batch via get_many
instead of N x get.
- Ports + fakes gain the bulk methods.
The #1129 round-trip fidelity test stays green (the compose extraction is
behaviour-preserving). New tests: bulk hydration correctness + round-trips are
constant w.r.t. batch size (one-per-table, proven by query count). 123 pass;
pyright strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the handler's whole-pipeline Session (one transaction across all
three stages, connection pinned during Ingestion's external IO) with a
Unit-of-Work per stage (ADR-0012, added here). Each stage runs its batch in
one unit and commits once; any property raising aborts the batch and the
subtask fails noisily.
- BaselineOrchestrator(unit_of_work, rebaseliner): one unit for the batch,
commit once. Raise on a pre-SAP10 property leaves the unit uncommitted.
- IngestionOrchestrator(unit_of_work, epc_fetcher, geospatial_repo,
solar_fetcher): fetch/write split — phase 1 fetches the whole batch (EPC /
coords / solar) with NO unit open; phase 2 writes in one unit and commits.
The connection is never held during external IO. Geospatial S3 repo stays
injected (reference data, not transactional).
- Handler: module-scoped engine (pool reused across warm invocations) + a UoW
factory; whole-pipeline `with Session` gone. `build_first_run_pipeline`
composes on the factory. Source clients still behind the raising seam.
- ADR-0012 records the decision (per-stage boundary, all-or-nothing batch,
idempotent re-run, fetch/write split, module-scoped engine). Modelling stub
left untouched (no-op, no DB) per the ADR.
Tests: orchestrators on a shared FakeUnitOfWork (assert persisted batch +
exactly-once commit + no-commit-on-raise). New real-DB E2E integration test:
real PostgresUnitOfWork, Ingestion writes the EPC → Baseline reads it back
through the repo → re-run replaces, not duplicates (1 EPC row, 1 baseline row
after two runs). 121 pass in tests/; pyright strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-runs of a First Run batch re-save a property's data; that must replace,
not duplicate (ADR-0012 idempotent batch writes).
- `EpcPostgresRepository.save` deletes the property's existing EPC graph
(parent + all child tables, floor-dims via their building parts) before
inserting, when a `property_id` is given. Anonymous saves still insert.
- `BaselinePostgresRepository.save` deletes the existing row for the
`property_id` before inserting — no more unique-constraint violation on
re-save; also what the re-score-on-override path needs.
- Solar already upserts, so it's unchanged.
The #1129 round-trip fidelity test stays green (delete-first is a no-op on
a first save). 2 new tests (re-save replaces, not duplicates). pyright
strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the per-stage batch-transaction refactor (ADR-0012). A
UnitOfWork is the single transaction a stage runs its batch in: a context
manager exposing the DB repos bound to one session, committing once on
`commit()` and rolling back on exception or exit-without-commit
(all-or-nothing per batch, fail noisily).
- `UnitOfWork` (port): `property` / `epc` / `solar` / `baseline` repos +
`commit()` / `rollback()`; `__exit__` rolls back uncommitted work.
- `PostgresUnitOfWork(session_factory)`: opens a Session from an injected
factory (a module-scoped engine + sessionmaker in prod, so the pool is
reused across warm invocations), binds the Postgres repos to it, closes
on exit.
Not yet wired into any orchestrator — that lands in the Baseline /
Ingestion refactor slices. 3 tests against ephemeral PG (commit durable
across units; exception rolls back; no-commit persists nothing). pyright
strict clean; AAA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the First Run spine. Replaces the #1130 stub FirstRunPipeline
with the real three-stage composition and wires it into the handler.
- `FirstRunPipeline.run(command)` sequences Ingestion → Baseline →
Modelling, threading **only** `property_ids` between stages (and
`scenario_ids` into Modelling, off the command — never a prior stage's
output). Stages are injected behind thin `IngestionStage` /
`BaselineStage` / `ModellingStage` Protocols (the EpcFetcher/SolarFetcher
idiom), so the handler owns wiring and tests substitute fakes (ADR-0011).
- `ModellingOrchestrator` stub + `ScenarioRepository` / `MaterialsRepository`
seam ports — `run(property_ids, scenario_ids)` reads through repos, does
no scoring yet. Method shapes deferred to the Modelling per-service grills
(Scenario / Scenario Phase / Snapshot / Optimised Package / Plans are rich
— not pre-empted here).
- Handler delegates to the real pipeline via `build_first_run_pipeline`
(Postgres-backed repos off the session). The Ingestion source clients
(EPC API / Google Solar / geospatial S3) are isolated behind one
`_source_clients_from_env` seam that raises until the deploy/Terraform
config settles — out of scope for this slice. Subtask complete/failed +
CloudWatch URL still come from `@subtask_handler`.
Integration test (the criterion's centrepiece): wires REAL Ingestion +
REAL Baseline + stub Modelling through a shared fake EPC repo, with a
repo-backed PropertyRepo composing the Property from that slice. Proves
Baseline reads the very EPC Ingestion persisted — the through-repos
hand-off, no in-memory coupling. Plus a composition test pinning stage
order + only-property_ids threading.
TDD, one test → one impl. pyright strict clean; AAA layout. 116 pass in
the tests/ tree, no regressions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 2 of First Run. Establishes each Property's Baseline Performance
from persisted source data and writes it back — reads only from repos,
never a Fetcher or HTTP (ADR-0003), so it is byte-identical whether
Ingestion ran milliseconds ago or last week.
Domain (`domain/baseline/`):
- `Performance` VO — the four rated quantities: SAP / EPC Band / CO2 /
Primary Energy Intensity. `lodged_performance(epc)` reads them off the
EPC's recorded fields (PEUI = `energy_consumption_current`).
- `BaselinePerformance` (ADR-0004) — the paired `lodged` + `effective`
Performance + `rebaseline_reason`, plus the no-derivation part of the
energy block (`space_heating_kwh` / `water_heating_kwh`, off the RHI,
deterministic per ADR-0006). Both halves always populated.
- `Rebaseliner` port + `StubRebaseliner`: the re-score-on-override seam
(ADR-0011). SAP10 certs pass through (effective == lodged, reason
"none"); a pre-SAP10 cert raises `RebaselineNotImplemented` rather
than fabricating a plausible-but-wrong "none" — ML rebaselining is not
wired yet. Mirrors the repo's strict-raise culture.
Persistence: new `BaselineRepository` port + `BaselinePostgresRepository`
+ flat-column `baseline_performance` SQLModel (one row per Property). Per
ADR-0004's amendment this is a standalone table, NOT columns on the
retiring `property_details_epc`. Production migration is FE-owned
(Drizzle) — docs/migrations/baseline-performance-table.md.
Docs (grill-with-docs): corrected CONTEXT.md Lodged/Effective Performance
to Primary Energy Intensity (the term collided with its own _Avoid_ entry
under "heat demand") + fixed stale RHI field names; amended ADR-0004
Consequences for the standalone-table decision.
Fuel split + bills (rest of EPC Energy Derivation) deferred to a
follow-up — they need a Fuel Rates source (Ofgem-cap ETL) that does not
exist yet.
TDD, one test -> one impl: 7 tests (lodged read, rebaseliner pass-through
+ raise, orchestrator establish-and-persist + pre-SAP10 raise, Postgres
round-trip + absent). pyright strict clean; AAA layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage-2 entry point for the First Run use case. Adds the
`ara_first_run` Lambda package mirroring the `postcode_splitter`
template, its typed trigger contract, and a stub `FirstRunPipeline`.
- `AraFirstRunTriggerBody`: thin command of five fields — `task_id`,
`sub_task_id` (UUID, lifecycle), `portfolio_id`, `property_ids`,
`scenario_ids` (int business IDs). No `model_config` override, so
Pydantic's default `extra="ignore"` lets the FastAPI backend add
fields without breaking deployed lambdas. UPRNs / Scenario defs are
deliberately off the event — read from source-of-truth tables.
- Thin `handler.py`: validate-and-delegate only, via a named
`dispatch_first_run` seam (testable without the Lambda runtime).
Subtask status (in-progress/complete/failed) + CloudWatch log URL
come for free from the existing `@subtask_handler()` decorator.
- `FirstRunPipeline` (orchestration/) stub: `run(command)` receives the
validated command. Declares a structural `FirstRunCommand` Protocol
(the three business fields) that `AraFirstRunTriggerBody` satisfies,
so orchestration needs no application-layer import — rhymes with the
`EpcFetcher`/`SolarFetcher` Protocols on IngestionOrchestrator
(ADR-0011). Full Ingestion→Baseline→Modelling composition lands in
#1136.
- Dockerfile / requirements.txt / local_handler/ mirror postcode_splitter.
TDD: 7 new tests (trigger-body validation incl. forward-compat +
id-types, pipeline seam, handler delegation). pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1 of the pipeline: per property, read its UPRN from the property row,
fetch its EPC, resolve coordinates from the Geospatial reference repo, thread
those into the Solar fetcher, and persist EPC + solar via repos. Fetchers never
call each other — the orchestrator threads the coordinate (ADR-0011). Coordinates
are reference data (deterministic from UPRN), resolved transiently to drive the
solar fetch rather than persisted per-property.
Depends on thin EpcFetcher/SolarFetcher Protocols (EpcClientService and
GoogleSolarApiClient satisfy them structurally). Unit-tested against fakes — no
DB, gov API, or network: persists EPC, threads coords into solar, skips
UPRN-less properties and skips solar when coordinates are absent. pyright clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Coordinates value object + GeospatialRepository port + GeospatialS3Repository
adapter. Resolves a Property's lon/lat from the partitioned Ordnance Survey
Open-UPRN parquet (filename_meta -> partition -> UPRN row). A Repo, not a
Fetcher (ADR-0011): no live OS API call. The parquet reader is injected, so it's
unit-tested against fixture parquets with no S3/network; returns None when the
UPRN is uncovered or absent. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the EpcClientService package (client + _retry + exceptions + tests) from
the dying backend/ tree to infrastructure/epc_client/ as the New-EPC-API Fetcher;
update the two callers (address2UPRN, a script). All 14 client tests pass.
Add SolarRepository port + SolarPostgresRepository persisting Google Solar
building insights as JSONB (solar_building_insights table), one row per Property.
The EPC repo half of this slice already landed in #1129. pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>