Model/domain/modelling/contingencies.py
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

36 lines
1.2 KiB
Python

"""Per-Measure-Type contingency rates.
The one cost component carried separately from a Product's fully-loaded total
(CONTEXT.md). Mirrors the legacy `recommendations/Costs.py::Costs.CONTINGENCIES`;
extended as each measure type lands.
"""
_CONTINGENCY_RATES: dict[str, float] = {
"cavity_wall_insulation": 0.10,
"loft_insulation": 0.10,
"sloping_ceiling_insulation": 0.10,
"flat_roof_insulation": 0.10,
"suspended_floor_insulation": 0.20,
"solid_floor_insulation": 0.26,
"mechanical_ventilation": 0.26,
"external_wall_insulation": 0.26,
"internal_wall_insulation": 0.26,
"double_glazing": 0.15,
"secondary_glazing": 0.15,
"low_energy_lighting": 0.26,
"high_heat_retention_storage_heaters": 0.10,
"air_source_heat_pump": 0.25,
"gas_boiler_upgrade": 0.26,
"solar_pv": 0.15,
}
def contingency_rate(measure_type: str) -> float:
"""Return the contingency rate for a Measure Type, raising if unknown
(strict — do not silently default, per the repo's strict-raise convention)."""
try:
return _CONTINGENCY_RATES[measure_type]
except KeyError as exc:
raise ValueError(
f"no contingency rate configured for measure type {measure_type!r}"
) from exc