Commit graph

6947 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
1382c8c886 docs: add AGENT_GUIDE.md — fresh-start onboarding for the SAP calculator
A single durable doc so agents can pick up the calculator without reading
historical handovers: (1) the accuracy bar for the two input paths
(site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5
register fallback otherwise; cross-mapper parity); (2) the per-line-walk
debugging loop incl. comparing site-notes vs API; (3) the tools &
pipeline (Summary PDF → extractor → from_elmhurst_site_notes →
cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API
from_api_response front-end, section helpers, and where the test vectors
live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as
point-in-time notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:32:29 +00:00
Khalim Conn-Kowlessar
72743eb8a4 S0380.188: D_PV,m uses lighting ELECTRICITY (L10) not the L12 gain — closes PV cohort to 1e-4
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
  D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.

Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).

Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:05:12 +00:00
Khalim Conn-Kowlessar
a5d886187c S0380.187: include electric secondary heating in Appendix M1 D_PV,m — closes gas+PV PE/CO2 gap
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV
generation by the monthly PV-eligible electricity demand D_PV,m. The cascade
included main and water electricity (when those fuels are electric) but had
no term for SECONDARY space heating. For the 10 cohort-2 gas-main +
electric-secondary + PV certs, the (215)m secondary electric fuel was dropped
from D_PV,m — understating demand in the heating months only, depressing the
monthly β, and under-crediting onsite PV primary energy.

Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric
space-heating demand; for a gas-main/electric-secondary dwelling that is the
secondary fuel. Diagnosis was decisive: E_PV (generation) matched the
worksheet exactly every month, the onsite (233a) split diverged ONLY in
heating months (Jun-Sep near-exact), and all 10 affected certs have PV while
all clean gas certs have none. Empirically adding (215)m to D_PV closed cert
3136 onsite 726.9 → 790.3 (worksheet 792.1).

Impact (calc − full-precision dr87 worksheet), the 10 certs:
  PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg.
The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers
unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant
on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy.

Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements).
2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:28:50 +00:00
Khalim Conn-Kowlessar
5f4a78e4c9 S0380.186: pin golden PE/CO2 against full-precision dr87 worksheets (47 certs)
The existing golden test compares calc PE/CO2 against the integer-rounded
lodged register values (energy_consumption_current / co2_emissions_current),
which conflates real calculator gaps with register rounding. This adds a
parallel pin against each cert's Elmhurst dr87 worksheet (286)/(272) at full
precision — a clean calculator-vs-Elmhurst signal for the 47 worksheet-backed
certs (9 ASHP + 38 cohort-2).

Findings at capture (calc − worksheet, on the worksheet's own decimal TFA):
  - 37/47 exact on both PE (<0.05 kWh/m²) and CO2 (<0.02 kg).
  - 10 higher-consumption gas certs carry PE +0.5..+1.5 kWh/m² AND
    CO2 -0.5..-1.1 kg simultaneously. PE-over + CO2-under on the same
    certs is the fingerprint of a small gas→electricity fuel-split
    difference (elec PE 1.51 > gas 1.13, but elec CO2 0.136 < gas 0.21),
    not a factor-value error — next slice candidate.

An earlier "41/47 PE gaps" reading was a JSON-integer-TFA division artifact;
comparing on the worksheet's decimal TFA (which the calculator also uses)
collapses it to the real 10. Worksheet values frozen as literals (the dr87
PDFs are untracked, so not parsed at test time) per the worksheet_unrounded_sap
convention. Also replaced a pre-existing pytest.approx with abs-diff to keep
the file at zero pyright errors (feedback_abs_diff_over_pytest_approx).

106 passed (was 59); pyright 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:54:45 +00:00
Khalim Conn-Kowlessar
57241322ea S0380.185: record CH6 pin-forever proof — distribution-loss is a Summary-export gap
CH6's P960 worksheet input lodges Distribution Loss = "Two adjoining
dwellings sharing a single heating system" → (306) DLF = 1.0000, vs CH4's
"Calculated" → 1.5 → (306) = 1.4500. That DLF choice swings SAP/cost/CO2/PE
materially, but it is NOT present in the Summary PDF that the corpus pipeline
consumes (Summary → ElmhurstSiteNotesExtractor → mapper → calculator).

Proven empirically with a user-supplied controlled pair (CH adjoined
dwellings/Summary_001431 (1) vs (2)): the two Summaries are byte-identical
across every RdSAP INPUT field, differing only in the derived header
(SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A
case-insensitive scan of the CH6 Summary for "distribution"/"adjoin" returns
0 hits. Since CH4/CH6 Summaries are themselves identical bar fuel type, no
Summary-derivable rule can yield CH4=1.45 AND CH6=1.0.

Doc-only change (comment in _EXPECTATIONS); 20/20 community-heating corpus
tests pass. Closes the CH6 re-litigation: pin held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:21:28 +00:00
Khalim Conn-Kowlessar
f179950519 feat(baseline): wire BillDerivation into the orchestrator and persist the Bill (ADR-0014)
The PropertyBaselineOrchestrator now reads the current Fuel Rates snapshot
once per batch, builds a BillDerivation, and prices each scored property's
SapResult -> EnergyBreakdown into a Bill carried on PropertyBaselinePerformance
(None only on the stub no-calculator path). The Bill is flattened onto nullable
bill_* flat columns (per-section kwh+cost, standing charges, SEG credit, total)
on the postgres table, with bill_total_annual_bill_gbp as the not-null
discriminator on read-back. Section absent from the bill stays None, not 0.

Updated all four orchestrator construction sites to inject the FuelRatesRepository
port (handler + three test sites), and the FE migration doc to reflect the
prefixed columns and that they are now populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:51:18 +00:00
Khalim Conn-Kowlessar
82f7315f8d S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3
SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):

  (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)

Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
  CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
  PE  factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501

Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).

