Commit graph

117 commits

Author SHA1 Message Date
Daniel Roth
9b58bebfb8 Rename SapEnergySource.mains_gas to gas_connection_available 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 10:59:21 +00:00
Daniel Roth
a174301136 rename mains_gas to gas_connection_available 2026-07-01 10:50:58 +00:00
Daniel Roth
9835b7115c On-gas-street electric dwelling (API fuel code 29) is offered HHRSH 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 10:38:30 +00:00
Daniel Roth
266a276dae revert changes 2026-07-01 09:52:06 +00:00
Daniel Roth
d4f735e096 Broaden mains_gas comment to cover all non-gas heating fuels, not just electric
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 09:48:22 +00:00
Daniel Roth
5a537df14b HHRSH offered to solid-biomass dwelling with mains_gas=True despite off-gas heating fuel 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 09:42:32 +00:00
Khalim Conn-Kowlessar
e02e6a76e1 Preserve an existing more-off-peak cert meter under a heating overlay 🟩
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>
2026-06-29 20:32:31 +00:00
Khalim Conn-Kowlessar
da17ddce8f Stop a System-built landlord override being mis-read as a basement 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:30:25 +00:00
Khalim Conn-Kowlessar
8606cab5f0 fix(pv): credit PV only when connected to the dwelling's meter 🟩
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>
2026-06-29 13:40:51 +00:00
Khalim Conn-Kowlessar
0a2ed67e94 Harden Dwelling-Roof Cap on real data: positional segments, ground-floor basis 🟩
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>
2026-06-26 12:24:52 +00:00
Khalim Conn-Kowlessar
edce0f46af Dwelling-Roof Cap bounds the PV array to the dwelling's own roof 🟩
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>
2026-06-26 12:17:07 +00:00
Khalim Conn-Kowlessar
d6a59be950 SolarPotential carries panel dims + per-segment centre/area 🟩
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>
2026-06-26 12:14:00 +00:00
Jun-te Kim
46fc8f338c
Merge pull request #1289 from Hestia-Homes/fix/solar-missing-max-array-panels
Treat solar block without array sizing as no-solar (fix KeyError maxArrayPanelsCount, 2 e2e failures)
2026-06-24 11:59:57 +01:00
Jun-te Kim
79f89d872e Treat solar block without array sizing as no-solar (fix KeyError)
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>
2026-06-24 10:22:06 +00:00
Khalim Conn-Kowlessar
80d617aa5b modelling: glazing overlay models draught-proofing + frame-factor re-lodge
Fitting sealed glazing units changes two things beyond the pane's U/g
that the cascade reads, which the overlay didn't model — leaving the
double/secondary before→after pins ~0.7 SAP short (xfail):

1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes
   they replace, re-lodging the dwelling-level `percent_draughtproofed`
   (cert 001431: 84 → 100). The §2 cascade reads that dwelling-level
   value, so the overlay now carries it. `_recompute_percent_draughtproofed`
   anchors on the lodged before-% — `after = round((round(before%/100 × N)
   + flips) / N × 100)`, N = openable windows (vertical + roof) + doors,
   flips = upgraded panes that were not draught-proofed — so it's robust
   to incomplete window extraction (unchanged openings are already in the
   aggregate). ~0.3 SAP.

