Solid fuel is ambiguous across coal/wood/smokeless/pellets, so the overlay
defaults to house coal (33, the most common) when no main_fuel override is
given; a specific solid-fuel override still wins by last-wins composition.
Updates the ADR-0041 natural-fuel amendment accordingly (every archetype drags
a natural fuel; solid defaults to coal).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A landlord heating override DESCRIBES the dwelling, so its assumed off-peak meter
(Dual/E7) is a coherent default — it must not downgrade a cert that already
lodges a more-off-peak meter (e.g. property 709874's 24-hour all-low tariff). The
overlay opts in via keep_existing_off_peak_meter; _fold_heating then keeps the
cert's meter when both it and the desired meter are off-peak. Heating MEASURES
build HeatingOverlay directly and leave the flag False, so they still re-meter
for real (Elmhurst re-lodges 18-hour -> Dual on a storage install) — the cascade
pins stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
System-built is RdSAP code 6, colliding with the gov-EPC code-6 basement
sentinel that main_wall_is_basement falls back to. The wall overlay now pins
wall_is_basement=False for system-built, mirroring the gov-API mapper's
_clear_basement_flag_when_system_built, so the override no longer adds a phantom
basement (which inflated both wall and ground-floor heat loss).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Electric room heaters (691) get a Dual meter from the overlay (not single-rate):
an all-electric room-heater dwelling realistically bills on Economy 7, and the
§12 dispatch then applies a high/low split rather than a single-rate over-penalty.
Overlay owns its assumed-meter policy via _ASSUMED_DUAL_METER_CODES (the §12
off-peak systems + room heaters), keeping OFF_PEAK_IMPLYING_HEATING_CODES pure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'Electric room heaters' archetype -> SAP Table 4a code 691 (panel/convector/
radiant), on a single-rate meter (691 is not off-peak-implying, unlike storage).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_heat_pump_apm_efficiencies applied the Table N8 in-use factor to the PCDB
water-heating efficiency but omitted the spec's "subject to a minimum
efficiency of 100%" clause (SAP 10.2 Appendix N3.7, PDF p.109). An oversized
heat pump whose PSR-extended water,3 x the 0.60 in-use factor fell below
100% therefore billed water heating at that sub-100% efficiency, over-
counting hot-water fuel.
Apply max(in_use x eta_water, 100%). Accredited Elmhurst worksheet for cert
100110101713 (golden fixture case 56, PCDB 100061): water (216) = 100.0000,
which we now match (was 77.13%). Combined with the space-heating PSR-
extension fix the cert lands 72.51 vs lodged 73 (|err| 18.32 -> 0.49).
Data-driven: only the single oversized-PSR cert moves (in-range heat pumps
keep their > 100% water COP, e.g. case 54 112.5%, case 55 179.6%). Corpus
(RdSAP-21.0.1 n=1000) MAE 0.726 -> 0.721, within-0.5 74.1% -> 74.2%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
interpolate_heat_pump_efficiency_at_psr clamped to the smallest/largest PSR
row when the dwelling's plant size ratio fell outside the record's range.
That is the SAP 10.2 Appendix N rule for *combined heat-pump-and-boiler*
packages, not for a plain air/ground/water source heat pump.
Per Appendix N2 (PDF p.101, footnotes 44/45) a source heat pump whose PSR
exceeds the record's largest value takes a reciprocal-linear interpolation
between the largest-PSR efficiency and 100% at twice that PSR (100% beyond),
and 100% when the PSR is below the record's smallest value. Both the space-
and water-heating PSR-dependent efficiencies extend this way.
Effect: an oversized heat pump in a small dwelling is no longer credited the
full top-of-table COP. Accredited Elmhurst worksheet for cert 100110101713
(golden fixture case 56, PCDB 100061, PSR 3.107 over largest 2.0): (206)
334.4% -> 139.66% = Elmhurst exact. Corpus (RdSAP-21.0.1, n=1000) MAE
0.7397 -> 0.7258, within-0.5 0.7410 held; only two certs move (both
oversized-PSR heat pumps), 100110101713 +18.32 -> -4.97.
Exhaust-air and combined heat-pump-and-boiler packages have different
boundary rules (straight-to-100% / clamp-to-edge) but are not distinguished
by the current PCDB parse; the air/ground/water rule is applied uniformly,
a documented limitation noted in the function docstring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate PV generation/credit in cert_to_inputs on gov-API pv_connection:
credit only when ==2 ('connected'); ==1 ('present but not connected to the
dwelling's meter') contributes zero to the dwelling's cost/CO2/PE per
RdSAP 10 §11.1 / SAP 10.2 Appendix M. Non-int (None / site-notes str) keeps
the credit-if-array behaviour, so the Elmhurst/Summary + synthetic paths are
unchanged (no regression).
Corpus: all 5 pv_connection=1 PV certs move inside ±0.5 (e.g. 100051118081
+6.5→+0.5); MAE 0.760→0.740, within-0.5 73.8→74.3%, no regression
(pv_connection=2 certs keep their credit).
Also corrects a now-load-bearing latent bug: the solar-recommendation
overlay tagged recommended arrays pv_connection=1 ('not connected') — which
the new gate would zero. A new install connects to the dwelling's meter, so
it must be 2; pinned by the overlay test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RdSAP 10 §11.1 / SAP 10.2 Appendix M: PV is included in a dwelling's
assessment only if connected to the dwelling's own electricity meter. The
gov-API pv_connection enum encodes this — 0=no PV, 1=present-but-not-
connected, 2=connected. Corpus-validated (57 PV certs: pv_connection=1 MAE
4.48->1.22 without credit, 0/5 need it; pv_connection=2 needs it, MAE 0.98
vs 10.29) and Elmhurst-proven (connected SAP 87 vs not-connected 74).
cert_to_inputs currently credits a pv_connection=1 array; the test pins that
it must contribute zero generation. Adds pv_connection to make_minimal_sap10_epc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gov-API flat can lodge dwelling_type="Mid-floor flat" while carrying its
own exposed roof — a top-floor flat mislabelled mid-floor. _dwelling_exposure
keyed roof exposure on the dwelling_type label alone, dropping the roof
heat-loss term: space-heating demand under-read ~32%, SAP over-read +7.
Fix: when the main building part lodges a *determined* roof_insulation_location
(an RdSAP integer code, not the "ND" Not-Defined party-ceiling sentinel),
expose the roof regardless of a contradictory label. Structured field, not a
description string and not roof_construction (which the gov-API lodges
building-wide on every unit, so it is not a per-unit signal).
On the RdSAP-21.0.1 corpus roof_insulation_location separates the classes with
zero disagreement: all 190 party-ceiling flats lodge "ND"; the 4 mid/ground
flats this exposes all move toward lodged, 0 away. within-0.5 73.3% -> 73.6%,
MAE 0.774 -> 0.761 (ratchets tightened). Verified end-to-end on the same
block: 715363 (location 6, RHI 2694) 81 -> 74 = lodged; genuine mid-floor
sibling 715395 (location ND, RHI 1024) stays party at 83 = lodged.
The override is additive (only ever exposes a label-dropped roof) and reads
the main part, so multi-part flats with a party main ceiling stay party.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Property.physical_state_changed (true on Site Notes / Landlord Overrides
/ Prediction — trigger (b)/(c)) and pass it from the
PropertyBaselineOrchestrator into the Rebaseliner. So an overridden or
predicted SAP>=10.2 property now stores calc(effective) as its Effective
baseline instead of echoing the lodged headline — closing the "81 in the DB"
divergence between the displayed baseline and the modelled plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CalculatorRebaseliner uses the calculator output as Effective Performance
whenever a Rebaselining trigger fired — pre-SAP10 (a) OR overrides/prediction
moved the physical state (b)/(c) — tagging pre_sap10 / physical_state_changed
/ both. Only a pristine lodged >=10.2 cert keeps its accredited figure (the
sole case the calculator runs purely to validate). Divergence is logged only
in that pristine case. ABC + StubRebaseliner take the new keyword-only flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Landlord heating-system override was applied as a sparse patch, so the
replaced system's fields bled through. A storage flat reclassified as a gas
combi (property 728513) kept mains_gas=False, heating category 7, the 2401
storage charge control, a Dual meter and an electric-immersion cylinder — an
incoherent record that gated out the gas-boiler-upgrade Measure and made the
heating Generator read the dwelling as off-gas (offering HHRSH storage).
Extend the ADR-0035 drag-along to gas boilers (Table 4b 102/104/120): the
overlay now sets the whole coherent companion set — mains_gas, gas main fuel,
heating category 2, fanned flue, full modern controls (2106), a single-rate
meter, and hot water from the main system with the cylinder set from the boiler
type (combi → none, regular/CPSU → cylinder). The main_fuel overlay also flips
mains_gas=True for a "mains gas" fuel. Non-off-peak archetypes now drag an
explicit Single meter so a system switch never leaves a stale Dual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three corrections found by re-running property 742003 end-to-end:
- roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field
the fixture happened to carry; key the centre/area lookup by array position.
- Base the cap on ground_floor_area (the footprint the roof covers), not the
greatest per-storey area; roof_area is the fallback.
- Clamp the basis by total_floor_area: predicted EPCs borrow the structural
template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from
the predicted 55 m² (ADR-0029), so without the clamp the cap reads the
template's larger footprint.
Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests +
orchestration threading + products green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
select_conservative_configs now also caps panels by the dwelling's own usable
roof — min(0.70 × maxArrayPanelsCount, roof_area/cos(pitch) × 0.5 / panel_area)
— threaded from recommend_solar via roof_area(epc, MAIN) (ADR-0038). No-op on
correctly-matched homes; falls back to the Google cap when the EPC has no MAIN
part. Defeats Google footprint conflation (semi-detached/terraced).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enrich each SolarRoofSegment from roofSegmentStats (centre + areaMeters2, keyed
by segmentIndex) and read panelHeightMeters/panelWidthMeters onto SolarPotential
— the geometry the Dwelling-Roof Cap (ADR-0038) needs. All Optional; existing
projection + config-selection tests stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add _apply_ventilation_mode: set the predicted mechanical_ventilation_kind to
the recency/geo-weighted cohort mode (mirrors _apply_glazing_mode — MEV/MVHR is
a new-build/retrofit feature clustering by era + street). Only the kind moves;
the template's sheltered_sides etc. stay. Natural cohorts mode to None and stay
natural (§2 default), so this only moves genuine MEV/MVHR neighbourhoods.
Display-only for the calc gate: component-accuracy (26) + corpus (6) + e2e (1)
all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_apply_heating_donor now also deepcopies the donor's main_heating and
main_heating_controls EnergyElements alongside its calc sap_heating cluster, so
the building-passport heating rows are coherent with the donated system and a
control row the size-template lacked (but the donor lodges) is populated. Fixes
'Heating Control: Unknown' on predicted properties (e.g. 721167). Display-only:
component-accuracy gate + corpus harness unchanged (26/6 green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the hot-water (Table 13 / HP-DHW), secondary (direct-acting),
main-2 and ALL_OTHER_USES High-Rate Fractions on CalculatorInputs from
the same Table 12a helpers the SAP cost path uses, so Bill Derivation's
day/night split matches the rating's exactly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A predicted EPC is seeded by deep-copying one representative neighbour's
structure. _template chose the member whose floor area was closest to the
cohort median, ignoring building-part labels. When that member's only part
was lodged with a null identifier (mapped to OTHER), the prediction had no
MAIN part and the modelling_e2e handler rejected it as "not predictable" —
discarding an otherwise-rich same-type cohort.
Restrict the template to MAIN-bearing members (median still over the whole
cohort); fall back to closest-on-size only when none are MAIN-bearing, so an
all-unlabelled cohort is left for the handler's MAIN-part guard to reject
rather than silently relabelling real data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note that code 39 "any tariff" currently collapses to the standard electricity
rate; future work should resolve it to the dwelling's actual tariff (off-peak vs
standard) so off-peak electric heating prices correctly. Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
modelling_e2e properties with main fuel 39 failed at the price boundary
(UnpricedFuelCode since #44fff767; previously mis-rated as non-electric →
the ~14-SAP over-rating flagged in earlier review).
Code 39 is SAP Table 12 "electricity, any tariff" (epc_codes.csv main_fuel 39 =
"electricity, unspecified tariff"; spec footnote (j): defines an electric system,
cost/CO2/PE = standard electricity). It was absent from API_FUEL_TO_TABLE_32, so
to_table_32_code(39) was None → is_electric_fuel_code(39) False and pricing
raised.
Fix: map API_FUEL_TO_TABLE_32[39] = 30 (standard electricity) — the canonical
place Khalim's fuel work added codes. One line makes classification, pricing,
CO2/PE and the billing carrier all agree (39 → 30 → ELECTRICITY).
Tests: to_table_32_code(39)==30, is_electric_fuel_code(39) True, price == standard
electricity, and the billing carrier resolves to ELECTRICITY. 0 corpus impact
(no lodged corpus cert uses 39); accuracy + mapper-corpus gates green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A dwelling's heating is one conceptual system, but its fields are scattered
across EpcPropertyData (a gov-API schema mirror): the cluster on sap_heating, the
electricity tariff on sap_energy_source.meter_type, hot-water flags loose at top
level. Three places synthesise a heating system — Measure Options, Landlord
Overrides, EPC Prediction's donor — and each hand-copied a different ad-hoc
subset. The override and donor both dropped meter_type, so an electric-storage
system landed on the template's single-rate meter and billed overnight heat at
the peak rate: property 713406 scored SAP 13 (G) vs ~50 (E), inflating the HHRSH
measure to +45.8 and overshooting the plan to band A.
Establish a single Coherent Heating System boundary (CONTEXT.md) that every
synthesiser must cover, with a source-appropriate fill policy (ADR-0035):
- Override overlay *completes* the partial system the landlord named. Companion
fields are now DERIVED from the SAP code, not hand-attached per archetype: the
off-peak meter from the calculator's single off-peak classification (new
OFF_PEAK_IMPLYING_HEATING_CODES = SAP §12 Rules 1-2), and an unobserved storage
charge control defaults to the conservative manual control (Table 4e 2401). So
adding a heating archetype is just adding its code — companions can't be
forgotten. A contract test guards it (every off-peak code drags a Dual meter).
- Prediction's heating donor now *carries* the donor's meter_type alongside its
sap_heating cluster — the donor is already coherent.
Coherence is a synthesis-time obligation only; the calculator still scores a real
lodged cert exactly as lodged.
Verified on 713406: baseline 13 -> 47.8 (E), matching its recorded rating; the
phantom HHRSH recommendation is gone and the plan no longer overshoots to A.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 modelling_e2e properties failed with KeyError: 'maxArrayPanelsCount'.
Google returns a `solarPotential` block with no array-level sizing fields
(`maxArrayPanelsCount` / `panelCapacityWatts`) for buildings with no usable
solar estimate. `SolarPotential.from_building_insights` hard-indexed those keys
and crashed the whole property.
Fix: the projection now returns Optional and yields None when those fields are
absent — the established "no solar potential" outcome (the orchestrator and
recommendation path already type it Optional and skip solar on None). Existing
callers (`_solar_potential_for`, harness) already assign to Optional.
Regression test + `assert is not None` narrowing on the valid-fixture tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 modelling_e2e properties failed with "unmapped SAP code in fuel_code: 10":
the billing layer (`sap_code_to_fuel`) had no carrier for Table-32 code 10
(dual fuel, mineral + wood) and raised rather than guess one.
SAP 10.2 treats dual fuel as its OWN fuel (its own Table-12 factors), so model
it as its own billing carrier rather than collapsing onto wood or coal:
- New `Fuel.DUAL_FUEL_MINERAL_AND_WOOD`.
- `_CODE_TO_FUEL[10]` -> that carrier.
- Fuel Rates snapshot prices it at 7.69 p/kWh — the midpoint of the COAL proxy
(7.13) and WOOD_LOGS (8.25). This mirrors SAP's own construction: Table-32
dual fuel (3.99) ~= midpoint of house coal (3.67) and wood logs (4.23).
Marked `derived` with a documented _note/_gap/_assumption (like the COAL and
HEAT_NETWORK proxies), since there is no retail blend price.
A dedicated carrier + rate (vs a one-line map to an existing carrier) keeps the
fuel identity faithful to SAP and avoids mispricing dual fuel as pure wood/coal.
Tests: code 10 -> DUAL_FUEL carrier; snapshot prices it at 7.69; grid-export
codes (36/60) still raise (the genuine no-carrier case).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SAP 10.2 Table 12d/12e: electric water heating on a 7-/10-hour tariff bills
CO2/PE at the high-rate code (32/34) and low-rate code (31/33), kWh-weighted
by the Table 13 high-rate fraction. The cost path already applied this split;
the CO2/PE factors did not — they used the flat annual Table 12 figure
(0.136 CO2 / 1.501 PE) for ALL dual-rate electric HW.
That flat-annual behaviour (slice S0380.163) was validated only against
HW-from-main "low-rate cost" certs (100% low, no high-rate split). It is NOT
how Elmhurst bills a whc-903 ELECTRIC IMMERSION: the hand-built case-50
worksheet (000565 + dual immersion, 7-hour) splits HW CO2/PE into "high rate
cost" (CO2 0.1475 / PE 1.5514) + "low rate cost" (CO2 0.1238 / PE 1.4429)
weighted by the Table 13 fraction 0.1009. So flat-0.136 for immersion HW was
a spec gap on our side, not an Elmhurst divergence.
Fix: `_electric_immersion_hw_high_rate_fraction` threads the Table 13 fraction
(scoped to whc-903, 7-/10-hour, cylinder data present) into the HW CO2 + PE
factor helpers, which then blend the Table 12d/12e high/low codes. The flat
rule is unchanged for HW-from-main and 18-/24-hour (no Table 12d split), so
the S0380.163 41-variant cases and the existing pin are untouched.
Case 50: rating CO2 2413.48 -> 2397.1237 = Elmhurst EXACT; demand CO2 2007.1384
EXACT; demand PE +111 -> +32.5 residual (within corpus PE noise). Corpus
unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4 (62 whc-903 off-peak certs;
aggregate gauges hold). SAP unaffected (cost-based).
Pin: test_whc903_immersion_hw_co2_pe_factors_split_high_low_on_off_peak; doc
updated in SAP_CALCULATOR.md §8.1.
pyright strict gate not run locally (pyright not installed in this container).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>