Commit graph

5853 commits

Author SHA1 Message Date
Jun-te Kim
8accb51383
Merge pull request #1142 from Hestia-Homes/feature/landlord_data
iam permissions for my lambda to import location
2026-06-02 09:43:32 +01:00
Khalim Conn-Kowlessar
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>
2026-06-02 08:34:20 +00:00
Khalim Conn-Kowlessar
561e1b8b49 feat(baseline): run Sap10Calculator in shadow on Property Baseline (ADR-0013)
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>
2026-06-02 08:01:47 +00:00
Khalim Conn-Kowlessar
8d465d973f Slice S0380.162: SAP 10.2 Appendix N3.1 default pump gain for electric HPs
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>
2026-06-01 23:59:29 +00:00
Khalim Conn-Kowlessar
482ce88b55 Slice S0380.161: SAP 10.2 Table 5a warm-air fan gain (SFP × 0.04 × V)
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>
2026-06-01 23:16:57 +00:00
Khalim Conn-Kowlessar
af34ad9846 Slice S0380.160: SAP 10.2 Table 5a wet-pump gate for central heating gain
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>
2026-06-01 23:02:42 +00:00
Khalim Conn-Kowlessar
0261f446f0 docs: handover post S0380.156..159
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>
2026-06-01 22:30:59 +00:00
Khalim Conn-Kowlessar
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>
2026-06-01 22:25:51 +00:00
Khalim Conn-Kowlessar
8843df1b46 Slice S0380.158: SAP 10.2 Table 4f warm-air heating system fans
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>
2026-06-01 21:56:03 +00:00
Jun-te Kim
04dc1b20fe iam permissions 2026-06-01 21:08:19 +00:00
Khalim Conn-Kowlessar
a2a4b6824a Slice S0380.157: SAP 10.2 Table 2b note b) WHC=903 electric-immersion guard
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>
2026-06-01 20:56:52 +00:00
Khalim Conn-Kowlessar
02092c8041 Slice S0380.156: SAP 10.2 Table 3 WHC=903 electric-immersion zero-loss guard
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>
2026-06-01 19:28:25 +00:00
Jun-te Kim
0a123c6723
Merge pull request #1122 from Hestia-Homes/feature/landlord_data
Feature/landlord data
2026-06-01 20:25:20 +01:00
Khalim Conn-Kowlessar
ce33cd94ef docs: correct SAP calculator path in CONTEXT (domain/sap → domain/sap10_calculator)
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>
2026-06-01 18:56:41 +00:00
Jun-te Kim
616744a606 Merge remote-tracking branch 'origin/main' into feature/landlord_data
# Conflicts:
#	datatypes/epc/schema/rdsap_schema_21_0_0.py
#	datatypes/epc/schema/rdsap_schema_21_0_1.py
2026-06-01 17:02:20 +00:00
Jun-te Kim
bf3b689f15 Remove EPC and asset_list changes unrelated to SAL handler
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>
2026-06-01 16:39:09 +00:00
Jun-te Kim
bdf703ea00 updated rdsap option; seperated s3 location in infrastrucutre; added open ai api 2026-06-01 16:33:14 +00:00
Jun-te Kim
754e6609fd standardist Address 2026-06-01 16:32:48 +00:00
Khalim Conn-Kowlessar
1ea71a3acb refactor(ara): rename FirstRunPipeline → AraFirstRunPipeline (PR #1139 review)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
d89983d44f refactor(property): PropertyRow.id non-Optional (PR #1139 review)
`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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
50914e8aae refactor(property-baseline): units on co2 / PEUI columns (PR #1139 review)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
457d959b1f refactor(property-baseline): rename baseline → property_baseline aggregate (PR #1139 review)
Wholesale rename of the Baseline aggregate to PropertyBaseline for clarity /
to disambiguate from baselines that appear elsewhere in Modelling. Scoped to
this aggregate only — the distinct Rebaselining term (rebaseline_reason,
StubRebaseliner, RebaselineNotImplemented) is deliberately untouched.

- domain/baseline → domain/property_baseline; BaselinePerformance →
  PropertyBaselinePerformance.
- repositories/baseline → repositories/property_baseline; BaselineRepository
  / BaselinePostgresRepository → PropertyBaseline*.
- orchestration/baseline_orchestrator.py → property_baseline_orchestrator.py;
  BaselineOrchestrator → PropertyBaselineOrchestrator. BaselineStage →
  PropertyBaselineStage.
- infrastructure/postgres: baseline_performance_table.py →
  property_baseline_performance_table.py; table `baseline_performance` →
  `property_baseline_performance`; Model renamed.
- UnitOfWork attribute `.baseline` → `.property_baseline`.
- Docs: ADR-0004 references + migration doc (renamed to
  property-baseline-performance-table.md) updated.

CONTEXT.md glossary term ("Baseline Performance") left as-is pending a
ubiquitous-language call (raised on the PR). 123 tests pass; pyright strict
clean (only the unrelated pre-existing moto import errors remain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
d2d008f5c5 perf(repos): bulk get_many / get_for_properties — batch reads, not N round-trips (#1138)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
7275850c9e refactor(orchestration): wire stages onto the UnitOfWork; per-stage commit (#1138)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
fa5224b6ed feat(repos): idempotent EPC + Baseline writes (replace by property_id) (#1138)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
5524385984 feat(uow): UnitOfWork port + PostgresUnitOfWork adapter (#1138)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
61846665b1 feat(first-run): FirstRunPipeline E2E — Ingestion → Baseline → Modelling (#1136)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
9f22b0aae8 feat(baseline): BaselineOrchestrator + BaselinePerformance aggregate (#1135)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
a910ce9855 feat(ara): AraFirstRunTriggerBody + ara_first_run lambda skeleton (#1130)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
454456bf22 feat(ingestion): IngestionOrchestrator end-to-end (#1134)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
285e7f8824 feat(geospatial): GeospatialRepo — OS Open-UPRN coordinate lookup (#1131)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
5a3be9d672 feat(ingestion): relocate EpcClientService to infrastructure + SolarRepo (#1133)
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>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
460970af39 feat(property): Property aggregate + PropertyRepository (#1132)
Add the Ara modelling aggregate root (ADR-0002): domain/property/ with
PropertyIdentity, SiteNotes, Property, Properties. Property.source_path
implements the two disjoint source paths + Recency Tie-Break (ADR-0001;
survey wins on an equal date); effective_epc resolves to the surveyed data
(Site Notes path) or the public EPC (epc_with_overlay path — Landlord
Overrides overlay is a later slice). Pure dataclasses, no infrastructure imports.

PropertyRepository port + PropertyPostgresRepository hydrate the aggregate
whole from a defensive view of the FE-owned 'property' table (identity columns)
plus the EPC slice via EpcRepository.get_for_property. Reads only from repos
(ADR-0003). 8 domain + 1 hydration test; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
3e1d3acfbf feat(epc): persist renewable_heat_incentive — full round-trip equality (#1137)
Add epc_renewable_heat_incentive table (space_heating_kwh, water_heating_kwh +
the three insulation-impact kWh fields), wired into EpcPostgresRepository
save/get. This is the P0 gap: RenewableHeatIncentive carries the baseline
space-heating/hot-water kWh that EPC Energy Derivation consumes.

The round-trip test now asserts full deep-equality (dropped the
renewable_heat_incentive exclusion) and passes for RdSAP 21.0.0 + 21.0.1.
DB migration for the new table documented in
docs/migrations/epc-property-round-trip-fidelity.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
559616d3bb feat(epc): EPC persistence round-trip fidelity + JSONB code columns (Slice 1 #1129)
Relocate EpcPropertyModel + child tables from the dying backend/ tree to
infrastructure/postgres/epc_property_table.py (re-export shim keeps
documents_parser working). Add EpcRepository port + EpcPostgresRepository with
a full reverse mapper (epc_property tables -> EpcPropertyData).

Round-trip test surfaced two fidelity gaps:
 1. Union[int,str] SAP code fields were str()-coerced on save, losing the int
    (API) vs str (Site Notes) distinction. Now stored as JSONB (type-preserving).
 2. The schema was a partial projection. Closed the cheap gaps on the model
    (heating shower/bath counts, roof_construction_type, curtain_wall_age,
    addendum, mechanical_vent_duct_insulation_level, SAP 10.2 §2 ventilation
    fields + a ventilation_present flag). Structural gaps tracked as follow-ups;
    renewable_heat_incentive (P0, #1137) excluded from the assertion until landed.

Round-trip passes for RdSAP-Schema-21.0.0 and 21.0.1; pyright strict clean.
Migration inventory for the DB: docs/migrations/epc-property-round-trip-fidelity.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
8291f29721 docs(ara): composable stage-orchestrator design (ADR-0011 + ADR-0003 amend + CONTEXT)
Records the grill-with-docs outcomes for the ara_first_run rebuild: three
composable stage orchestrators (Ingestion/Baseline/Modelling), one lambda per
use case chaining them through repos (not in-memory), and the Fetcher-vs-Repo
data-source taxonomy. Amends ADR-0003's chaining rule to generalise beyond
RefreshOrchestrator. Adds the pipeline-composition + First Run vocabulary to
CONTEXT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
ca6a0efd70 Slice S0380.155: SAP 10.2 Table 4a — heat-pump water-efficiency column dispatch
SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency into
two columns — "space" and "water":

    Code  System                                            space  water
    211   Ground source HP with flow temp <= 35°C            230    170
    213   Water source HP with flow temp <= 35°C             230    170
    215   Gas-fired GSHP with flow temp <= 35°C              120     84
    216   Gas-fired WSHP with flow temp <= 35°C              120     84
    217   Gas-fired ASHP with flow temp <= 35°C              110     77
    521   Warm-air electric GSHP                             230    170
    523   Warm-air electric WSHP                             230    170
    525   Warm-air gas-fired GSHP                            120     84
    526   Warm-air gas-fired WSHP                            120     84
    527   Warm-air gas-fired ASHP                            110     77

The split reflects real physics: heat pumps lose efficiency raising
water to ~55°C DHW temperatures vs ~35°C space-heating flow. ASHP
"in other cases" (codes 214, 221, 223, 224) and the "other cases"
gas-fired rows (225-227) have space == water = 170 / 84 / 77 — no
distinct DHW column.

Pre-slice the cascade routed WHC ∈ {901, 902, 914} ("HW from main
heating") through `seasonal_efficiency(main_code)`, which only consults
the Space column. For SAP code 211 the cascade returned 2.30 (= space)
when the spec requires 1.70 (= water). HW fuel kWh undercounted by
26% on the heating-systems corpus gshp variant: cascade 841.47 kWh vs
worksheet 1138.46 kWh.

New `_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY` dict (10 codes where Space
≠ Water) consulted in `_water_efficiency_with_category_inherit` before
falling through to the existing `seasonal_efficiency` path. Codes
where Space == Water keep the legacy inheritance — no behaviour
change. Non-HP main heating (boilers, storage heaters) likewise
unchanged.

Closures (gshp variant — SAP code 211 + WHC=901 + cylinder):
  HW fuel kWh:  841.47 → 1138.45 (matches worksheet 1138.46)
  ΔSAP_c:       +0.9373 → -0.0178
  Δcost:        -£21.60 → +£0.41
  ΔCO2:         -34.98  → +7.06 kg/yr
  ΔPE:          -418.92 → +33.52 kWh/yr

No regressions on 40 other corpus variants — gshp is the only fixture
that lodges a heat-pump code with diverging Space/Water columns.

Cohort-1 ASHP closure (S0380.28 reciprocal interpolation) is unaffected
because that path runs through `heat_pump_record` PCDB Appendix N3
when a PCDB Table 362 record is lodged; this fix is the Table 4a
fallback for cases without a PCDB record.

Extended handover suite: 899 pass / 0 fail. Pyright net-zero (43 → 43).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
981aaadf73 Slice S0380.154: SAP 10.2 §12.4.4 — back-boiler summer-immersion HW split
SAP 10.2 §12.4.4 (PDF p.36-37):

  "Independent boilers that provide domestic hot water usually do so
   throughout the year. With open fire back boilers or closed room
   heaters with boilers, an alternative system (electric immersion)
   may be provided for heating water in summer. In that case water
   heating is provided by the boiler for months October to May and by
   the alternative system for months June to September."

Scope is verbatim Table 4a codes 156 (Open fire with back boiler to
radiators) and 158 (Closed room heater with boiler to radiators). Range
cooker boilers (160, 161), pellet stoves with boilers (159), and
independent solid-fuel boilers (151, 153, 155) are NOT covered.

Pre-slice, the cascade treated the back-boiler cohort identically to
year-round solid-fuel mains: (59)m primary loss applied Jun-Sep, HW
fuel kWh was billed entirely at the boiler's solid-fuel rate, the HW
CO2 / PE factors used the boiler fuel's annual factor, and the off-peak
electric standing charge (£40 for 18-hour tariff) was not added because
the cert's lodged water-heating fuel code was anthracite.

Implementation (4 wired pieces):

1. `_section_12_4_4_summer_immersion_applies(epc, main)` — predicate
   gate keyed on back-boiler SAP code (156, 158) + WHC ∈ {901, 902, 914}
   "HW from main heating" + cylinder present.

2. `_primary_loss_override` zeroes (59)m for Jun-Sep when the predicate
   fires — matches the Elmhurst P960 worksheet which has (59) Jun-Sep =
   0 for SF2 (vs ~42 kWh/month for SF3 range cooker).

3. `_section_12_4_4_hw_blend(...)` — returns the 5-tuple
   (annual_hw_fuel_kwh, blended_cost_gbp_per_kwh, blended_co2_factor,
   blended_pe_factor, extra_standing_charge_gbp). The blend is kWh-
   weighted across:
   - Winter Oct-May: boiler fuel at the boiler's Table 32 unit price /
     Table 12 annual CO2 / Table 12 annual PE factor
   - Summer Jun-Sep: standard electricity (Table 12d/12e monthly
     factors weighted by summer (62)m demand) priced at the tariff's
     off-peak low rate per Table 13 note 2 (the 6.8 - 0.036V × N -
     0.105V dual-immersion formula clamps to zero high-rate for
     normal V/N combos on tariffs with ≥18 hrs low rate; SF2 has
     V=110, N≈2 → 100% low-rate)
   - The Table 32 off-peak electric standing charge that fires when
     hot water uses off-peak electricity per Table 12 note (a). For
     EIGHTEEN_HOUR tariff this is Table 32 code 38 = £40.

4. Orchestrator (`cert_to_inputs`) resolves the blend once and overrides
   `hot_water_kwh_per_yr`, `hot_water_fuel_cost_gbp_per_kwh`,
   `hot_water_co2_factor_kg_per_kwh`, `hot_water_primary_factor`, and
   `standing_charges_gbp` when the predicate fires. Other certs fall
   back to the existing single-fuel HW helpers (no behaviour change).

Worksheet evidence (heating-systems corpus property 001431 SF2 — code
158 + WHC=901 + cylinder thermostat + 18-hour tariff):
  - (62) Oct-May = 2205.80 kWh, Jun-Sep = 684.55 kWh
  - (217)m = 65 winter / 100 summer, (219) = 3393.5 anthr + 684.55 elec
    = 4078.06 fuel kWh
  - (247) HW cost = 4078.06 × 4.27 p/kWh blended = £174.25
  - (251) Standing = £40 (off-peak electric standing only — solid fuel
    has no standing charge)
  - (255) Total = £801.13

Closures (SF2):
  ΔSAP_c   +1.86 → -0.0000  (EXACT)
  Δcost   -£42.84 → -£0.00  (EXACT)
  ΔCO2  +346.87  → -93.10 kg/yr (residual: Elmhurst CO2 blend uses a
                                  different summer-month weighting that
                                  the SAP 10.2 Table 12d cascade does
                                  not reproduce — spec-correct per
                                  Table 12d header).
  ΔPE   -605.76  → -1027.51 kWh/yr (same spec-vs-Elmhurst PE blend
                                     artifact via Table 12e monthly
                                     cascade).

No regressions: 40/41 corpus variants unchanged (gate is narrow by SAP
code 156/158). Extended handover suite 898 pass / 0 fail. Pyright net-
zero (43 → 43).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
0001c7d11f Slice S0380.153: SAP 10.2 Table 3 — not-separately-timed DHW for solid-fuel boilers
SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off
the DHW timing arrangement, the middle row giving winter h=5 / summer
h=3 for "Cylinder thermostat, water heating NOT separately timed".

Solid-fuel boiler systems (Table 4a codes 151-161 — independent boilers,
open-fire + back boilers, closed room heaters with boilers, range cooker
boilers, stoves with boilers) do not ship with dual programmers. Per
SAP 10.2 §9.2.4 (PDF p.27) these are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler" — the
appliance itself is the timer. DHW timing follows the burn schedule,
not a separate cylinder programmer, so the middle Table 3 row applies.

Pre-slice `_separately_timed_dhw` returned True for any cylinder +
non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel
boilers through h=3 year-round (the third row, "Cylinder thermostat,
water heating separately timed"). That under-counted winter (59)m
by ~21 kWh/month × 8 winter months across the affected cohort, with
the under-counted water-heating gain propagating into MIT / SH / SAP.

New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES`
(frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before
the existing cylinder-present fallback. The post-S0380.140 electric-
immersion / heat-pump / no-main branches are unchanged. Table 4b
liquid-fuel boilers (101-141) keep the True default — modern gas/oil
installations standardly include dual programmers and the worksheet
confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at
h=3 year-round.

Worksheet evidence (heating-systems corpus property 001431):
  - solid fuel 3 (SAP code 160 range cooker boiler + WHC=901
    cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0)
    and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30
    → −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact).
  - solid fuel 2 (SAP code 158 closed room heater + back boiler):
    same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP
    is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers
    (codes 156, 158) — the worksheet has summer (59)m = 0 because the
    Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes
    Jun-Sep HW through an electric immersion at η=100%. That's a
    bigger lift (monthly HW efficiency + fuel-split plumbing) and is
    a follow-up slice.

Other corpus variants: no impact (verified via cohort sweep). The
gate is narrow by SAP code so only the 2 affected variants move.

Extended handover suite: 897 pass / 0 fail (+1 from new AAA test).
Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData`
import on the new test's `_cylinder_epc_for` return annotation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
e59b10e971 docs: handover post S0380.150..152
Three slices closed:
- S0380.150 18-hour tariff for pumps+lighting (§12 + App F2)
- S0380.151 RdSAP 10 §4.1 Table 5 extract-fans default
- S0380.152 Table 3 primary loss for solid-fuel back-boilers

Cluster A closed; Cluster B partial (SF3 done, SF2 partial); Cluster
C open. Σ|ΔSAP| 14.5 → 6.4 across the 25 cascade-OK cohort variants.

Mid-session pivot documented: my Cluster B hypothesis was wrong
(Table 9c step 12), the actual gap was Table 3 primary loss for
solid-fuel boilers. Discipline added: dump per-line worksheet data
before forming a spec hypothesis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
a92b134237 Slice S0380.152: SAP 10.2 Table 3 — primary loss for solid-fuel back-boilers
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:

  "Primary circuit loss applies when hot water is heated by a heat
   generator (e.g. boiler) connected to a hot water storage vessel
   via insulated or uninsulated pipes (the primary pipework)."

The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:

  - WHC=901/902/914 (HW from main heating system) + wet boiler +
    cylinder → primary loss applies (back-boiler / wet boiler heats
    cylinder via primary loop).
  - WHC=903 (HW from a separate electric immersion / secondary) → no
    primary loss even when the main is a wet boiler.

Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.

Worksheet evidence on the 001431 corpus (all age G, same cylinder):
  - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr   → apply
  - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr   → apply
  - solid fuel 5 (code 153, WHC=903): ws (59) = 0            → skip
  - solid fuel 4..11 (633/636 non-boilers, WHC=903): skip

The fix:
  - `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
    parameter (default None for back-compat with synthetic tests).
  - New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
    + `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
  - Call site `_primary_loss_override` passes
    `epc.sap_heating.water_heating_code`.

Heating-systems corpus impact:
  - solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
                                       PE -918.6 → -214.3 kWh/yr
  - solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
                                       PE -1241.7 → -754.1 kWh/yr
  - All other variants: unchanged

SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).

Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
2fea9c4ff5 Slice S0380.151: RdSAP 10 §4.1 Table 5 — extract-fans age-band default
RdSAP 10 Specification §4.1 Table 5 "Ventilation parameters" (PDF p.28)
verbatim — "Extract fans" entry:

  • Number of extract fans if known
  • If number is unknown:
      Not park home:
        Age bands A to E      all cases             → 0
        Age bands F to G      all cases             → 1
        Age bands H to M      up to 2 hab. rooms    → 1
                              3 to 5 hab. rooms     → 2
                              6 to 8 hab. rooms     → 3
                              more than 8 hab. rooms → 4
      Park home:
        Age band F            all cases             → 0
        Age bands G onwards   all cases             → 2

The Elmhurst Summary §12.0 renders "No. of intermittent extract fans: 0"
as the form for *unknown*; every other §2 chimney/flue line item follows
"number if known, or 0 if not present" and the cascade trusts the lodged
value verbatim. Only extract fans have a non-zero age-band default.

Pre-slice the cascade read the lodged 0 verbatim → cohort-wide -0.044
ACH ventilation deficit (= -2.6 W/K HLC, = -1.2% SH demand, = ~-0.3 SAP
per variant). All 25 cascade-OK corpus variants are age G + 4 habitable
rooms + not park home → Table 5 default = 1 fan.

New helper `_rdsap_extract_fans_default(age_band, habitable_rooms, *,
is_park_home)` + wiring in `ventilation_from_cert` applies
`max(lodged, table_5_default)` so the spec minimum fires when lodging
is below it.

Heating-systems corpus impact (25 cascade-OK variants):

  oil 1, oil pcdb 1/2/3            +0.27..+0.29 → EXACT (<1e-4)
  electric 1, solid fuel 5/6/7/8   +0.28..+0.43 → EXACT
  pcdb 1, ashp                     +0.41 / +0.18 → ±0.02
  electric 3/6/7/8/9, sf 4/9/10/11 +0.39..+0.60 → +0.08..+0.12
  electric 5                       -0.74 → -1.18  (Cluster B over-shoot)
  electric 2                       -0.24 → -0.46  (Cluster C HW gap)
  gshp                             +1.09 → +0.94  (Cluster C HW gap)
  solid fuel 2/3                   +3.08 / +1.76  → +2.77 / +1.31

Cluster A (cohort-wide HLC deficit) is closed. The four remaining open
fronts (Clusters B + C) are now visible without offsetting bugs:
  - Cluster B (Table 9c step 12 R sign): electric 5, solid fuel 2/3
  - Cluster C (HW kWh cascade): gshp + electric 2 (Appendix N3)
                                solid fuel 2/3 (Table 4b HW efficiency)

Golden-fixture re-pins:
  cert 0240 (age J, TFA 118): PE +2.18 → +5.80, CO2 +0.13 → +0.32
  cert 0390-2954 (age F, TFA 360): PE -28.27 → -27.97, CO2 -2.74 → -2.71

Pyright net-zero (44 → 44). Extended handover suite: 893 → 895 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
f5e3c1bcea Slice S0380.150: SAP 10.2 §12 / Appendix F2 — 18-hour high-rate for pumps + lighting
SAP 10.2 §12 (PDF p.45 lines 2280-2283):

  "The 18-hour tariff is only for use with electric CPSUs with
   sufficient energy storage to provide space (and possibly water)
   heating requirements for 2 hours. Electricity at the low-rate price
   is available for 18 hours per day, with interruptions totalling 6
   hours per day, with the proviso that no interruption will exceed 2
   hours. The low-rate price applies to space and water heating, while
   electricity for all other purposes is at the high-rate price."

SAP 10.2 Appendix F2 (PDF p.63 lines 3809-3812):

  "F2 Electric CPSUs using 18-hour electricity tariff. The 18-hour
   low rate applies to all space heating and water heating provided
   by the CPSU. The CPSU must have sufficient energy stored to provide
   heating during a 2-hour shut-off period. The 18-hour high rate
   applies to all other electricity uses."

Table 12a Grid 2 omits 18-hour / 24-hour from its 7-hour / 10-hour
table; pre-slice the cascade's `_other_fuel_cost_gbp_per_kwh` fell
through Grid 2's `NotImplementedError` to
`prices.standard_electricity_p_per_kwh` (Table 32 code 30 = 13.19
p/kWh). Per §12 + Appendix F2 the 18-hour rule is explicit fraction =
1.0 at the high rate — pumps, fans, and lighting bill at the 18-hour
high rate (Table 32 code 38 = 13.67 p/kWh).

All 41 heating-systems corpus variants lodge `meter_type='18 Hour'`,
so this gap was cohort-wide. Pre-slice the cascade undercounted
pumps + lighting cost by (13.67 − 13.19) × kWh on every variant:

  oil 1            Δcost -£9.31 → -£6.69   (closed £2.62, pumps 265 +
                                            lighting 282 × £0.0048)
  oil pcdb 1/2     Δcost -£8.32 → -£6.29   (closed £2.03)
  oil pcdb 3       Δcost -£8.91 → -£6.29   (closed £2.62)
  pcdb 1           Δcost -£11.10 → -£9.07  (closed £2.03)
  ashp             Δcost -£5.57 → -£4.22   (closed £1.35, lighting only)
  electric 1..9    Δcost shift ~ -£1.35..+£1.35  (lighting only;
                                                  storage / room-heater
                                                  certs carry pumps_fans
                                                  = 0)
  solid fuel 4..11 Δcost ~ -£1.55 (lighting only)
  gshp             Δcost -£26.48 → -£25.12 (closed £1.35)

Pyright net-zero (43 → 43). Extended handover suite: 892 → 893 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
b5709be2dc docs: handover post S0380.146..149
Captures the four slices that closed the oil-cohort Table 4f gap:
.146 primary loss for Table 4b regular boilers, .147 Eq D1 for
non-PCDB Table 4b, .148 liquid fuel boiler aux 100 kWh, .149
per-pump-age circulation + wet-boiler gate.

Documents the cohort-wide ~-£10/yr cost residual that S0380.149's
spec correctness exposed — the new next-slice front. Highlights the
user directive [[feedback-software-no-special-handling]] that
surfaced during S0380.147 and continued to apply through .149.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
0728aa1039 Slice S0380.149: Table 4f — circulation pump dispatch by pump age + wet-boiler gate
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" — Heating system circulation pump rows:

  Circulation pump, 2013 or later                 41 kWh/yr
  Circulation pump, 2012 or earlier              165 kWh/yr
  Circulation pump, unknown date                 115 kWh/yr

Pre-slice the cascade hardcoded `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY[2]
= 160 kWh/yr` (115 Unknown CH + 45 gas flue fan) for category=2 gas
boilers and fell through to `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130`
for any other category. Both shortcuts ignored the per-cert
`central_heating_pump_age` lodging AND incorrectly applied
circulation pump electricity to dry electric storage / direct-acting
/ room heater systems (no primary water loop).

Implementation:

  - Mapper: `_elmhurst_pump_age_int` now recognises both "Pre 2013"
    and "2012 or earlier" string forms as the SAP10 enum 1 (Pre 2013).
    Pre-slice "2012 or earlier" silently returned 2 (2013 or later)
    on the entire oil corpus, mis-applying the 41 kWh post-2013
    circulation pump to certs that lodge "2012 or earlier" via
    Elmhurst Summary §14 "Heat pump age".
  - New `_is_wet_boiler_main(main)` gate: identifies wet-boiler
    systems by Table 4a/4b code range (101-141 gas/oil, 151-161
    solid fuel, 191-196 electric boilers), PCDB Table 322 record,
    or category ∈ {1, 2} fallback. Heat pumps (cat 4) return False
    per Table 4f note "Not applicable for electric heat pumps from
    database". Electric storage / direct / room heater codes
    (401-499, 601-699) return False — they have no primary loop.
  - New `_table_4f_circulation_pump_kwh(main)` dispatches on
    `central_heating_pump_age`:
        None / 0 → 115 kWh (Unknown date)
        1        → 165 kWh (Pre 2013 / 2012 or earlier)
        2        →  41 kWh (2013 or later)
  - New `_table_4f_main_1_gas_boiler_flue_fan_kwh(main)` extracts
    the gas-flue-fan 45 kWh logic from the old category dispatch.
    Gated on `_is_wet_boiler_main` + gas fuel + fan_flue_present.
  - Remove `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` and
    `_DEFAULT_PUMPS_FANS_KWH_PER_YR` constants (the new helpers
    replace both).

Worksheet evidence for the wet-boiler gate:

  electric 1 (code 191 electric boiler):   ws (230c) = 41 kWh ✓
  electric 5 (code 402 electric storage):  ws (231)  =  0 kWh ✗
  solid fuel 2 (code 158 anthracite):      ws (230c) = 41 kWh ✓
  solid fuel 9 (code 636 wood stove):      ws (231)  =  0 kWh ✗
  oil 1 (code 127 condensing oil):         ws (230c) = 165 kWh ✓
  oil pcdb 3 (PCDB 18573):                 ws (230c) = 41 kWh ✓

Cascade impact across heating-systems corpus (vs S0380.148 state):

  | Variant        | SAP Δ        | Cause |
  |----------------|--------------|-------|
  | oil 1          | +0.60→+0.40  | 165 + 100 = 265 ≡ worksheet exact |
  | oil pcdb 1/2   | -0.15→+0.36  | 41 + 100 = 141 ≡ ws exact |
  | oil pcdb 3     | +0.59→+0.39  | same |
  | pcdb 1         | -0.03→+0.50  | 41 + 100 = 141 ≡ ws (was over) |
  | electric 1     | -0.06→+0.45  | 41 (wet electric boiler) |
  | electric 3-9   | -0.1..-1.4→  | 0 (dry storage/UFH) |
  |                | +0.5..+0.6   | was 130 default; now 0 |
  | solid fuel 2-8 | various      | 41 (boilers) — partial closures |
  | solid fuel 9-11| -0.2→+0.5    | 0 (room heaters) — was 130 |

Re-pins reflect spec-correct application. Per
[[feedback-software-no-special-handling]]: pre-slice near-zero pins
were masking pre-existing offsetting cascade gaps; spec correctness
unmasks them.

Golden fixtures impact:

  - cert 0240 (dual oil combi, pump_age=0 Unknown): PE +2.52→+2.18
  - cert 0390 (Firebird PCDF oil, pump_age=0): PE -28.08→-28.27
  - cert 6035 (gas combi, pump_age=2 post-2013): PE +47.29→+46.42

Cert 6035 closer to zero (post-2013 41 kWh < pre-slice 115 unknown).
Cert 0240/0390 small shifts from removing the gas-cat-2 hardcoded
160 path for oil mains.

Tests:
  - test_sap_table_4f_circulation_pump_dispatches_per_central_heating_
    pump_age — asserts oil 1 inputs.pumps_fans_kwh_per_yr == 265
    (165 Pre 2013 + 100 liquid fuel) ± 1.0.
  - test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
    100_kwh (S0380.148) still passes.

Extended handover suite: 892 pass, 0 fail. Pyright net-improved
(removed unused `main_category` variable, file 33→32 errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
3f68ec1f0d Slice S0380.148: Table 4f — liquid fuel boiler flue fan and fuel pump (100 kWh/yr)
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" row:

  Liquid fuel boiler — flue fan and fuel pump   100 kWh/yr  c) d)

Note c): "Applies to all liquid fuel boilers that provide main heating,
but not if boiler provides hot water only. Where there are two main
heating systems include two figures from this table."

Pre-slice the cascade's `_table_4f_additive_components` only wired:
  - (230a) MEV / MVHR
  - (230e) Main 2 gas-boiler flue fan (45 kWh)
  - (230g) Solar HW pump

The liquid-fuel sibling row was missing — oil 1 worksheet (230d) and
oil pcdb 3 worksheet (230d) both lodge 100 kWh/yr "oil boiler pump"
that the cascade was silently skipping.

Implementation:

  - Add `_LIQUID_FUEL_CODES = frozenset({4, 71, 73, 75, 76})` and new
    `is_liquid_fuel_code(fuel_code)` helper in
    `domain/sap10_calculator/tables/table_32.py`. Mirror of
    `is_electric_fuel_code` — routes through `_to_table_32_code`
    normalisation so Elmhurst-derived Table 32 codes (e.g. code 23
    = bulk wood pellets, solid) don't collide with API enum codes
    (where 23 = B30D community).
  - Extend `_table_4f_additive_components` to add 100 kWh for Main 1
    when `is_liquid_fuel_code(main.main_fuel_type)` returns True
    (`isinstance(int)` guard for the `Union[int, str]` field). Mirror
    the same gate for Main 2 per Note c) "Where there are two main
    heating systems include two figures".
  - LPG is GAS (Table 4b/4f convention, Ecodesign classification) —
    `_LIQUID_FUEL_CODES` deliberately excludes 2/3/5/9 LPG codes.

Cascade impact across heating-systems corpus:

  | Variant   | SAP Δ       | Cost Δ      | PE Δ        |
  |-----------|-------------|-------------|-------------|
  | oil 1     | +1.18→+0.60 | -£27→-£14   | -276→-124   |
  | oil pcdb 1| +0.42→-0.15 |  -£10→+£3.4 |  -84→+67    |
  | oil pcdb 2| +0.42→-0.15 |  -£10→+£3.4 |  -84→+67    |
  | oil pcdb 3| +1.16→+0.59 | -£27→-£14   | -271→-120   |
  | pcdb 1    | +0.57→-0.03 | -£13→+£0.6  | -109→+42    |

Cohort closures: pcdb 1 EXACT (-0.03), oil pcdb 1/2 closed to -0.15.

Golden fixtures impact:

  - cert 0240 (dual-main oil combi 130): SAP integer 73→72 (resid
    +0→-1), PE +1.02→+2.52, CO2 +0.11→+0.14. Dual-main certs add
    2 × 100 = 200 kWh aux per Note c). Cert's published SAP 73
    suggests the dual-main Q_space split (main_heating_fraction)
    may also need wiring — slice candidate.
  - cert 0390 (Firebird PCDF 9005 oil combi): PE -28.50→-28.08
    (CLOSER to zero), CO2 -2.75→-2.73 (CLOSER to zero), SAP +7
    unchanged.

Test:
  test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
  100_kwh — asserts oil pcdb 3 inputs.pumps_fans_kwh_per_yr ≥ 230
  (130 base + 100 liquid fuel boiler aux).

Extended handover suite: 891 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
c9c418be64 docs: handover post S0380.146..147
Captures the two slices that closed oil 1 from +2.66 → +1.18 SAP via
Table 3 primary-loss extension (.146) + Appendix D §D2.1 (2) Equation
D1 wiring for non-PCDB Table 4b boilers (.147). Highlights the user
directive that surfaced this session ("BRE/Elmhurst software follows
spec exactly; no special non-spec handling") and the resulting pin
shifts on cert 0240 + 6035 (combi-no-cylinder golden fixtures
re-pinned per spec correctness).

Ranks next-slice candidates: oil 1 Table 4f auxiliary energy (~+0.4
SAP closure remaining), electric 5 -1.43 regressed by .145, solid
fuel 2/3 anthracite outliers, community heating + electric storage
unblocking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
54ed142512 Slice S0380.147: Appendix D Eq D1 — Table 4b non-PCDB boilers (winter/summer monthly cascade)
SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):

  If the boiler provides both space and water heating, and the summer
  seasonal efficiency is lower than the winter seasonal efficiency,
  the efficiency is a combination of winter and summer seasonal
  efficiencies according to the relative proportion of heat needed
  from the boiler for space and water heating in the month concerned:

              Q_space + Q_water
  η_water,m = ───────────────────────────────
              Q_space/η_winter + Q_water/η_summer

  where Q_space (kWh/month) is the quantity calculated at (98c)m
  multiplied by (204) or by (205);
        Q_water (kWh/month) is the quantity calculated at (64)m;
        η_winter and η_summer are the winter and summer seasonal
        efficiencies (from Table 4b).

Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the
`pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB
Table 4b boilers (`sap_main_heating_code` 101-141) where the cert
lodges no `main_heating_index_number`, the cascade fell through to
the scalar `water_efficiency_pct` divisor — which resolved via WHC
901 inherit to Table 4b WINTER eff (wrong direction; spec wants the
monthly Eq D1 blend).

This slice:

  - Adds `domain/sap10_calculator/tables/table_4b.py` with the full
    41-row Table 4b (winter, summer) pair dict for codes 101-141
    verbatim from SAP 10.2 PDF p.168 (Table 4b).
  - Refactors `_apply_water_efficiency` parameter from
    `pcdb_record: Optional[GasOilBoilerRecord]` to
    `eq_d1_winter_summer_pct: Optional[tuple[float, float]]` —
    decouples the Eq D1 input from the PCDB record so a Table 4b
    fallback can populate it without faking a PCDB record.
  - Resolves Eq D1 inputs at the call site with priority order:
        1. PCDB Table 105 winter/summer (existing path)
        2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB
           absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form
           of "boiler provides both space and water heating").
    §9.4.11 -5pp interlock applies symmetrically to both columns of
    whichever (winter, summer) tuple is resolved.

Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May
79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with
Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) =
Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice.

