Review findings on PR #1527:
- The 'which goals are goal-aligned' set lived in two adjacent if-chains
(_objective_for and _require_budget_for_goal_aligned) that had to stay in
sync — a new goal-aligned goal added to one but not the other would slip
the budget guard. Both now read a single _GOAL_ALIGNED_OBJECTIVES table.
- The goal strings are the canonical PortfolioGoal enum values, not
re-declared string constants, so goal-value drift can't silently degrade
a goal to max-SAP; _target_sap reads the enum too.
- _scored_candidate_groups takes objective without a default (its only
caller passes it).
- scoring.py: 'cached: float | None' -> Optional[float] per the CLAUDE.md
'Use Optional over | None' rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on PR #1527:
- The objective is required (no sap_rating default) on _repair_to_target,
_best_repair_candidate and _rescored_groups: every caller already passes
it, and a default would let a future call path silently optimise SAP for
a carbon/bill goal while pyright stayed green. The default stays on the
public optimise_package / optimise_package_fabric_first entry points.
- _best_repair_candidate hoists objective(current) out of the candidate
loop: current is loop-invariant, so for the Energy-Savings bill objective
this was one full BillDerivation.derive per candidate per repair iteration
for the same score.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on PR #1526, behaviour-preserving:
- No fabric groups, or phase 1 committed nothing → delegate to the plain
optimise_package: the phase-2 prefix would be empty and its re-scoring
would reproduce the role-1 signals the groups already carry, one full
SAP-calculator run per Option for zero effect (the common case on
already-insulated stock).
- Phase 1 consumed every group → return the fabric package instead of a
phase-2 pass that can only select the empty package.
- _rescored_groups takes start_sap from the caller: the post-fabric
baseline score is the phase-1 package score already in hand, not a
fresh calculator run.
- fabric_types → phase_one_types: the set holds everything phase 1
committed, injected dependencies included — that inclusion is what the
outstanding-dependencies filter relies on, so the name must not invite
narrowing it to FABRIC_MEASURE_TYPES.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
North-drop now runs once: the selection computes the usable (north-dropped)
ladder, feasibility filters it, and Sub-Ladder derivation consumes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The heat-network mirror of keep_existing_off_peak_meter: the override's
2313 default applies only when the replaced system's control is
cross-family (stale); an observed community control survives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sizing selects the aroTHERM plus rung nearest PSR ~0.8 (0.8 x design heat
loss) — the Appendix-N efficiency peak, reproducing the pump a real
installer fits — rather than the MCS PSR>=1.0 capacity target, which
oversizes. Validated against the relodged Elmhurst ASHP cert (delta 0
held at the 5 kW Vaillant); the two self-snapshot pins re-pin to their
correctly-sized larger pumps, and the orchestrator/harness thread the
calculator's design heat loss so production sizes to the dwelling. Manual
SapResult test stubs carry the new design_heat_loss_kw field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_ashp_option threads its sized design heat loss into ashp_cost_inputs so
the cost band and the pump's efficiency record share one figure — a leaky
dwelling's larger pump is priced on the matching band.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_ashp_option now selects the aroTHERM plus PCDB record matching the
threaded design heat loss (floor-area proxy fallback), so the overlay's
efficiency anchor is sized to the dwelling instead of a fixed 5 kW unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sizing ladder (Vaillant aroTHERM plus 3.5-12 kW PCDB records) picks
the smallest rung whose rated output meets the design heat loss, so SAP
10.2 Appendix N reads the heat-pump efficiency near its PSR peak instead
of collapsing on an undersized fixed unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The high-heat-retention storage-heater (HHRSH) recommendation was computed
as LOWERING SAP and wrongly rejected by the Optimiser, leaving electric-
room-heater flats/maisonettes stuck at band E with no sensible plan.
Root cause: `_HHR_STORAGE_OVERLAY` sets `sap_main_heating_code=409` (HHR
storage) + `meter_type="Dual"` but left `main_heating_category` at the
pre-existing value (10 = electric room heaters). The SAP 10.2 Table 12a
space-heating high-rate-fraction resolver classifies by category, so it
misread the off-peak storage heating as direct-acting electric (1.00
high-rate) and priced 100% at the 7-hour peak rate (15.29p) instead of the
storage off-peak split (0.00, ~5.5p).
Verified against accredited Elmhurst RdSAP-10 on UPRN 100020942571
(8 Booth Close, SE28 8BW): engine HHRSH SAP 48 (peak-priced, cost £1087)
vs Elmhurst 71 (cost ~£611); with the fix the engine gives 73 / £576.
Two-part fix (defence in depth, so no future overlay/mapper can
reintroduce it):
- `_HHR_STORAGE_OVERLAY` now sets `main_heating_category=7`, keeping the
overlaid EPC internally consistent.
- `_table_12a_system_for_main` now classifies electric storage heaters by
their Table 4a SAP code (401-409) as well as `category==7`, checked
before the room-heater branch, so a specific storage code is
authoritative over a stale/inconsistent category.
Tests: new regression `test_storage_heater_classified_by_sap_code_over_stale_category`;
updated the HHRSH overlay pin. Full accuracy corpus still passes (no regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gov-API certs that lodge a gas boiler as main_heating_category=2 + a PCDB
main_heating_index_number but omit sap_main_heating_code were carried with
a null code, so the heating generators (which gated on sap_main_heating_code
alone) never evaluated them for a boiler upgrade or a system tune-up — they
silently got nothing (£0 works, plateau at D). The SAP calculator already
handles this shape (cert_to_inputs._is_wet_boiler_main), so the calculator
modelled these as wet boilers while the recommenders ignored them.
Mirror the calculator's classification in the generator:
- _is_wet_boiler / _is_electric_boiler fall back, when no SAP code is
lodged, to a PCDB Table 105 boiler index or main_heating_category in
{1,2} (heat pumps excluded); electric boilers stay excluded by the code
range when lodged, else by an electricity main fuel.
- _already_condensing reads the boiler's declared PCDB winter efficiency
when there is no Table 4b code, so a modern condensing boiler lodged this
way still isn't offered a pointless like-for-like swap (gets a tune-up).
Verified read-only on portfolio 814 / scenario 1271: pids 742304, 742306,
742322, 742325, 742336 now get a gas_boiler_upgrade (two cross D->C on the
boiler alone) where they had £0 works; predicted properties whose heating
donor is a PCDB-shape boiler (e.g. 742095/742102/742105) also recover a
tune-up. Fixes the A#2 slice of #1388 (and unblocks 742302/742367).
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>
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>
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>