Closure (CH3 was already SAP+cost EXACT):
  CH3 (HP/Elec)  CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
                 PE −249.32→−0.0000 (× (1.5569−1.501))  — FULLY EXACT

Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:43:16 +00:00
Khalim Conn-Kowlessar
f7dc9dbccb feat(baseline): Rebaseliner returns RebaselineResult carrying the SapResult
The Rebaseliner is the assemble-and-score step (ADR-0013 amendment); its
SapResult is the scored picture that Bill Derivation also prices (ADR-0014),
so rebaseline() now returns a RebaselineResult{effective, reason, sap_result}
instead of (Performance, reason). CalculatorRebaseliner sets sap_result on
both branches (the bill prices it whether lodged or calculated figures win);
StubRebaseliner returns sap_result=None (runs no calculator). Orchestrator
unpacks the result; the bill wiring lands in the next slice.

Also refreshes the stale ML-era docstrings in rebaseliner.py to the
assemble-and-score model (the calculator, not ML, is the rebaseliner
mechanism per ADR-0013).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:37:13 +00:00
Khalim Conn-Kowlessar
803da062a2 S0380.183: community-heating HW bills at heat-network rate (§10b) — closes CH2/CH4 fully
SAP 10.2 §10b: hot water for a community-heating dwelling bills at the
heat-network rate, not the cert-lodged fuel. Elmhurst §15.0 lodges
`water_heating_fuel_type = "Mains gas"` (3.48 p/kWh) as a placeholder on
community certs; the worksheet (342) Water-heating cost = (310) × the
S0380.171 CHP heat-fraction blend — the SAME rate as space heating (340).

Per-line walk of the CH2 block 10b:
  (340) space   = 11837.83 × 0.037955 = 449.3047  (cascade EXACT)
  (342) water   =  3854.12 × 0.037955 = 146.2830  (cascade billed
                  3854.12 × 0.0348 = 134.12 → −£12.16, the whole residual)
  (350) lighting + (351) standing → (355) 754.1502.

`_hot_water_fuel_cost_gbp_per_kwh`'s `inherit_main_for_community_heating`
path already routes HW cost through `_fuel_cost_gbp_per_kwh(main)` (the
CHP blend), but its gate `_is_community_heating_hw_from_main` excluded
code 302. S0380.182 wired the 302 CO2/PE credit via
`_heat_network_code_302_effective_factor`, which intercepts the HW
CO2/PE helpers ABOVE this predicate's branch — so extending the
predicate to include 302 now affects ONLY the cost path.

Closures:
  CH2 (CHP/Gas)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH4 (CHP/Oil)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH6 (CHP/Coal) SAP −7.49→−8.02, cost +£172.68→+£184.84 — its HW now
                 also bills the blend, compounding the DLF=1.0 quirk
                 (cascade DLF=1.45); same separate CH6 DLF front.

