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

38 lines
1.3 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,
"system_tune_up": 0.15,
"system_tune_up_zoned": 0.15,
"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