Cascade impact:

  Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid):
    oil 1  SAP +1.76 → +1.18  (Δ -0.59)
           cost -£40.60 → -£27.12  (Δ +£13.48)
           CO2  -129.22 → -55.36   (Δ +73.86 kg/yr)
           PE   -590.02 → -275.52  (Δ +314.50 kWh/yr)
    Remaining oil 1 residual is Table 4f auxiliary energy (cascade
    pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler
    pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice.

  Golden fixtures (cert-pinned, integer-rounded PE):
    cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02
    cert 6035 (gas combi 104, no cylinder):      PE +46.10 → +47.29
    Both shifts reflect spec-correct Eq D1 now firing for non-PCDB
    combi-no-cylinder configs. The pre-slice near-zero pin on cert
    0240 was masking offsetting cascade gaps (likely Table 4f
    auxiliary energy and/or dual-main Q_space split per (98c)m ×
    (204) which the cascade currently treats as full demand).

Following [[reference-unmapped-sap-code]] discipline, the new Table
4b dict is the canonical spec-source — `domain.sap10_ml.sap_
efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for
the ML feature cascade and is left in place per the sap10_ml
deprecation plan (separate migration).

Test:
  test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_
  table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel
  annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)).

Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
8416e8c6e1 Slice S0380.146: Table 3 primary loss — Table 4b non-PCDB regular boilers with cylinder
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss":

  "Primary circuit loss applies when hot water is heated by a heat
   generator (e.g. boiler) connected to a hot water storage vessel via
   insulated or uninsulated pipes (the primary pipework). Primary loss
   is set to zero for the following:
       Electric immersion heater
       Combi boiler ...
       CPSU ..."

A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a cylinder is in neither zero-loss list, so primary loss must
apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies`
only covered PCDB Table 322 records (S0380.142) — when the cert lodges
a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil
boiler") with no PCDB index and no `main_heating_category` lodgement,
primary loss silently fell through to zero.

This slice extends the Elmhurst-path fallback in `_primary_loss_applies`
to fire when `sap_main_heating_code` is in the Table 4b code range
(101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3:

  Combi codes:  103, 104, 107, 108, 112, 113, 118, 128, 129, 130
  CPSU codes:   120, 121, 122, 123

Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 ×
[0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat +
separately timed DHW → h=3 winter & summer per Table 3 split). Annual
sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual.

Cascade impact on heating-systems corpus:
  - oil 1 SAP residual +2.66 → +1.76 (Δ -0.90)
         cost  -£61.24 → -£40.60 (Δ +£20.64)
         CO2   -242.27 → -129.22 (Δ +113.05 kg/yr)
         PE  -1050.49 → -590.02   (Δ +460.47 kWh/yr)

Only the oil 1 variant moves — every other cascade-OK variant either
already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/
2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil
codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for
free by the same dispatch when their certs surface in future cohorts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00
Khalim Conn-Kowlessar
97cbd7e93b docs: handover post S0380.141..145
Five slices closing pcdb 1 (+6.95→+0.57 via §9.4.11 + §4 cylinder
gates + RdSAP10 Table 29) and the electric storage cluster (e3/e6/e7
+2.5/+1.3 SAP → <0.21 each via Table 4e (92)m→(93)m). Cumulative
|ΔSAP| 18.0 → 12.2 (-32%). Open fronts ranked + spec-source index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:48 +00:00