Corpus now 39 variants EXACT on all four metrics (CH2/CH4 join). Open:
CH3 CO2/PE (code-304 community-HP COP), CH6 all-metric (DLF=1.0 manual
override the Summary doesn't carry). 2225 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 32→32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:57 +00:00
Khalim Conn-Kowlessar
5e75fb474c feat(baseline): EnergyBreakdown.from_sap_result + COOLING section
The SapResult -> EnergyBreakdown adapter (ADR-0014), a classmethod on the
target mirroring Performance.from_sap_result. Folds each positive per-end-use
delivered kWh into a billable EnergyLine: main/main-2/secondary heating and
hot water at their resolved fuel (sap_code_to_fuel); lighting/pumps-fans/
appliances/cooking/cooling as electricity. PV export carries to exported_kwh
for the SEG credit. Zero-kWh end uses emit no line; a positive kWh with no
fuel code raises rather than billing at a default (strict, mirrors the
calculator).

Adds BillSection.COOLING (electricity, from space_cooling_fuel_kwh_per_yr).
BillDerivation already prices any section it is given, so no change there.

Also corrects the ADR-0014 amendment: SapResult carries the calculator's own
fuel codes (raw API or Table-32 per mapper, ADR-0015); sap_fuel normalizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:28:22 +00:00
Khalim Conn-Kowlessar
2cb4dd5833 feat(baseline): sap_code_to_fuel normalizes via the calculator's own helper
The fuel codes the calculator now puts on SapResult are its own codes — raw
gov-API enums or already-Table-32, depending on the source mapper (ADR-0015).
sap_code_to_fuel now runs the code through table_32.to_table_32_code
(promoted from private _to_table_32_code) — T32-first, then API-translate,
the SAME normalization the calculator's pricing/CO2 helpers use — before the
Table-32 -> Fuel dispatch, so the bill's carrier matches what the calculator
billed (incl. the API/T32 collision codes, e.g. 20 = wood-logs not heat-net).

Falls back to the raw code for billing fuels the price table omits (the 41-58
heat-network range), which resolve to HEAT_NETWORK -> UnpricedFuel — stricter
than, and intentionally divergent from, the calculator's lossy
default-to-mains-gas for an unpriced code (ADR-0014 §5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:24:39 +00:00
Khalim Conn-Kowlessar
8e86de2257 S0380.182: community-heating CHP+boilers CO2/PE credit (§12b/13b) — closes CH2/CH4 CO2+PE
SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating
"CHP and boilers" (SAP code 302). Per unit of network heat fuel
H = (307)+(310) the effective generation factor is:

  chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel

  (363)/(463) CHP fuel      = chp_frac × 100/heat_eff × f_fuel
  (364)/(464) less credit   = −chp_frac × elec_eff/heat_eff × f_disp
  (368)/(468) boiler fuel   = (1−chp_frac) × 100/boiler_eff × f_fuel

f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up
boilers burn the same community fuel — verified vs CH2 gas / CH4 oil /
CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit
for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff
50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac
0.35 per-cert via community_heating_chp_fraction.

New `_heat_network_code_302_effective_factor` + Table 12f flexible
constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired
into all four factor helpers (main + HW, CO2 + PE) ahead of the existing
single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468)
boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the
(373)/(473)/(386)/(486) totals reconcile only with the boiler at the
full Table 12 factor — verified EXACT.

Two spec citations applied:
- Table 12f flexible-operation default for RdSAP community CHP is an
  Elmhurst engine choice (Table 12f notes make "standard" the default);
  mirrored per [[feedback-software-no-special-handling]] and documented
  in SAP_CALCULATOR.md §8.3.
- Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected
  0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302
  oil cascade (CH4) was the first to exercise it. PE 1.180 was already
  correct. No other variant uses these codes (no regression).

Closures (CO2 + PE only — the CHP credit does not touch cost/SAP):
  CH2 (CHP/Gas)  CO2 −1411.49→+0.0000, PE +1331.23→+0.0000  EXACT
  CH4 (CHP/Oil)  CO2 −4378.24→−0.0000, PE  +319.81→−0.0000  EXACT
  CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet
                 lodges a manual DLF=1.0 the Summary doesn't carry, so
                 cascade DLF=1.45 over-scales H; same root as the CH6
                 SAP −7.49 / cost +£172 (separate DLF front).

CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP
residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side,
untouched here). CH3 unchanged (code 304 community-HP COP front).

Corpus state: 37 variants EXACT on all four metrics (incl. CH1);
remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6
all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all
metrics per S0380.181); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:23:17 +00:00
Khalim Conn-Kowlessar
4e9ff7c3cb feat(calculator): thread per-end-use fuel codes + PV export onto SapResult
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.

Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
  main_heating_fuel_code      RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
  main_2_heating_fuel_code    code column (the lodged fuel code, e.g.
  secondary_heating_fuel_code mains gas 26). None when the corresponding
  hot_water_fuel_code         system is absent / fuel not resolvable.
  pv_exported_kwh_per_yr      SAP 10.2 Appendix M1 §3-4 annual export kWh
                              (0.0 when no PV).

cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.

HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).

New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:16:40 +00:00
Khalim Conn-Kowlessar
02a89bcb39 S0380.181: tighten heat-systems corpus residual tolerances to 1e-4 (all metrics)
The corpus residual-pin tolerances had drifted looser than the comment
above them claimed ("pin at 1e-4 relative to lodged precision"): SAP was
1e-3, cost ±£0.01, CO2 ±0.1 kg, PE ±0.1 kWh. A ±0.1 kg CO2 band could
silently mask a ~0.09 kg drift on a variant we report as EXACT.

The worksheet pins are extracted from the P960 PDF text, which prints
4 d.p., so the hard residual floor is ~5e-5 (half a unit in the last
printed digit) regardless of cascade precision. 1e-4 sits just above
that floor. All 41 variants hold at uniform 1e-4 on continuous SAP,
cost, CO2 AND PE — confirming the 37 EXACT variants are genuinely exact
to PDF print-rounding and the looser bands were masking nothing.

Aligns the guard with [[feedback-zero-error-strict]] /
[[feedback-continuous-sap-tolerance]] (basically zero error across all
four metrics). Test-only change; no cascade behaviour touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:11:28 +00:00
Khalim Conn-Kowlessar
19a56461ba docs(baseline): Bill Derivation design — fuel as calculator output + rebaselining is assemble-and-score
Captures a /grill-with-docs session resolving how BillDerivation gets the
fuel each end use burns, and what Rebaselining actually is.

- ADR-0014 amendment: per-end-use fuel is a calculator OUTPUT (resolved
  Table-32 codes on SapResult: main-1/main-2/secondary/HW + pv_exported_kwh);
  the adapter is a pure SapResult->EnergyBreakdown map. Corrects stale §3
  (is_gas_code... -> sap_fuel.sap_code_to_fuel). Adds COOLING section.
  Interim, pending ADR-0015.
- ADR-0013 amendment: the calculator is the SCORING ENGINE within
  Rebaselining (assemble the Effective EPC picture, then score), not the
  whole of it; the Rebaseliner exposes its SapResult so the orchestrator
  composes Effective Performance AND the Bill from one scoring.
- ADR-0015 (new): mappers own cert normalization; EpcPropertyData becomes a
  strict type. Explains why fuel resolution sits in the calculator today.