2. Frame factor (§6 solar gains). A replacement unit re-lodges its own
   FF=0.70, overriding the pane it replaced — the two "single glazing,
   known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them
   unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay`
   now carries `frame_factor`, written flat onto the window. ~0.4 SAP.

Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor`
new fields; `apply_simulations` / `_fold_window` write them; the glazing
generator computes both from the upgraded set and cert 001431's after.

Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after`
— both now pin SAP/CO2/PE to the relodged after within tolerance. Updates
the two `test_glazing_recommendation` overlay expectations for the new
`frame_factor`. 96 modelling tests pass; zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:25:08 +00:00
Jun-te Kim
9956df07ff Land an override on the EPC's part at its position when the label is absent 🟩
A Landlord Override's building_part is a positional index (0=main, 1=extension
1…, ADR-0004), but the gov-API EPC can label that slot differently (e.g. lodge
the 2nd part as 'other', not 'extension_1'). The previous fix skipped such
orphaned overrides, silently discarding the landlord's correction. Now the
override falls back onto the EPC's part at that position (via _resolve_part), so
the correction lands; only a position the EPC models no part at is skipped
(no geometry to model a wholly-absent part). Replaces the skip-only behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:09:11 +00:00
Jun-te Kim
23ccfb0d0b Skip a landlord override that targets a building part the EPC lacks 🟩
A Landlord Override can reference a building part the lodged or predicted EPC
never carried (e.g. an extension_1 override on a property whose EPC has only
main). apply_simulations indexed parts_by_id[identifier] unguarded, raising
KeyError and aborting the whole property's modelling. Now the orphaned part is
skipped. Recovers 14 of the 22 modelling_e2e failures in portfolio 796.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:36:37 +00:00
Daniel Roth
25c695186b measured excluded plus get geospatial nearby stuff working 2026-06-22 16:12:16 +00:00
Jun-te Kim
a3e2566378 landlord override data added 2026-06-20 12:57:54 +00:00
Jun-te Kim
0135f0f27b Resolve a landlord age-band override onto the main building part 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:41:20 +00:00
Jun-te Kim
608e96bb09 Remap every window's glazing from a landlord glazing override 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:21:42 +00:00
Jun-te Kim
f1c6825cae Resolve a landlord double-glazing override to its glazing code 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:18:45 +00:00
Jun-te Kim
4219ef9d8b Overlay landlord property-type and built-form corrections onto the Effective EPC 🟩
Adds whole-dwelling property_type/built_form to EpcSimulation (folded by
apply_simulations) and maps those override components. property_type drives
party-wall heat loss + ASHP/solar/wall eligibility, so a landlord correction now
moves both the SAP calc and the measure menu; built_form has no calculator
consumer today (feeds the ML transform). Written as the landlord text value
(park-home check is text-only). Refines ADR-0032 dec-4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:06:29 +00:00
Jun-te Kim
364867b05e Merge remote-tracking branch 'origin/main' into feature/landlord-overrides 2026-06-16 17:46:28 +00:00
Jun-te Kim
db1e283b07 Map a landlord wall-type override to a wall Simulation Overlay 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:14:54 +00:00
Khalim Conn-Kowlessar
3580d059ec feat(modelling): drive measure scoping from the Scenario's exclusions
The measures a run considers should come from the Scenario, not a CLI flag.
The live scenario table persists exclusions only (no inclusions column), as a
Postgres text-array of exact MeasureType values.

- Scenario gains `exclusions: frozenset[MeasureType]` + `considered_measures()`
  (all measures minus the excluded ones, or None when none are excluded).
- ScenarioModel.to_domain parses the `{a,b,c}` exclusions array into
  MeasureTypes, raising on a token that is not an exact MeasureType value
  (no high-level category expansion), per the strict-enum convention.
- ModellingOrchestrator._plan_for derives the allowlist from the Scenario's
  exclusions, combined (intersection) with any explicit considered_measures
  override via the new `combine_considered_measures`.
- run_modelling_e2e sources the allowlist from the Scenario; --measures /
  --exclude-measures become optional overlays (e.g. the technical
  secondary_heating_removal exclusion the catalogue cannot yet stock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:26:25 +00:00
Khalim Conn-Kowlessar
53d9f21f73 fix(modelling): offer ASHP when the catalogue has no ASHP row
The ASHP bundle is priced from the rate sheet (ADR-0025); the catalogue
row is read only for its material id, which is nullable end-to-end. The
live `material` catalogue has no `air_source_heat_pump` row, so
`products.get` raised `ValueError: no active product` and aborted every
ASHP-eligible property.

Add `ProductNotFound(ValueError)` + a concrete `ProductRepository
.get_optional`, raise the typed error from both repos, and have
`_ashp_option` look the row up optionally — a missing row now yields an
ASHP Option with `material_id=None` rather than crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:55:41 +00:00
Khalim Conn-Kowlessar
c11eb46b8a fix(modelling): HHR overlay sets off-peak immersion type so HW Table 13 applies
The HHR-storage HeatingOverlay (ADR-0024) added an off-peak electric
immersion cylinder but never set `immersion_heating_type`, so the overlaid
cert left it None. The calculator then could not resolve `immersion_single`
for the SAP 10.2 Table 13 HW high-rate split and billed hot water 100% at
the off-peak low rate — £127.41 vs the relodged after-cert's £169.39,
overstating the overlay's SAP by +1.26 (CO2/PE matched, isolating it to the
HW cost path).

Add `immersion_heating_type` to HeatingOverlay, route it through
`_fold_heating` (it lives on `sap_heating`), and set it to 1 (single
off-peak immersion) on the HHR overlay to match the relodged reference.
Closes both `test_hhr_storage_overlay_reproduces_the_relodged_after_*`
cascade pins (electric-storage and no-system befores share the after).

Pre-existing failure (present before this branch's recent commits), outside
the handover regression gate. Full modelling suite 220 pass, pyright net-
zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 06:53:14 +00:00
Khalim Conn-Kowlessar
0d1ec2228d feat(modelling): cost data for secondary-heating-removal (ADR-0028)
Flat per-dwelling decommission price (sample_catalogue \£250) + 0.25 contingency
(covers unknown heater count / hard-wired-vs-plugged / repaint extent). The JSON
repo joins the contingency from config, proven by the new repo test. No composite
Products machinery — a lodged secondary is one roughly-fixed job, not room-scaled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:51:16 +00:00
Khalim Conn-Kowlessar
ae7959f57c feat(modelling): secondary-heating-removal generator + MeasureType (ADR-0028)
recommend_secondary_heating_removal offers one standalone Option that clears the
lodged secondary system. Eligibility is purely physical (offer iff
sap_heating.secondary_heating_type is set) — no effectiveness gate, since a
lodged secondary is a fixed emitter per RdSAP (portables are ignored), and the
electric-storage §A.2.2 no-op is the Optimiser's call (ADR-0028 decisions 1-2).
Priced at a flat per-dwelling decommission cost, not room-scaled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:35:14 +00:00
Khalim Conn-Kowlessar
9b286e4a22 feat(modelling): SecondaryHeatingOverlay clears the lodged secondary (ADR-0028)
The first overlay surface that sets fields to *absent* rather than to a
target state: _fold_secondary_heating clears sap_heating.secondary_heating_type
+ secondary_fuel_type, so the calculator's Table 11 secondary-fraction split
(SAP 10.2 §9a) routes 100% of space heating to the main. On an electric-storage
main RdSAP §A.2.2 re-forces a default secondary, making removal a no-op there —
left to the Optimiser to de-select (ADR-0028 decisions 2-3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:13:20 +00:00
Khalim Conn-Kowlessar
6ce6e89de1 feat(modelling): gate boiler upgrade on the existing boiler's efficiency
Don't offer a like-for-like gas boiler swap to a dwelling whose existing gas
boiler is already at least as efficient as the new condensing boiler (SAP 10.2
Table 4b codes 102/104 = 84% winter) — it gains nothing, and the dwelling gets
the tune-up (cylinder + controls) instead. `_already_condensing` compares the
existing code's Table 4b winter efficiency to 84%; a non-Table-4b code (solid
fuel) has no comparable efficiency and is never treated as already-condensing.

The gate is GAS-ONLY: a non-gas boiler → gas is a fuel switch whose value (cost
/ carbon) is not captured by winter efficiency, so oil/LPG/coal → gas is never
suppressed on efficiency grounds (only gated on the mains-gas connection).

This correctly demotes the gas-with-cylinder example (cert lodges code 114
"Regular, condensing", 84% winter) to a tune-up case — confirming that 114→102
is ~0 boiler-efficiency gain in both our calc and Elmhurst (both Table 4b 84%);
Elmhurst's uplift there came from the cylinder + flue, not the boiler. The
boiler-with-cylinder overlay stays validated by the lpg pin (code 115, non-
condensing + cylinder) and by recasting the 114 fixtures' code to a pre-1998
non-condensing boiler (110) in the boiler tests — the overlay overwrites the
code to 102 regardless, so only eligibility changes, not the delta-0 result.
New tests: an already-condensing gas boiler yields no boiler upgrade (but a
tune-up); an oil condensing boiler is not gated (the fuel switch survives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:15:58 +00:00
Khalim Conn-Kowlessar
ae7e6a0c42 feat(modelling): composite per-dwelling boiler + tune-up costing (ADR-0027)
Replace the flat placeholder scalars (boiler £3000; tune-up £500/£900) with a
per-dwelling composite cost, mirroring the ASHP architecture (ADR-0025): a
`HeatingRates` table (data, `heating_rates.json`), typed `BoilerCostInputs` /
`TuneUpCostInputs`, pure `Products.boiler_bundle_cost` / `tune_up_cost`, and
modelling-layer interpreters that read the dwelling into those inputs.

The cost mirrors the Simulation Overlay component-for-component, sharing the
controls + cylinder pricing across both options:

- tune-up (standard) = standard controls + cylinder fixes
- tune-up (zone)     = zone controls + cylinder fixes
- boiler upgrade     = £3200 all-in + standard controls (only when the upgrade
  fired a controls change) + cylinder fixes

Standard controls are priced INCREMENTALLY — only the parts missing to reach
SAP 2106 (programmer £120 / room thermostat £150 / TRV £35×radiators), read
from a Table 4e Group-1 feature map so a dwelling that already has a room
thermostat + TRVs is only charged the programmer. Zone controls are a full
smart kit (hub £205 + smart TRV £50×radiators) — the smart TRV is itself the
room sensor, so there is no separate per-room sensor line. Cylinder fixes:
jacket £50 (when under-insulated) + thermostat £150 (when absent). The boiler
is a like-for-like wet swap (no radiators/flue/pipework — eligibility already
requires an existing wet boiler), so those dead-code extras are not modelled.

Figures are research-validated 2025/26 UK installed costs (legacy Costs.py
lineage); fully-loaded totals with one contingency on top (Model B, not the
legacy VAT/preliminaries engine). Contingency: boiler 0.26; tune-ups 0.10
(was a 0.15 placeholder). ADR-0027 records the design; CONTEXT.md's Heating
Eligibility entry updated to cover the partial boiler/tune-up family + composed
cost. Products cost pins (delta<=1e-9) + interpreter tests + generator
composite-cost assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:41:06 +00:00
Khalim Conn-Kowlessar
07f534ee11 feat(modelling): system tune-up options (standard + zone controls)
Add the system tune-up to the heating Recommendation: keep the existing wet
boiler but install better heating controls and fix the cylinder. Two competing
Options (the Optimiser picks <=1 across the whole heating rec) per the user's
two best control end-states:

- system_tune_up        — standard controls (programmer + room thermostat +
  TRVs, SAP 10.2 Table 4e code 2106)
- system_tune_up_zoned  — time-and-temperature zone control (code 2110, type 3):
  more SAP uplift for more cost

Both keep the boiler (no fuel / SAP code / flue change), set the control
ABSOLUTELY to their end-state, and apply the conditional cylinder fixes (an
80 mm jacket when under-insulated, a thermostat when absent — only when a
cylinder exists). Each control option is offered only when it genuinely improves
the existing control — standard is skipped when the control is already 2106 /
2110 / 2112, zone when already 2110 / 2112 — so neither is ever a downgrade or a
no-op.

Validated against the Elmhurst "system tune up" re-lodgements (cert 001431):
nine befores spanning controls 2101-2113 all converge to the two common afters,
proving the control overlay is absolute. The cascade pin is parametrised over
two starting controls (2101 "no control" + 2113 "room thermostat and TRVs") x
both afters, delta 0 (SAP/CO2/PE).

Wires the two MeasureTypes through contingencies (0.15), the offline catalogue
(500 / 900), the catalogue-coverage list, the report triggers, and the ARA
first-run seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:20:46 +00:00
Khalim Conn-Kowlessar
2413bc87da feat(modelling): solid-fuel(coal)->gas boiler upgrade + boiler_flue_type end-state
Pin the coal-boiler-with-cylinder upgrade and add the `boiler_flue_type`
end-state field. A solid-fuel (coal) boiler (fuel 11, SAP code 153) on a
mains-gas street converts to a gas condensing boiler (fuel 11->26, code 102) —
the non-gas->gas path for a solid-fuel system, eligible because code 153 is in
the wet-boiler solid-fuel range 151-161 and mains gas is present.

New `boiler_flue_type` HeatingOverlay field, routed to main_heating_details[0]
and set to 2 (room-sealed/balanced) on both boiler shapes: every relodged after
lodges flue type 2, but coal's before lodged none. The field is SAP-inert (the
cascade score is unchanged by it), so it is written purely for end-state
fidelity — the overlay now represents the installed condensing boiler's flue.
Validated via the overlay-equality unit tests.

The coal after predates the user-locked "always add a cylinder thermostat when
absent" rule, so it stale-lodged thermostat 'N'; the pin corrects it to the
rule's end-state 'Y' in-test (the gas with-cylinder after got the same
correction by re-lodging). The cylinder is already 80 mm insulated, so the
jacket is skipped and only the thermostat is added; controls (2106) are
unchanged. Cascade-pinned delta 0 (SAP/CO2/PE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:27:07 +00:00
Khalim Conn-Kowlessar
63dd69ff8b feat(modelling): gas combi boiler upgrade + controls-when-inadequate
Extend the gas-boiler-upgrade Option to combi (no-cylinder) dwellings and add
the controls upgrade shared by both boiler shapes. A dwelling has a cylinder or
it does not, so the one `gas_boiler_upgrade` Option is shaped per dwelling:

- no cylinder -> a gas condensing combi (Table 4b code 104), no cylinder fields
  touched;
- a cylinder  -> a regular boiler (code 102) heating it, with the conditional
  cylinder jacket/thermostat (slice 1).

Controls: bring an inadequate boiler control up to full programmer + room
thermostat + TRVs (SAP 10.2 Table 4e Group 1 code 2106). "Inadequate" = the
Group-1 codes with NO room thermostat (2101, 2102, 2107, 2108, 2109, 2111) —
these lack boiler interlock (Table 4c(2) / footnote c) p.171), so adding a room
thermostat genuinely improves SAP. Room-thermostatted (2103/2104/2105/2106/2113)
or better zone controls (2110/2112) are left unchanged — never downgraded, so
no phantom uplift. The with-cylinder cert (control 2106) is therefore untouched
and its pin still holds at delta 0.

Validated by the combi before/after re-lodgement (cert 001431, gas boiler
upgrade - no cylinder): control 2111 "TRVs and bypass" -> 2106, fan flue
False->True, SAP code 112 -> 104. Cascade-pinned delta 0 (SAP/CO2/PE). Removed
the slice-1 placeholder test asserting no boiler Option fires without a cylinder
(the combi Option now correctly fires there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:28:00 +00:00
Khalim Conn-Kowlessar
31c74ab500 feat(modelling): gas-boiler-upgrade-with-cylinder option in the heating rec
Add the first boiler-upgrade option to the single "Heating & Hot Water"
Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats
a hot-water cylinder is offered a new gas condensing boiler, with the cylinder
jacketed when under-insulated and given a thermostat when absent. One competing
Option (the Optimiser picks <=1), folded into one composite Plan line.

The end-state is read from the Elmhurst before/after re-lodgements (cert 001431,
gas boiler upgrade - with cylinder), which REVISE ADR-0024:

- Target is always a gas condensing boiler, not fuel-preserving: every after
  lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a
  mains-gas connection; electric boilers are left alone (electrification is the
  upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 /
  151-161 / 191-196) + not an electric boiler + mains gas present.
- End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler
  + cylinder). The calculator derives the condensing seasonal efficiency from
  the code, so no efficiency input exists or is needed.
- A modern condensing boiler has a fanned flue: the after flips
  `fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan +
  the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay
  field, routed to main_heating_details[0].
