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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Slice 4 of ADR-0025 costing. ASHP is offered to any house regardless of fuel,
so _decommission now prices a fallback instead of raising: no system -> 0,
electric room/panel heaters -> electric-storage line, anything else -> gas
line (representative default). Never blocks ASHP eligibility.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of ADR-0025 costing. When the dwelling has a reusable wet system,
_distribution charges a power-flush (168) plus _REUSE_DISTRIBUTION_FRACTION
(0.5) of the full radiator band -- a documented stand-in for partial radiator
upsizing at ASHP flow temps, the headline uncertainty in the model. Without a
wet system the full new distribution is priced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of ADR-0025 costing. _decommission maps the existing system to its
Southern Housing line: gas/oil flat 720, LPG 960 (tank+fuel removal),
electric-storage 570/840 by property-size band. Unmapped systems raise for
now -- the no-system/electric-other/other fallbacks land in the next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the per-dwelling ASHP bundle costing (ADR-0025). Products is
the rich catalogue collection over Product, owning the catalogue math: given
a typed AshpCostInputs it sums the applicable Southern Housing rate lines
(decommission + heat-pump band + fixed cylinder + full wet distribution) into
a Cost with the separate 25% ASHP contingency. Pure -- no EpcPropertyData or
calculator. Pinned exact (1e-9) against the real rate sheet. Reuse branch,
decommission variants, fallbacks, band edges and radiator clamp follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ASHP bundle is a fixed whole-system end-state (confirmed: always the same
contractor cylinder), so the hot-water arrangement is fixed too. The overlay now
sets water_heating_code=901 ("from main system") absolutely, so a combi (909/611)
or electric (903/908) before is reset to HW-from-the-heat-pump — previously the
overlay relied on the before already lodging 901 (true for boiler-1, not in
general). No-op for the boiler-1 pin (stays 1e-4). Cascade pins for combi /
electric-with-cylinder befores await example certs. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_heating now receives planning_restrictions in the orchestrator (the
ASHP planning gate); the ASHP bundle joins the free candidate pool for every
house/bungalow. Catalogue + contingency (legacy 0.25) gain air_source_heat_pump;
report.py _triggers_for explains the ASHP trigger; the harness forcing test
covers it. Integration tests seed an air_source_heat_pump MaterialRow (ASHP
fires on every house, the broadest trigger yet). NB the optimiser correctly does
NOT select ASHP for an EPC-band goal — gas->electric does not improve the SAP
cost-rating; ASHP is a CO2/PE measure, selectable once non-EPC goals land. ASHP
bundle COMPLETE (S5-S7). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A typical mains-gas combi house re-lodged as an air-source heat pump closes at
1e-4 (gas-boiler 1 example from the technical specialist). Closes one named gap
the pin surfaced: a whole-system replacement to a PCDB-indexed system left the
old Table 4a sap_main_heating_code (104) beside the new heat-pump index, and the
stale code won the calculator's efficiency dispatch (hot water billed at boiler
not HP efficiency, ΔSAP 3.98). _fold_heating now enforces the mutual exclusion
of the two efficiency anchors (setting an index clears the SAP code and vice
versa). Also fixed a pre-existing pyright annotation in the lighting applicator
test. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the air-source heat-pump Option to the competing "Heating & Hot Water"
bundles. Its overlay is the absolute heat-pump end-state (fixed representative
PCDB index 101413 + category 4 + control 2210 + HWP cylinder + single meter +
off mains gas), pinned against the relodged after-cert next slice. Eligibility
is physical/planning only (ADR-0024, research-grounded): any non-flat
house/bungalow, not listed/heritage (PlanningRestrictions.blocks_internal —
conservation is offered with a caveat, not excluded), not already a heat pump;
floor area / built form / fuel / fabric are deliberately not gates. recommend_
heating gains a restrictions param (defaulted). An already-HHR electric house
now correctly gets ASHP as a better end-state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_heating joins the free candidate pool in _candidate_recommendations;
the HHR storage bundle reaches the optimised package for an electric/off-gas
dwelling. Catalogue + contingency (legacy 0.10) gain
high_heat_retention_storage_heaters; report.py _triggers_for explains the
heating trigger (electric/off-gas main); the harness _GENERATOR_MEASURE_TYPES
forcing test covers it. ASHP + boiler bundles still to come. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The same absolute-target HHR overlay reproduces the common relodged after from
two different base systems (existing electric storage; "no system present"
electric) — proving the bundle is a true whole-system end-state. Closes one
named gap the pin surfaced: the relodged HHR cylinder lodges
cylinder_thermostat='Y', so HeatingOverlay + _fold_heating + the HHRSH overlay
gain cylinder_thermostat (ΔSAP 0.065 -> <1e-4). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The heating Recommendation Generator (HHRSH first). Emits one "Heating & Hot
Water" Recommendation whose competing whole-system bundles the Optimiser picks
from; this slice builds the high-heat-retention storage Option. Its overlay is
the absolute HHR end-state (Table 4a code 409 + control 2404 + dual off-peak
meter + off-peak electric cylinder), pinned against the relodged after-cert in
the next slice. Eligibility translates legacy is_high_heat_retention_valid to
structured predicates (electric or off-gas main, not already HHR/heat-pump).
mains_gas and the heat emitter are unchanged by the measure, so unset. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 5th EpcSimulation overlay surface and the deepest applicator fold yet: a
heating bundle is a whole-system replacement, so _fold_heating routes its
absolute-target fields across main_heating_details[0] (fuel/emitter/control +
sap_main_heating_code OR index+category), sap_heating (water_heating_* +
cylinder), the top-level EpcPropertyData (has_hot_water_cylinder), and
sap_energy_source (meter_type, mains_gas). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 of the lighting generator (ADR-0023): run recommend_lighting in
_candidate_recommendations (no planning gate). Price low_energy_lighting in the
offline catalogue + contingency table (0.26, the legacy rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both. A run_modelling test pins
the wiring end-to-end (an incandescent-lit dwelling gets the LED upgrade in the
optimised package).
Downstream updates, all because lighting now fires on any cert with non-LED
bulbs: report.py gains the low_energy_lighting trigger (the non-LED counts); the
two golden-cert report tests and the multi-measure integration test now expect
low_energy_lighting alongside the fabric measures (the sample/golden EPCs lodge
low-energy-unknown bulbs); first-run integration seeds a low_energy_lighting
MaterialRow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 of the lighting generator (ADR-0023): detect non-LED bulbs
(incandescent + CFL + low-energy-unknown > 0) and emit one "Lighting"
Recommendation whose single low_energy_lighting Option converts every bulb to
LED — the overlay sets led = total, the other three counts 0. Priced as a flat
per-bulb average x the non-LED count, contingency 0.26; the description names
"LED" while the measure_type stays MEASURE_MAP-aligned. None when already
all-LED or no bulb counts are lodged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the lighting generator (ADR-0023): the first whole-dwelling,
top-level overlay surface. LightingOverlay carries the four fixed-lighting
bulb-count fields by their exact EPC names (all Optional, absolute counts) +
EpcSimulation.lighting. The applicator's _fold_lighting writes the non-None
counts directly onto the result EpcPropertyData by name (setattr) — simpler
than ventilation's nested fold since the counts live top-level. Baseline
unmutated; pyright strict clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With the mapper now in main, cert 001431 parses: it lodges four single-glazed
windows — codes 1 ("Single") and 15 ("single glazing, known data", a single
pane with manufacturer U/g). The generator only detected code 1, so it missed
two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary
glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but
exact).
A deterministic green pin proves the overlay reproduces the after's 14 windows
exactly. The full-SAP before->after pins are xfail(strict) tripwires: the
overlay nails the windows, but the measure also re-lodges percent_draughtproofed
84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP
fabric residual the overlay doesn't model yet — a glazing-measure coupling to
close later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0019 warns that wall_construction code 8 is Park home (PH), NOT system-
built. It was already excluded (8 isn't in the constructable-options map), but
only implicitly. Add an explicit early-return + named constant so a park home
can never be mis-keyed as system-built, with a pin as the tripwire. A park
home's proprietary panel is never EWI/IWI-suitable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room-in-roof carries its insulation on its own sloping/stud/gable surfaces
(RdSAP 10 §3.10, Table 17/18), which the roof overlay's flat
roof_insulation_thickness bump cannot model. Without a guard a RR with an
uninsulated loft fell through to the loft fallback and mis-recommended 300 mm
loft insulation. Return None when the main part lodges a sap_room_in_roof,
deferring until a dedicated RR branch lands (ADR-0021).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 of the glazing generator (ADR-0022): run recommend_glazing in
_candidate_recommendations, threading the Property's PlanningRestrictions so a
protected dwelling is offered secondary glazing instead of double (mirrors
recommend_solid_wall). Price both Measure Types in the offline catalogue
(double £600/window, secondary £510 -- the legacy 0.85x scaling) and the
contingency table (0.15, the legacy windows_glazing rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both entries exist.
run_modelling tests pin the wiring end-to-end on an all-single-glazed dwelling:
double when unrestricted, secondary when listed. The first-run integration test
seeds a double_glazing Product because its lodged EPC has a single-glazed
window. _single_glazed_epc() deep-copies build_epc() (which shares its window
objects) so the mutation can't leak into other tests' baselines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of the glazing generator (ADR-0022): a conservation/listed/heritage
protection (PlanningRestrictions.blocks_external) hard-picks secondary_glazing
instead of double_glazing -- an internal second pane, since the external units
can't be replaced on a protected building. Each single-glazed window upgrades
to the secondary target pinned from cert 001431 (glazing_type=7, u_value=2.90,
solar_transmittance=0.85 -- the outer single pane still drives solar gain).
The before/after cascade pins for both measures remain deferred behind the
glazing-label mapper coverage (owned by another agent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The glazing Recommendation Generator (ADR-0022): detect single-glazed
windows (SAP10.2 Table U2 code 1) and emit one "Windows" Recommendation whose
single Option rewrites every single-glazed window to the double-glazing target
pinned from cert 001431's before->after (glazing_type=5, u_value=1.40,
solar_transmittance=0.72). The overlay writes the per-window U/g into
WindowTransmissionDetails because the calculator consumes those directly.
Priced as a flat per-window average x count. No single-glazed windows -> None.
Planning gate (-> secondary) and the before/after cascade pins land next; the
pins are blocked on glazing-label mapper coverage (owned by another agent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the glazing generator (ADR-0022). `WindowOverlay` (all-optional
partial of one SapWindow) + `EpcSimulation.windows` keyed by sap_windows index.
The applicator folds it onto sap_windows[i]: glazing_type flat on the window,
u_value/solar_transmittance routed into its WindowTransmissionDetails (created
if absent) — the applicator's first nested write, because that's where the
calculator reads window heat loss and solar gain. Baseline left unmutated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 (ADR-0021). The roof dispatcher can now emit sloping_ceiling_insulation
and flat_roof_insulation, so wire both into contingencies and the sample
catalogue; the forcing-function test now asserts every generator measure type
is both priced and has a contingency rate, so an offline/live run over a
sloping or flat roof never dies on a missing entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 (ADR-0021). The dispatcher gains a flat-roof branch: a "flat"
roof_construction_type with no lodged thickness (uninsulated → None on the
Elmhurst path) gets a single flat_roof_insulation Option whose overlay raises
roof_insulation_thickness to 200 mm — tested before the loft fallback so a flat
roof's None doesn't trip the loft trigger. Pinned against the Elmhurst
before→after cert at 1e-4. Golden cohort roof firing unchanged (none across 57).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2 (ADR-0021). `recommend_roof_insulation` now owns the loft branch as the
fallback — a plain pitched loft, a thatched roof (the covering doesn't block
insulating the loft floor), or an unlodged roof type all take loft (joist)
insulation at 300 mm when `roof_insulation_thickness == 0`. Sloping is tested
first; a no-access roof gets nothing. Retired the standalone
`recommend_loft_insulation`; the orchestrator and its tests now call the
dispatcher.
Pinned: thatch before→after (None→300) reproduces at 1e-4; the existing loft pin
still holds through the dispatcher. Behaviour-preserving on the golden cohort
(roof measure unchanged: none across all 57) — the dispatch is strictly more
precise (won't fire loft on a sloping/no-access roof).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the roof-insulation generator (ADR-0021). New `recommend_roof_insulation`
dispatcher keys on the `roof_construction_type` string: a "sloping ceiling" roof
that is uninsulated (roof_insulation_thickness 0/None) gets a single
`sloping_ceiling_insulation` Option whose overlay raises roof_insulation_thickness
to 100 mm. Pinned against the Elmhurst before→after cert 001431 at 1e-4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
System-built (precast/no-fines concrete) takes both solid-wall Options like
solid brick (ADR-0019), keyed on `wall_construction == 6` (WALL_SYSTEM_BUILT,
Elmhurst `SY`). A basement-suitability guard (`main_wall_is_basement`) is added
since a below-ground basement wall is never EWI/IWI-suitable.
This is currently inert: `B Basement wall` also maps to 6 (mapper.py:2100) and
`main_wall_is_basement` is derived as `wall_construction == 6`, so every code-6
wall reads as basement and is guarded out — the live cohort is unchanged. The
system-built EWI/IWI cascade pin is committed as a strict-xfail tripwire that
flips green the moment the calculator disambiguates system-built from basement
(MAIN wall_construction==6 with main_wall_is_basement False). `wall_construction
== 8` is Park home, not system-built — not keyed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3a (ADR-0020). PlanningRestrictions relocated out of the solid-wall
generator into domain/geospatial/ as the shared, Property-level value object
(three distinct flags + measure-specific blocks_external/blocks_internal).
GeospatialRepository gains a non-abstract planning_restrictions_for defaulting
to None (sources without the flags need not implement it); GeospatialS3Repository
reads conservation_status/is_listed_building/is_heritage_building from the same
Open-UPRN partition as the coordinates (legacy column names — to confirm in the
S3 deep-dive). Shared _row_for helper dedups the partition lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2e. recommend_solid_wall joins the orchestrator's fabric generator pool
(restrictions default unrestricted until slice 3 sources them); the harness
catalogue + contingencies (26%) gain external_wall_insulation /
internal_wall_insulation. run_modelling on an uninsulated solid-brick dwelling
(baseline SAP 36.6) now selects internal wall insulation into the optimised
package; the catalogue-completeness guard covers both new measure types.
Golden cohort 57/57 still error-free; IWI now fires on a real cohort cert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2d. A flat can take IWI (its own unit) but not EWI (whole-block
coordination) — ADR-0019. _is_flat handles both ingestion representations:
the Elmhurst name form ('Flat') and the API stringified RdSAP code ('2' = Flat
per PROPERTY_TYPE_LOOKUP). Completes slice 2's eligibility surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2c. recommend_solid_wall takes a PlanningRestrictions value object
(defaults unrestricted): a conservation area removes the EWI Option (external
appearance), a listed or heritage building removes both EWI and IWI (protected
fabric) -> None when nothing survives (ADR-0019). Plus a guard that a cavity
wall yields no solid-wall Recommendation (it is handled by recommend_cavity
_wall). PlanningRestrictions will be sourced onto the Property from the
geospatial layer in slice 3 (ADR-0020).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2b. Timber frame (wall_construction=5) takes internal wall insulation but
not external (not constructable — ADR-0019), so the generator offers IWI only.
Cascade pin: the IWI Option reproduces the re-lodged timber-frame after at
abs(diff) <= 1e-4 (general Table 6 insulation-thickness bucket, not the solid-
brick documentary path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2a. New recommend_solid_wall emits one Main-wall Recommendation carrying
External + Internal wall-insulation Options for an uninsulated (wall_insulation
_type=4) solid-brick (wall_construction=3) main wall, each priced at the heat-
loss wall area. Cascade pin: the generator's EWI and IWI Options reproduce
their respective re-lodged afters at abs(diff) <= 1e-4.
Detection keys on wall_construction code, not description (ADR-0019 note
corrected): the Elmhurst ingestion path leaves walls[].description empty, so
the code is the only cross-path signal; codes 1-5 are consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of solid-wall insulation. BuildingPartOverlay gains a
wall_insulation_thickness field; the generic applicator already folds it onto
SapBuildingPart by name. With wall_insulation_type=1 (EWI) / 3 (IWI) + 100 mm,
the calculator derives the post-insulation U-value (§5.8 documentary path,
λ=0.04 default) — and for IWI also lowers the thermal-mass parameter. Two new
Elmhurst before/after cascade pins (solid-brick EWI + IWI, cert 001431)
reproduce the re-lodged after at abs(diff) <= 1e-4 across SAP/CO2/PE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post
band jump and works+contingency cost, given one external input — the
Property's current market value (a Property Valuation, mostly absent).
`Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other
headline figures; `PlanModel.from_domain` maps the £ forms to the live
plan.valuation_* columns (NULL when no value — the percentage is not
persisted on those columns). `Property.current_market_value` is the new
optional source; the orchestrator threads it onto the Plan. `run_one`
takes a `current_market_value` so the harness can value the uplift, and
the sense-check table shows the average % (always) plus the £ forms when
known.
Sourcing the current market value (upload / default) remains deferred
(ADR-0018); it is None throughout until that lands, so the columns stay
NULL at scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The financial-uplift model per ADR-0018. `estimate_valuation_uplift(
current_band, target_band, current_value=None, total_cost=None)` returns
a `ValuationUplift`: band-transition uplift compounded from four broker
tables (MoneySupermarket / Lloyds per-step, Knight Frank / Rightmove
whole-jump), taking min/max/mean across the covering sources. Always a
percentage; absolute £ forms (increase at each bound + post-retrofit
value) only when a current market value is supplied; the 2x ROI cap
rescales the percentages and can only bite once a value is known. A
non-improving jump is a clean 0% no-op.
Pure function, no external dependency. Persisting it (where the value
lands) and sourcing the current market value stay deferred (ADR-0018).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand half of the recommendation_materials retirement (ADR-0017). A
Plan Measure installs a single Product, so thread its catalogue id end to
end — Product.id -> MeasureOption.material_id -> PlanMeasure.material_id
-> recommendation.material_id — replacing the per-material BOM child
table with one nullable column on the row. ProductPostgresRepository
reads the id from MaterialRow; the four fabric generators set it on their
Option; the orchestrator carries it onto the Plan Measure; the mirror
declares + maps the column. Optional throughout (the JSON stopgap
catalogue carries no ids -> NULL).
The multi-measure integration test now pins each persisted measure's
material_id to its seeded MaterialRow id. Migration spec (live column
must be added before this deploys; contraction is the owner's next step)
in docs/migrations/recommendation-material-id.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PortfolioGoal is domain vocabulary (a Scenario's goal — legacy planning branches
on PortfolioGoal.INCREASING_EPC), so it belongs in domain/ co-located with
scenario.py, mirroring how domain/epc/wall_type.py holds an enum that
infrastructure/ imports. This lets the consolidated ScenarioModel (next slice)
source the goal enum from domain without an infra→backend dependency.
portfolio.py keeps a re-export so every existing
`from ...portfolio import PortfolioGoal` caller is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_plan_for` now scores the baseline + every cumulative prefix once
(`cascade_scores`, best-practice order) and reuses those Scores for both the
role-3 marginal attribution and a per-measure bill cascade: bill each prefix at
one Fuel Rates snapshot and take consecutive Bill deltas as each measure's
marginal delivered-kWh and £ saving. Saving is signed (ventilation is
negative) and telescopes exactly to the Plan headline savings, because the
Plan's baseline/post Bills are now the same cascade endpoints (`bills[0]` /
`bills[-1]`) — which also drops the redundant standalone baseline `calculate`.
`recommendation.kwh_savings` / `energy_cost_savings` are filled from these.
Adds `Bill.total_consumption_kwh` (shared by Plan + the orchestrator). Pinned
end-to-end on the real calculator: Σ per-measure savings == the Plan totals
(ADR-0014 amendment).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`PlanMeasure` grows optional `kwh_savings` (delivered energy) and
`energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed
so positive is a saving and `None` until billing runs. `RecommendationRow`
declares the matching live `recommendation.kwh_savings` /
`energy_cost_savings` columns and maps them in `from_domain` (None → NULL).
The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0).
No FE migration — the columns already exist on the live table (ADR-0014 / 0017).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>