- CONTEXT.md: Effective EPC = the assembled picture; Rebaselining = assemble
  (overrides / neighbour-estimation / old-schema remap) then score.
- EpcPropertyData docstring points at ADR-0015.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:04:55 +00:00
Khalim Conn-Kowlessar
8452cf9e2d S0380.180: heat-network distribution pumping electricity (§C3.2) — closes CH1
SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and
Primary Energy associated with the electricity used for pumping water
through the distribution system are allowed for by adding electrical
energy equal to 1% of the energy required for space and water heating."

Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472)
bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity
for pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a). (307)m/(310)m = (space_demand +
hw_output) / efficiency (the cascade models a heat network's generator
efficiency as 1/DLF).

This un-defers the (372)/(472) front the post-S0380.179 handover flagged
"don't guess until the factor source is identified": the source is
§C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent
0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet
shows the (372) energy column as 0.01×(307) (space only) while computing
emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by-
line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating),
(472)=208.2267 PE (demand).

New `_heat_network_distribution_electricity` helper (gated on
`_is_heat_network_main`) precomputes the energy + effective CO2/PE
factors; three new CalculatorInputs fields + calculator.py CO2/PE
summation terms (0.0/None → no-op for individually-heated certs).

Closures:
  CH1 (Boilers/Gas)  CO2 −23.60→−0.00, PE −208.23→+0.00  — FULLY EXACT
  CH3 (HP/Elec)      CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution
                     component closed; code-304 community-HP COP remains)
  CH2/CH4/CH6        gain their (372)/(472) component (CO2 +23.6, PE
                     +208.2); dominant CHP displaced-electricity credit
                     residual (Table 12f + block 12b/13b) is next slice.