- Cylinder thermostat is always added when absent (user-locked); the jacket is
  the 80 mm `cylinder_insulation_type=2` end-state, applied only when the
  cylinder is below 80 mm (never downgrading a better one). Both are conditional
  per-dwelling components, not a frozen overlay.

Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via
`_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is
subject to a separate Summary-path mapper roof-fidelity gap (we read the roof
better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is
identical on before+after (the boiler measure never touches the roof) so it
cancels and the pin still proves the exact heating field-delta. Tracked on the
calculator branch.

Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26),
the offline sample catalogue, the catalogue-coverage list, and the ARA
first-run integration seed (the option fires on any mains-gas boiler+cylinder
dwelling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:16:46 +00:00
Khalim Conn-Kowlessar
7942a8101a feat(modelling): considered_measures allowlist on the orchestrator
Add domain/modelling/considered_measures.py::restrict_to_considered_measures —
the pure allowlist that limits a run to a chosen set of MeasureType (mirroring
the legacy engine's `inclusions`). It filters at the Option level, so a
multi-option Recommendation (e.g. Heating & Hot Water competing HHRSH against
an ASHP bundle) is kept with only its allowed Options; a Recommendation left
with none is dropped. None = consider everything (unrestricted default).

Thread `considered_measures: frozenset[MeasureType] | None` through
ModellingOrchestrator.run -> _plan_for -> _scored_candidate_groups /
_candidate_recommendations (applies the filter) and _measure_dependencies
(suppresses a forced dependency whose required measure is outside the
allowlist, so a restricted run forces nothing it is not considering). The
local-run seam (harness.console.run_modelling) gains the same param.

The Optimiser still freely chooses among survivors — including none. Tests:
the pure filter (3 cases) + an orchestrator-seam test proving a
{solar_pv}-restricted run yields only solar_pv options. 257 pass + 3 xfail;
pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:32:11 +00:00
Khalim Conn-Kowlessar
9ef97be958 refactor(modelling): type measure_type fields as MeasureType
Tighten the recommendation/plan vocabulary off generic str:
MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType
(also _GlazingTarget.measure_type, MeasureDependency.triggers ->
frozenset[MeasureType], and the optimiser's chosen/required-type locals).
Because MeasureType is a StrEnum the change is transparent to persistence
(the `recommendation` varchar column), the optimiser group-by key, and every
`== "solar_pv"`-style comparison — so pyright now enforces the enum at every
construction site with no runtime behaviour change.

The catalogue boundary stays str: ProductRepository.get(measure_type: str)
and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so
the fake product repos in tests need no edit. Test construction helpers coerce
their str arg via MeasureType(...); direct constructions use members.

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on production + tests (pre-existing moto + property-
override-rowcount baselines untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:13:31 +00:00
Khalim Conn-Kowlessar
d58ac60d29 feat(modelling): MeasureType StrEnum as the canonical measure vocabulary
Introduce domain/modelling/measure_type.py — a StrEnum with one member per
modelled measure (the 15 the generators emit). A StrEnum so each member *is*
its string value: it persists straight into the `recommendation` varchar
column, is the optimiser's group-by key, and compares equal to the catalogue /
EPC strings — so it replaces the per-generator string constants with no
persistence or optimiser change.

Repoint every generator's measure-type constant/literal to a MeasureType
member (wall, solid_wall, roof, floor, glazing, lighting, ventilation,
heating, solar). Field annotations stay `str` for now; tightening them to
MeasureType is the next slice.

This is the enum the historical engine deferred (engine.py:970
"TODO - formalise property measure types into an enum") and the vocabulary the
forthcoming `considered_measures` allowlist will speak (mirroring the legacy
`inclusions`).

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on the enum + generators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:54:04 +00:00
Khalim Conn-Kowlessar
09cb8ceb9d feat(modelling): recommend_solar — eligibility + competing array Options
Slice 6 of the Solar PV Recommendation Generator (ADR-0026). `recommend_solar`
emits one "Solar PV" Recommendation of up to five conservatively-sized configs
× {no battery, battery} = ≤10 competing Options (a free Optimiser candidate).
Each Option folds a SolarOverlay built from the chosen config: one
PhotovoltaicArray per non-north segment (peak_power = panels × panelCapacityW /
1000; orientation/pitch from geometry; generation-calibrated overshading),
is_dwelling_export_capable set True absolutely, a diverter when the dwelling
has a cylinder (None for a combi), a 5 kWh battery for the battery variant, and
the per-config composite cost from Products.solar_bundle_cost.

Eligibility = house/bungalow ∧ not listed/heritage (blocks_internal, the same
gate as ASHP — a conservation area does NOT block PV) ∧ no existing PV ∧ a
feasible SolarPotential. Flats and existing-PV top-up are deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:14:24 +00:00
Khalim Conn-Kowlessar
46bca47365 feat(modelling): Products.solar_bundle_cost + committed solar rate sheet
Slice 7 of the Solar PV Recommendation Generator (ADR-0026). Adds the
composite per-dwelling Solar PV cost on the Products collection (ADR-0025
pattern): pv_system(kWp band, nearest of the ECOPV06-13 EA bands 1.0→4.5 kWp,
floor/cap at the ends) + scaffolding(£900 first elevation + £450 each
additional, default 2) + enabling base (EICR £150 + DNO £50 + 2-way consumer
unit £330) + [diverter £980 if cylinder] + [battery if the with-battery
variant] → Cost(total, contingency_rate 0.15).

Rates are data in the committed solar_rates.json (Southern Housing "SOLAR PV &
BATTERY" EA column), loaded via SolarRates.from_json/.default and injectable.
The £2,000 / 5 kWh battery is NOT on the rate sheet — a flagged estimate
(battery_estimate=true), confirmed with the user to stand in until a DB rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:10:27 +00:00
Khalim Conn-Kowlessar
9dddfa00c8 feat(modelling): SolarOverlay + _fold_solar (sixth overlay surface)
Slice 5 of the Solar PV Recommendation Generator (ADR-0026). Adds the flat
`SolarOverlay` and `_fold_solar`, the sixth Simulation Overlay surface: like
the ventilation/lighting overlays it targets no building part and folds its
fields onto `sap_energy_source` (home of the SAP Appendix M PV inputs) —
photovoltaic_arrays (absolute target, one PhotovoltaicArray per non-north
segment, replacing the dwelling's existing arrays), pv_diverter_present,
pv_connection, is_dwelling_export_capable (set True absolutely), pv_batteries.
Omitted fields leave the baseline unchanged (combi → no diverter); the
baseline is never mutated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:10:15 +00:00
Khalim Conn-Kowlessar
c03f4ff123 feat(modelling): conservative PV config selection (5-config spread)
Slice 4 of the Solar PV Recommendation Generator (ADR-0026).
`select_conservative_configs` turns Google's full solarPanelConfigs ladder
into up to five competing array configs for the Optimiser: drop north-facing
planes (within 30° of due north, wrap-aware), cap usable panels at ~70% of
maxArrayPanelsCount (imagery misses obstructions; MCS edge setback), collapse
rungs that trim to the same usable size keeping the higher-generation layout,
then sample five spanning min→max by expected generation. Returns () when
nothing usable remains.

Real London example → 5 rungs at 4/12/19/26/34 panels (all ≤34.3 = 70% of
49); synthetic cases pin the north-drop and the 70% cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:02:15 +00:00
Khalim Conn-Kowlessar
82c3422788 feat(modelling): generation-calibrated PV overshading derivation
Slice 3 of the Solar PV Recommendation Generator (ADR-0026). Per roof segment,
back-solve the effective overshading factor ZPV from Google's expected
generation against SAP's own unshaded annual output:

    ZPV = (yearlyEnergyDcKwh × 0.955) / (0.8 × kWp × S)

reusing the calculator's Appendix U3.3 annual solar radiation S via a new
public seam `pv_annual_solar_radiation_kwh_per_m2`. Dividing Google's
generation by SAP's S cancels orientation/tilt and isolates shading; the
result snaps to the RdSAP bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35} via the
ADR-0026 midpoint cutpoints (≥0.90→1, 0.65–0.90→2, 0.425–0.65→3, <0.425→4;
ZPV>1→1). The real London example's planes all back-solve to ZPV>1 → code 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:59:48 +00:00
Khalim Conn-Kowlessar
f31d5bcff9 feat(modelling): typed SolarPotential projection over Google buildingInsights
Slice 2 of the Solar PV Recommendation Generator (ADR-0026). Adds the
strictly-typed `SolarPotential` domain projection over the raw Google Solar
`buildingInsights` JSON that Ingestion persists (SolarRepository): the
`solarPanelConfigs` ladder, each rung broken into its roof segments with
Google's continuous azimuth/tilt mapped to the SAP octant
(`azimuth_to_sap_octant`, 0°=N clockwise → 1=N..8=NW, matching the
calculator's ORIENTATION_BY_SAP10_CODE) and RdSAP §11.1 pitch code
(`pitch_to_sap_code`, snap to {0→1,30→2,45→3,60→4,90→5}).

Pinned against the real London buildingInsights example (mirrored into
fixtures from the user-provided RTF): 400 W panels, maxArrayPanelsCount 49,
46-rung ladder, per-segment SE/NW/NE/SW octants at ~32° → pitch code 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:55:55 +00:00
Khalim Conn-Kowlessar
dd92ba5972 refactor(modelling): load ASHP rates from a committed costs file
Slice 10 of ADR-0025 costing. The Southern Housing rate table moves from code
constants into ashp_rates.json (structured rows the flat scalar catalogue can't
hold), loaded via AshpRates.from_json. Products takes an injected AshpRates
(default: the committed sheet), so rates are now data -- tunable (e.g.
reuse_distribution_fraction) without a code change, and ready for ETL/DB-supplied
rates later. Behaviour-preserving: the 6 pinned cost tests still hold against the
default, plus a new test proving injected rates drive the total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:19:53 +00:00
Khalim Conn-Kowlessar
f06a048a6f feat(modelling): ASHP option carries the composite per-dwelling cost
Slice 9 of ADR-0025 costing. _ashp_option now prices via Products.ashp_bundle_
cost(ashp_cost_inputs(epc)) instead of the flat catalogue scalar; the catalogue
row is still read for its material_id. Pinned on boiler-3: gas reuse dwelling
composes to 15600.60 (decommission 720 + pump 9720 + cylinder 2382.60 + reuse
distribution 2778) with 25% contingency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:17:06 +00:00
Khalim Conn-Kowlessar
6f136a8d6a feat(modelling): classify ASHP existing system by fuel code
Slice 8 of ADR-0025 costing. _existing_system keys on the heating fuel code,
not the mains_gas flag -- the 001431 electric fixtures all lodge mains_gas=True
(gas available at the property) while heating electrically (fuel 30), which the
flag-based check misread as gas (and would have wrongly reused a non-existent
wet system). Electric/gas/oil/LPG map to their categories; empty details ->
NONE; unrecognised -> OTHER (gas-line fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:10:45 +00:00
Khalim Conn-Kowlessar
f182f36802 feat(modelling): ashp_cost_inputs reads a dwelling into AshpCostInputs
Slice 7 of ADR-0025 costing: the modelling-layer interpretation half of the
split. ashp_cost_inputs derives existing system (mains_gas/fuel/SAP-code),
size band (floor area <= 75 m2), design heat loss (floor_area x 0.05 -- the
chosen proxy over HLC, ADR updated), radiator count (habitable + 3, floor-area
fallback) and reusable-wet-system flag. Catalogue math (Products) stays
EPC-free. ADR-0025 updated to record the floor-area pump-sizing choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:59:27 +00:00