mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
97 lines
3.5 KiB
Python
97 lines
3.5 KiB
Python
"""The dwelling interpretation that feeds `Products.boiler_bundle_cost` /
|
|
`tune_up_cost` — reading an `EpcPropertyData` into typed cost inputs (ADR-0027).
|
|
The modelling-layer half of the split: it derives the radiator count, which
|
|
standard-control parts are already fitted (from the SAP Table 4e control code),
|
|
whether the boiler upgrade fires a controls change, and which cylinder fixes
|
|
apply — the catalogue math (Products) stays EPC-free.
|
|
"""
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
from domain.modelling.generators.heating_recommendation import (
|
|
boiler_cost_inputs,
|
|
tune_up_cost_inputs,
|
|
)
|
|
from domain.modelling.products import BoilerCostInputs, TuneUpCostInputs
|
|
from tests.domain.modelling._elmhurst_recommendation import (
|
|
parse_recommendation_summary,
|
|
)
|
|
|
|
|
|
def test_tune_up_inputs_from_no_controls_charge_every_part() -> None:
|
|
# Arrange — control 2101 ("no control"): no programmer, room thermostat or
|
|
# TRVs fitted; an uninsulated, un-thermostatted cylinder; 10 radiators.
|
|
epc: EpcPropertyData = parse_recommendation_summary(
|
|
"tune_up_from_2101_001431_before.pdf"
|
|
)
|
|
|
|
# Act
|
|
inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=False)
|
|
|
|
# Assert
|
|
assert inputs == TuneUpCostInputs(
|
|
is_zoned=False,
|
|
radiator_count=10,
|
|
has_programmer=False,
|
|
has_room_thermostat=False,
|
|
has_trvs=False,
|
|
needs_cylinder_jacket=True,
|
|
needs_cylinder_thermostat=True,
|
|
)
|
|
|
|
|
|
def test_tune_up_inputs_read_existing_control_parts() -> None:
|
|
# Arrange — control 2113 ("room thermostat and TRVs"): already has a room
|
|
# thermostat + TRVs, only the programmer is missing. The is_zoned flag is
|
|
# passed through.
|
|
epc: EpcPropertyData = parse_recommendation_summary(
|
|
"tune_up_from_2113_001431_before.pdf"
|
|
)
|
|
|
|
# Act
|
|
inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=True)
|
|
|
|
# Assert — so the standard cost would charge only the programmer.
|
|
assert inputs.is_zoned is True
|
|
assert inputs.has_programmer is False
|
|
assert inputs.has_room_thermostat is True
|
|
assert inputs.has_trvs is True
|
|
|
|
|
|
def test_boiler_inputs_flag_a_controls_upgrade_for_inadequate_controls() -> None:
|
|
# Arrange — a combi (no cylinder) with inadequate controls (2111 "TRVs and
|
|
# bypass", no room thermostat): the boiler upgrade also fires the standard
|
|
# controls, which already has TRVs but no programmer/room thermostat.
|
|
epc: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_combi_gas_001431_before.pdf"
|
|
)
|
|
|
|
# Act
|
|
inputs: BoilerCostInputs = boiler_cost_inputs(epc)
|
|
|
|
# Assert
|
|
assert inputs == BoilerCostInputs(
|
|
upgrades_controls=True,
|
|
radiator_count=7,
|
|
has_programmer=False,
|
|
has_room_thermostat=False,
|
|
has_trvs=True,
|
|
needs_cylinder_jacket=False,
|
|
needs_cylinder_thermostat=False,
|
|
)
|
|
|
|
|
|
def test_boiler_inputs_no_controls_upgrade_when_already_adequate() -> None:
|
|
# Arrange — a gas boiler with a cylinder and already-adequate controls
|
|
# (2106): the boiler doesn't fire a controls change, but both cylinder fixes
|
|
# apply (uninsulated, un-thermostatted).
|
|
epc: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_gas_001431_before.pdf"
|
|
)
|
|
|
|
# Act
|
|
inputs: BoilerCostInputs = boiler_cost_inputs(epc)
|
|
|
|
# Assert
|
|
assert inputs.upgrades_controls is False
|
|
assert inputs.needs_cylinder_jacket is True
|
|
assert inputs.needs_cylinder_thermostat is True
|