No regression on the other 36 corpus variants (helper returns None off
heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0
fail; pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:04:16 +00:00
Khalim Conn-Kowlessar
ba2d6e1cbb docs: handover post S0380.177..179 + CI/test-move infra
Captures the corpus state (36 EXACT + 5 pinned community-heating
variants), the SAP 302 CHP credit cluster as the highest-leverage
remaining front, the unresolved 0.8523 / 0.1994 worksheet-factor
mysteries to per-line-walk before hypothesising, and — importantly —
the new test layout (tests/domain/sap10_calculator/) that changes every
verification command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:21:50 +00:00
Khalim Conn-Kowlessar
c431453d75 refactor(fuel-rates): name the adapter aggregate-first per house convention
PR feedback: adapters here are <aggregate>_<backend>_repository (e.g.
property_baseline_postgres_repository). Rename the fuel-rates adapter to
match — file static_file_fuel_rates_repository.py ->
fuel_rates_static_file_repository.py and class StaticFileFuelRatesRepository
-> FuelRatesStaticFileRepository, plus its test. git mv preserves history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:21:27 +00:00
Khalim Conn-Kowlessar
b24e4d46e4 refactor(baseline): clearer divergence-threshold constant names
PR feedback: the threshold constants were obscure. Rename to state intent —
_SAP10_2_FLOOR -> _MIN_TRUSTED_SAP_VERSION, _SAP_ABS_TOL ->
_MAX_SAP_SCORE_DIVERGENCE, _REL_TOL -> _MAX_RELATIVE_DIVERGENCE — matching
the existing _log_divergence vocabulary, and fold the rationale into the
comments: the calculator emits a continuous SAP score vs the lodged rounded
integer, so a gap up to 0.5 is rounding, beyond it a genuine disagreement
worth recording; CO2/PEUI are not rounded so they get a 1% relative band.
Behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:21:27 +00:00
Jun-te Kim
af8e0d9485
Merge pull request #1149 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-02 18:04:26 +01:00
Khalim Conn-Kowlessar
d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00
Khalim Conn-Kowlessar
69995edec8 Merge branch 'main' of https://github.com/Hestia-Homes/Model into feature/per-cert-mapper-validation 2026-06-02 16:10:41 +00:00
Khalim Conn-Kowlessar
d1c87d84b0 Add heating-systems corpus PDF fixtures so CI can run the residual pins
test_heating_systems_corpus.py (and one pcdb-1 cross-check in
test_cert_to_inputs.py) read the 001431 controlled-variable corpus PDFs
directly at runtime from `sap worksheets/heating systems examples/`, but
that directory was never committed — it was supplied locally on
2026-05-30 and only ever existed on dev machines. CI therefore errored
with "no Summary PDF in …" for all 57 corpus variants.

Commit the 82 corpus PDFs (41 populated variant folders × Summary +
P960, 4.7 MB) in place so the cascade-vs-worksheet residual pins run in
CI, matching the existing convention where the U985 / 000565
conformance fixtures are committed under
backend/documents_parser/tests/fixtures/ (31 PDFs already tracked).

Only the .pdf fixtures are added; the stray .DS_Store and a P960 .txt
dump in pcdb 1/ are left untracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:38:03 +00:00
Jun-te Kim
ed8751b715
Merge pull request #1150 from Hestia-Homes/feature/landlord_data
more tests to ensure we don't deploy something that is brokern
2026-06-02 16:36:38 +01:00
Jun-te Kim
bf166e7f46 test suite to pick it up 2026-06-02 15:33:46 +00:00
Jun-te Kim
6c8fe86cf9 ddd tests 2026-06-02 15:31:42 +00:00
Jun-te Kim
25ba1427b1 seperate ddd tests 2026-06-02 15:30:58 +00:00
Khalim Conn-Kowlessar
0e484aaa1f Fix 11 pre-existing test failures from the absorbed PR
Two unrelated breakages surfaced after merging the PR into this branch;
neither was caused by the appliances/cooking work.

test_appendix_u.py (9 failures) — signature drift + wrong methodology
label. The climate lookups were renamed `external_temperature_c(region=…)`
→ `(region_or_climate, month)` when PostcodeClimate support landed for
the demand cascade, but the tests still passed `region=`. The expected
values match our SAP 10.2 _TABLE_U1/U2/U3 exactly (UK-avg Jan 4.3 °C,
Thames Jul 17.9 °C, solar Jul 189 W/m², Shetland Jan wind 9.5 m/s), so
these are valid 10.2 coverage — fixed the call signature to positional
and corrected the mislabelled "SAP 10.3" docstrings to SAP 10.2 (we
track 10.2 deliberately). Also converted pytest.approx → abs(x-y)<=tol
per the repo convention; pyright on the file drops 48 → 0.

test_table_32.py (2 failures) — the parametrised "match PDF p.95" test
pinned heating oil (code 4) = 7.64 and FAME (code 73) = 5.44, but the
table deliberately diverges from the PDF for these two carriers: oil =
5.44 (Slice S0380.131, two independent lodging engines agree the PDF
7.64 is the outlier) and FAME = 7.64 (Slice S0380.168). Updated the two
expected values to the worksheet-canonical figures the table actually
uses, with inline citations + a docstring note on the divergence.

Full calculator + property_baseline + heating-corpus suites: 1748 pass,
0 fail. pyright net-improving on both files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:09:59 +00:00
Jun-te Kim
4e02eb7c77 more tests to ensure we don't deploy something that is brokern 2026-06-02 15:03:20 +00:00
Khalim Conn-Kowlessar
2f039aeb39 Thread appliances + cooking annual kWh onto SapResult for ADR-0014 bills
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.

Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
  appliances_kwh_per_yr  — SAP 10.2 Appendix L L13/L14/L16a annual E_A
                           (sum of the §5 (68) monthly appliances kWh)
  cooking_kwh_per_yr     — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
                           estimate E_cook = 138 + 28×N

Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.

Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.

HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.

New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.

Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:00:10 +00:00
Khalim Conn-Kowlessar
44bf186e22 Merge branch 'feature/per-cert-mapper-validation' of https://github.com/Hestia-Homes/Model into feature/per-cert-mapper-validation 2026-06-02 14:46:47 +00:00
Khalim Conn-Kowlessar
f2062a2fbe Slice S0380.179: RdSAP §10.7 electric-immersion default for no-system certs
Closes the "no system" corpus variant fully (ΔSAP +1.18 → <1e-4 on all
four metrics).

The cert lodges §15.0 "Water Heating Code: NON / SapCode 999" and §15.1
"Hot Water Cylinder Present: No". Per RdSAP 10 §10.7 (PDF p.55) "No
water heating system" verbatim: "the calculation is done for an
electric immersion heater. If the electric meter is dual the immersion
heater is also dual, but is a single immersion otherwise... for a
cylinder defined by the first row of Table 28 (110 litres) and the
first row of Table 29." Table 29 row 1 gives age-band cylinder
insulation (age G -> 25 mm foam) and assumes a cylinder thermostat
present for immersion-heated DHW.

The BRE-approved Elmhurst engine confirms the substitution: the P960
worksheet header lodges "WHS: 903 Electric immersion, Single", a 110 L
cylinder, and storage loss (56) = 594.32 kWh/yr, so HW (64) = (45)
1935.37 + 594.32 = 2529.6927.

Pre-slice the cascade trusted the lodged "no cylinder" -> added no
storage loss and a spurious Table 3a keep-hot combi loss; the wrong HW
heat-gains also propagated through §5/§7, over-stating the base MIT by
+0.25 K and space fuel by +228 kWh. New
`_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at
the top of cert_to_inputs (the demand cascade delegates here too) when
water_heating_code == 999, injecting WHC 903 + electricity fuel +
110 L cylinder + Table 29 insulation + assumed cylinder thermostat.
This closes HW fuel AND the downstream space residual in one move.

Age bands A-F (12 mm loose jacket) raise UnmappedSapCode — no corpus
member exercises that and the Table 2 loss-factor dispatch only has the
factory-foam path plumbed. Gate is keyed on code 999, unique to "no
system" in the corpus; 40 other variants + 858 section pins + 6 U985
fixtures unchanged. 936 pass; pyright net-zero 32 -> 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:44:45 +00:00
Daniel Roth
223c1a259b adjust comment about evidence type 2026-06-02 14:32:13 +00:00
Daniel Roth
1a1804399e Service persists mcs_certificate file_type when evidence_category is MCS cert 🟩 2026-06-02 14:29:52 +00:00
Daniel Roth
bdc35573b5 Downloaded files carry evidence_category as DownloadedFile 🟩 2026-06-02 14:27:54 +00:00
Daniel Roth
d68cbb44f8 Downloaded files carry evidence_category as DownloadedFile 🟥 2026-06-02 14:24:24 +00:00
Daniel Roth
98eccda0ba MCS cert identified by evidence_category in get_core_file_type 🟩 2026-06-02 14:21:58 +00:00
Daniel Roth
ca224515f9 MCS cert identified by evidence_category in get_core_file_type 🟥 2026-06-02 14:20:35 +00:00
Khalim Conn-Kowlessar
c054d71284 Slice S0380.178: oil 6 circulation pump x1.3 for absent room thermostat
Closes the residual S0380.177 exposed on oil 6. The cascade's central
heating pump used the bare Table 4f age default (41 kWh for "2013 or
later") but the worksheet (230c) = 53.3 kWh.

SAP 10.2 Table 4f (PDF p.175) footnote a) on the "Circulation pump"
rows reads verbatim: "Multiply by a factor of 1.3 if room thermostat
is absent." oil 6 lodges control code 2101 ("No time or thermostatic
control of room temperature") = no room thermostat, so 41 x 1.3 = 53.3
= ws (230c) EXACTLY; pumps/fans (231) = 53.3 + 100 (liquid-fuel boiler
flue fan/pump) = 153.3 EXACT. Same root cause (absent room thermostat)
as the S0380.177 Table 4c(2) interlock fix — both keyed on the new
`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`.

`_table_4f_circulation_pump_kwh` now multiplies the resolved pump kWh
by `_TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER = 1.3` when the main's
control code is in that set.

oil 6 now FULLY EXACT on all four metrics (ΔSAP/cost/CO2/PE < 1e-4).
The sibling oil 5 (same "2013 or later" pump age but control 2106 WITH
a room thermostat) keeps the bare 41 kWh and is unaffected — as do the
other 39 corpus variants (2101/2102 appear only on oil 6). 935 pass;
pyright net-zero 32 -> 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:20:18 +00:00
KhalimCK
c05c7ead63
Merge pull request #1146 from Hestia-Homes/feature/property-baseline-sap10
Property Baseline: SAP calculator load-bearing + Bill Derivation engine
2026-06-02 15:14:19 +01:00
Khalim Conn-Kowlessar
5276282d8c Slice S0380.177: oil 6 boiler interlock from room thermostat absence
oil 6 (B30K standard liquid-fuel boiler, Table 4b code 126 winter 80 /
summer 68) lodges Main Heating Controls Sap code 2101 ("No time or
thermostatic control of room temperature") WITH a cylinder thermostat.
The cascade's `no_interlock` gate only checked the cylinder thermostat,
so oil 6 kept raw efficiency despite the P960 worksheet header lodging
"Boiler Interlock: No".

Per RdSAP 10 §3 (PDF p.57): boiler interlock is "assumed present if
there is a room thermostat and (for stored hot water systems heated by
the boiler) a cylinder thermostat. Otherwise not interlocked." Control
code 2101 (and 2102 "Programmer, no room thermostat") provides no room
thermostat — the two Table 4e Group 1 rows carrying the "+0.6 °C /
Table 4c(2)" annotation — so the boiler is NOT interlocked regardless
of the cylinderstat. SAP 10.2 Table 4c(2) (PDF p.169) "No thermostatic
control of room temperature – regular boiler" then deducts 5pp from
BOTH the Space and DHW seasonal efficiency.

Three changes in cert_to_inputs.py:
- new `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`;
- `no_interlock` now ORs room-thermostat absence with the existing
  stored-HW cylinderstat-absence test (the RdSAP §3 conjunction);
- the Space -5pp leg fires for Table 4b non-PCDB boilers (code
  101-141), not only PCDB-record boilers; the DHW leg is gated on a
  cylinder being present (Table 4c(2) combi DHW = 0).

Result for oil 6: space fuel (211) = 13446.3457 EXACT, HW fuel (219) =
4099.5872 EXACT. ΔSAP +3.0518 → +0.0782, Δcost -£69.79 → -£1.68,
ΔCO2 -240.66 → -1.71, ΔPE -1112.66 → -18.61.

The spec-correct fix exposes a single residual cause (per
[[feedback-software-no-special-handling]]): the central heating pump
(230c) — cascade reads pump_age=2 → Table 4f 41 kWh but ws (230c) =
53.3 kWh. The 12.3 kWh gap fully accounts for the residual across all
three metrics; pinned as the S0380.178 forcing function.

All other 40 corpus variants + 858 section pins + 6 U985 fixtures
unchanged (2101/2102 boiler codes appear only on oil 6). Pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:13:51 +00:00
Khalim Conn-Kowlessar
0fb5da2f79 refactor(baseline): Performance.from_sap_result replaces the loose mapper
PR feedback: the SapResult -> Performance mapping should be a method, not a
free function you must know exists in the rebaseliner. Put the factory on
the target as `Performance.from_sap_result`, beside its sibling
`lodged_performance` and mirroring `Epc.from_sap_score` (the factory this
mapping already calls).

Not a `SapResult.to_performance()`: that would make the SAP calculator
import `Performance` (a property_baseline type), re-introducing the
engine->consumer coupling removed by the SapCalculator ABC. SapResult is a
TYPE_CHECKING-only import in performance.py (the body only reads attributes),
so the calculator module is not pulled in at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:59:25 +00:00
Khalim Conn-Kowlessar
389e39012d style(baseline): typehint call-return locals in CalculatorRebaseliner
PR feedback: annotate locals assigned from a method-call return or
attribute access, even though pyright infers them — the type is visible at
the assignment without chasing the callee. `result: SapResult` and
`sap_version: Optional[float]` in rebaseline(). Local annotations are not
evaluated at runtime, so the TYPE_CHECKING-only SapResult import stands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:49:34 +00:00
Khalim Conn-Kowlessar
bce4a9f7ec refactor(baseline): SapCalculator ABC replaces the Calculator Protocol
PR feedback: prefer an abstract base the calculator inherits from over a
structural Protocol. Define `SapCalculator(ABC)` in the calculator package
(the engine owns its own contract) and have `Sap10Calculator` inherit it;
a future methodology is another subclass. Placing the ABC with the engine —
not in property_baseline — keeps the dependency pointing consumer -> engine
(sap10_calculator imports nothing from property_baseline). Consistent with
the repo's existing port convention (FuelRatesRepository(ABC)).

CalculatorRebaseliner keeps its reference to SapCalculator type-only (under
TYPE_CHECKING), so the module still does not import the calculator at
runtime. Test fakes now inherit the ABC since structural conformance no
longer applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:45:48 +00:00
Khalim Conn-Kowlessar
168b1941ea docs: handover post S0380.174..176
Three slices landed: §4 storage+primary loss for community heating
(.174), §14.1 heating_controls_sap extraction (.175), Table 4b combi
sub-row dispatch (.176). Cohort moves from 36 EXACT + 5 pinned to 34
EXACT + 7 pinned — net 2 new full-EXACT closures (oil 3 + oil 4) +
2 reshape (CH1/CH3 SAP/cost EXACT, CO2/PE pinned on the (372)
electrical-distribution Elmhurst-factor mystery).

933 pass + 0 fail at HEAD 326066ee. Pyright net-zero.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:42:42 +00:00
Khalim Conn-Kowlessar
326066ee12 Slice S0380.176: Table 4b combi sub-row dispatch for (61)m
SAP 10.2 §4 line 7702 (PDF p.137) defines (61)m as "Combi loss for
each month from Table 3a, 3b or 3c (enter '0' if not a combi
boiler)". Table 4b sub-rows 128 / 129 / 130 are explicit combi sub-
rows per the spec row names:
    128: Combi oil boiler, pre-1998
    129: Combi oil boiler, 1998 or later
    130: Condensing combi oil boiler

Pre-slice `_table_3a_combi_loss_default_applies` gated only on
`main_heating_category ∈ {1, 2, 3, 6}`. The Elmhurst mapper leaves
`main_heating_category=None` on Table 4b liquid-fuel boilers (FAME,
HVO, B30K) — the cascade fell through to (61)m=0 despite the lodged
SAP code being a combi sub-row, under-counting (62)m by 600 kWh/yr
for FAME combi certs.

Extended the helper with a `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-
through (set already exists for the symmetric `_primary_loss_
applies` Table 4b non-combi branch — see S0380.146). The set carries
the canonical combi + CPSU sub-row codes (103/104/107/108/112/113/
118/120-123/128-130). For cylinder-lodged certs the existing
`if epc.has_hot_water_cylinder: combi_loss_override = zero_monthly`
guard in `_water_heating_worksheet_and_gains` still pre-empts the
combi-loss fall-through correctly — non-combi codes with cylinders
remain (61)m=0.

Closures (heating-systems corpus 001431):
  oil 3 (code 128, FAME, no cylinder) ALL EXACT (±0.0000):
    ΔSAP_c +2.5863 → -0.0000
    Δcost  -£61.89 → -£0.00
    ΔCO2   -14.58  → +0.00
    ΔPE    -967.10 → +0.00
  oil 4 (code 129, FAME, no cylinder) ALL EXACT (±0.0000):
    ΔSAP_c +2.5603 → +0.0000
    Δcost  -£56.66 → +£0.00
    ΔCO2   -13.35  → +0.00
    ΔPE    -884.90 → +0.00

Oil 6 (code 126, NOT a combi, with cylinder) unchanged — the fix
is gated on the combi sub-row set. Cohort moves from 9 pinned
residuals to 7.

933 pass + 0 fail (+1 new mapper test). Pyright net-zero on cert_
to_inputs.py + tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:36:17 +00:00
Khalim Conn-Kowlessar
eda07d12dc Slice S0380.175: Community heating main_heating_control extraction
SAP 10.2 Table 4e Group 3 (PDF p.173) — heat-network control codes
2301-2314 dispatch to control_type 1, 2, or 3. Code 2306 = "Charging
system linked to use of heating, programmer and TRVs" →
control_type=3, temperature_adjustment=0. Per Table 9 the elsewhere-
zone off-hours depend on control_type: type 1/2 → (7, 8); type 3 →
(9, 8). The two extra off-hours change the §7 (90) T_rest mean by
~0.6 K → (92) MIT by ~0.4 K → (98) SH demand by ~390 kWh/yr.

Pre-slice diagnosis: cascade defaulted `main_heating_control=2`
(modal RdSAP) when the §14.0 "Main Heating Controls Sap" field was
empty. The 5 community heating corpus variants ALL lodge the SAP
code in §14.1 Community Heating "Heating Controls SAP" instead
(format: bare 4-digit integer, e.g. "2306"). The extractor was
storing this in `CommunityHeating.heating_controls_sap` but the
mapper only read `mh.heating_controls_sap` (§14.0).

Two changes:

1. `_elmhurst_sap_control_code` extended to accept bare 4-digit form
   ("2306") in addition to the §14.0 narrative form ("SAP code 2106,
   Programmer, room thermostat and TRVs"). Empty-string returns None
   instead of swallowing through the original `re.match` regex.

2. `_map_elmhurst_sap_heating` falls through to
   `mh.community_heating.heating_controls_sap` when the §14.0 main
   block leaves `heating_controls_sap` empty.

Closures (heating-systems corpus 001431):
  CH1 ΔSAP_c -1.0572 → +0.0000  EXACT
      Δcost  +£24.36 → -£0.00   EXACT
  CH3 ΔSAP_c -1.0572 → +0.0000  EXACT
      Δcost  +£24.36 → -£0.00   EXACT
  CH2/CH4 SAP-side flip ±0.42 → ±0.53 (CHP-split blend reacts to
        the now-lower SH demand × CHP rate)
  CH6 ΔSAP_c -8.4406 → -7.4942 (DLF=1.0 P960 quirk untouched)

Remaining CH1/CH3 ΔCO2 -23.60 / ΔPE -208.23 is the §13a (372)
"Electrical energy for heat distribution" line (118.38 kWh × electric
factors 0.1993 CO2 / 1.760 PE). Cascade doesn't currently meter this
electricity overhead separately from heat-network heat — next slice.

932 pass + 0 fail (+5 new mapper tests). No regressions on the other
36 corpus variants — the mapper change is gated on `mh.community_
heating is not None` and only fires when §14.0 leaves the control
field empty. Pyright net-zero on mapper.py + corpus test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:07:59 +00:00
Daniel Roth
5df874b978 evidence categories plus typehinting 2026-06-02 12:54:12 +00:00
Khalim Conn-Kowlessar
4876140a97 Slice S0380.174: §4 storage + primary loss for community heating
SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482):

    "Primary circuit loss for insulated pipework and cylinderstat
     should be included (see Table 3)."

SAP 10.2 Table 2b note b (PDF p.159) verbatim:

    "Multiply Temperature Factor by 0.9 if there is separate time
     control of domestic hot water (boiler systems, warm air systems
     and heat pump systems)."

The Table 2b note b ×0.9 multiplier is restricted to "boiler / warm
air / heat pump systems" — community heating is omitted from that
verbatim list. Pre-slice the cascade applied the ×0.9 reduction
unconditionally when DHW was separately timed, AND omitted the Table
3 primary-loss path for heat-network mains entirely. Combined the
two gaps under-counted (62)m HW total demand by ~320 kWh/yr for
heating-systems corpus 001431 community heating 1 (8164 + 0 vs
448.74 + 273.90 spec losses).

Three changes:

1. New `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION = 1.0` constant.
   `_primary_loss_override` selects this for heat-network mains
   instead of the RdSAP §3 age-band default, per the spec's literal
   "insulated pipework" + back-solve from worksheet (59) Jan = 23.26
   = 31 × 14 × (0.0091×3 + 0.0263).

2. Extended `_primary_loss_applies` with a new branch: heat-network
   main + WHC ∈ {901, 902, 914} + cylinder present → primary loss
   applies.

3. New `_table_2b_note_b_multiplier_applies(epc, main)` predicate
   that gates the ×0.9 storage-loss reduction on the spec's verbatim
   system-type list, returning False for heat-network mains. The
   primary-loss `_separately_timed_dhw` continues to return True for
   community heating (Table 3's "separately timed" row is system-
   type-agnostic and gives h=3 all year).

Closures (heating-systems corpus 001431):
  CH1 HW kWh 3391.90 → 3854.12 (= ws 3854.1175, abs Δ < 1e-3)
  CH1 HW cost £143.82 → £163.41 (= ws £163.41, EXACT)
  CH1 (65)m heat gains 793.51 → 1221.62 (= ws 1221.62, EXACT)
  CH2/CH3/CH4/CH6 same shape — HW path closes against ws (310).

§4 fix is spec-correct on all 5 CH variants. The closure surfaces a
separate §7 MIT (92)m over-count of +0.46 K (cascade Jan = 17.22 vs
ws 16.76) that the pre-slice (65)m gain under-count was masking. Per
[[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; new pinned residuals reflect the exposed MIT gap.

New residuals (vs pre-slice):
  CH1   ΔSAP -0.5273 → -1.0572  ΔPE -9.15  → +408.67
  CH2   ΔSAP -0.0076 → -0.4187  ΔPE +1506  → +1779
  CH3   ΔSAP -0.5273 → -1.0572  ΔPE -387.03 → -239.03
  CH4   ΔSAP -0.0076 → -0.4187  ΔPE +494.61 → +767.13
  CH6   ΔSAP -8.0295 → -8.4406  ΔPE +7864.60 → +8137.11

927 pass + 0 fail (+1 new test). No regressions on the other 36
corpus variants — the gate is narrow on `_is_heat_network_main`.
Pyright net-zero (43 → 43) on cert_to_inputs.py + tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:50:01 +00:00
Khalim Conn-Kowlessar
6c2053afac docs: handover post S0380.170..173
Captures the 4-slice community-heating closure phase: blocked tier
emptied (.170), CHP cost split (.171), heat-network heat-source-eff
scaling (.172), WHC=901 HW main-fuel routing (.173).

Open fronts ranked: SAP 302 CHP credit cascade (3-variant cohort),
+£12 lighting/standing overage on CH1/CH3, oil 3/4/6 + no-system
follow-ups.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:13:34 +00:00