mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): Products.solar_bundle_cost + committed solar rate sheet
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>
This commit is contained in:
parent
9dddfa00c8
commit
46bca47365
4 changed files with 292 additions and 1 deletions
|
|
@ -20,6 +20,7 @@ _CONTINGENCY_RATES: dict[str, float] = {
|
|||
"low_energy_lighting": 0.26,
|
||||
"high_heat_retention_storage_heaters": 0.10,
|
||||
"air_source_heat_pump": 0.25,
|
||||
"solar_pv": 0.15,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,14 @@ from domain.modelling.contingencies import contingency_rate
|
|||
from domain.modelling.recommendation import Cost
|
||||
|
||||
_ASHP_MEASURE_TYPE = "air_source_heat_pump"
|
||||
_SOLAR_MEASURE_TYPE = "solar_pv"
|
||||
|
||||
# The committed ASHP rate sheet (ADR-0025) — structured rate rows the flat
|
||||
# scalar catalogue cannot hold; loaded into `AshpRates`.
|
||||
_ASHP_RATES_PATH = Path(__file__).resolve().parent / "ashp_rates.json"
|
||||
# The committed Solar PV rate sheet (ADR-0026) — the Southern Housing "SOLAR PV
|
||||
# & BATTERY" EA-rate column; loaded into `SolarRates`.
|
||||
_SOLAR_RATES_PATH = Path(__file__).resolve().parent / "solar_rates.json"
|
||||
|
||||
_MIN_RADIATORS = 4
|
||||
_MAX_RADIATORS = 12
|
||||
|
|
@ -94,6 +98,67 @@ class AshpRates:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolarRates:
|
||||
"""The Southern Housing "SOLAR PV & BATTERY" EA rate table (ADR-0026) —
|
||||
fully-loaded supply+install rates. Data, not code: the committed default
|
||||
loads from `solar_rates.json`, and a caller can inject a variant (e.g. to
|
||||
replace the flagged battery estimate with a DB rate)."""
|
||||
|
||||
# pv_system install price by kWp band (ECOPV06-13, slate roof), ascending.
|
||||
pv_system_by_kwp: tuple[tuple[float, float], ...]
|
||||
scaffolding_first_elevation: float
|
||||
scaffolding_additional_elevation: float
|
||||
enabling_eicr: float
|
||||
enabling_dno: float
|
||||
enabling_consumer_unit: float
|
||||
# Myenergi Eddi microgeneration diverter (ECOPV30).
|
||||
diverter: float
|
||||
# Battery supply+install — NOT on the rate sheet; a flagged estimate
|
||||
# (`battery_estimate`) confirmed with the user to stand in until a DB rate.
|
||||
battery: float
|
||||
battery_estimate: bool
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "SolarRates":
|
||||
"""Load the committed Southern Housing solar rate sheet."""
|
||||
return cls.from_json(_SOLAR_RATES_PATH)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: Path) -> "SolarRates":
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
raw: dict[str, Any] = json.load(handle)
|
||||
bands: dict[str, Any] = raw["pv_system_by_kwp"]
|
||||
return cls(
|
||||
pv_system_by_kwp=tuple(
|
||||
sorted(
|
||||
(float(kwp), float(price)) for kwp, price in bands.items()
|
||||
)
|
||||
),
|
||||
scaffolding_first_elevation=float(raw["scaffolding_first_elevation"]),
|
||||
scaffolding_additional_elevation=float(
|
||||
raw["scaffolding_additional_elevation"]
|
||||
),
|
||||
enabling_eicr=float(raw["enabling_eicr"]),
|
||||
enabling_dno=float(raw["enabling_dno"]),
|
||||
enabling_consumer_unit=float(raw["enabling_consumer_unit"]),
|
||||
diverter=float(raw["diverter"]),
|
||||
battery=float(raw["battery"]),
|
||||
battery_estimate=bool(raw["battery_estimate"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolarCostInputs:
|
||||
"""The dwelling facts the Solar PV catalogue math needs — produced by the
|
||||
modelling layer's interpretation of a chosen array config (ADR-0026)."""
|
||||
|
||||
peak_power_kwp: float
|
||||
has_cylinder: bool
|
||||
has_battery: bool
|
||||
elevations: int = 2
|
||||
|
||||
|
||||
class AshpExistingSystem(Enum):
|
||||
"""The dwelling's pre-retrofit heating system, as it bears on decommission
|
||||
cost and whether a wet distribution system can be reused (ADR-0025). The
|
||||
|
|
@ -125,8 +190,15 @@ class Products:
|
|||
is not a single catalogue scalar (the ASHP bundle — ADR-0025). The ASHP rate
|
||||
table is data, injected as `AshpRates` (default: the committed rate sheet)."""
|
||||
|
||||
def __init__(self, rates: AshpRates | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
rates: AshpRates | None = None,
|
||||
solar_rates: SolarRates | None = None,
|
||||
) -> None:
|
||||
self._rates: AshpRates = rates if rates is not None else AshpRates.default()
|
||||
self._solar_rates: SolarRates = (
|
||||
solar_rates if solar_rates is not None else SolarRates.default()
|
||||
)
|
||||
|
||||
def ashp_bundle_cost(self, inputs: AshpCostInputs) -> Cost:
|
||||
"""Compose the fully-loaded ASHP bundle total for a dwelling and pair it
|
||||
|
|
@ -141,6 +213,39 @@ class Products:
|
|||
total=total, contingency_rate=contingency_rate(_ASHP_MEASURE_TYPE)
|
||||
)
|
||||
|
||||
def solar_bundle_cost(self, inputs: SolarCostInputs) -> Cost:
|
||||
"""Compose the fully-loaded Solar PV bundle total for a dwelling and
|
||||
pair it with the separate 15% solar contingency (ADR-0026)."""
|
||||
rates = self._solar_rates
|
||||
total: float = (
|
||||
self._pv_system(inputs.peak_power_kwp)
|
||||
+ self._scaffolding(inputs.elevations)
|
||||
+ rates.enabling_eicr
|
||||
+ rates.enabling_dno
|
||||
+ rates.enabling_consumer_unit
|
||||
+ (rates.diverter if inputs.has_cylinder else 0.0)
|
||||
+ (rates.battery if inputs.has_battery else 0.0)
|
||||
)
|
||||
return Cost(
|
||||
total=total, contingency_rate=contingency_rate(_SOLAR_MEASURE_TYPE)
|
||||
)
|
||||
|
||||
def _pv_system(self, peak_power_kwp: float) -> float:
|
||||
"""Price the pv_system install at the kWp band nearest the array size,
|
||||
flooring below the smallest band and capping at the largest."""
|
||||
bands = self._solar_rates.pv_system_by_kwp
|
||||
nearest_kwp, _ = min(bands, key=lambda band: abs(band[0] - peak_power_kwp))
|
||||
return dict(bands)[nearest_kwp]
|
||||
|
||||
def _scaffolding(self, elevations: int) -> float:
|
||||
"""£900 for the first elevation + £450 for each additional."""
|
||||
rates = self._solar_rates
|
||||
additional: int = max(0, elevations - 1)
|
||||
return (
|
||||
rates.scaffolding_first_elevation
|
||||
+ additional * rates.scaffolding_additional_elevation
|
||||
)
|
||||
|
||||
def _heat_pump(self, design_heat_loss_kw: float) -> float:
|
||||
"""Price the install at the smallest band that covers the design heat
|
||||
loss (round up); above the largest band, the top rate applies."""
|
||||
|
|
|
|||
21
domain/modelling/solar_rates.json
Normal file
21
domain/modelling/solar_rates.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"_source": "20260409 Eco Approach - DOMNA Southern Housing Group Rates.xlsx, 'SOLAR PV & BATTERY' tab, EA Rates column (ADR-0026).",
|
||||
"pv_system_by_kwp": {
|
||||
"1.0": 2410.0,
|
||||
"1.5": 2635.0,
|
||||
"2.0": 2890.0,
|
||||
"2.5": 2965.0,
|
||||
"3.0": 3115.0,
|
||||
"3.5": 3380.0,
|
||||
"4.0": 3490.0,
|
||||
"4.5": 3690.0
|
||||
},
|
||||
"scaffolding_first_elevation": 900.0,
|
||||
"scaffolding_additional_elevation": 450.0,
|
||||
"enabling_eicr": 150.0,
|
||||
"enabling_dno": 50.0,
|
||||
"enabling_consumer_unit": 330.0,
|
||||
"diverter": 980.0,
|
||||
"battery": 2000.0,
|
||||
"battery_estimate": true
|
||||
}
|
||||
164
tests/domain/modelling/test_solar_products.py
Normal file
164
tests/domain/modelling/test_solar_products.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""Behaviour of `Products.solar_bundle_cost` — the composite, per-dwelling
|
||||
Solar PV bundle cost (ADR-0026). Pure catalogue math over the Southern Housing
|
||||
"SOLAR PV & BATTERY" EA-rate column: pv_system(kWp band) + scaffolding +
|
||||
enabling base + [diverter if cylinder] + [battery if the with-battery variant],
|
||||
carrying the separate 15% solar contingency. No EpcPropertyData / calculator.
|
||||
|
||||
Pinned against the real rate sheet (delta <= 1e-9), mirroring the cascade-pin
|
||||
philosophy. The £2,000 battery is a flagged estimate — it is not on the rate
|
||||
sheet (ADR-0026), confirmed with the user to stand in until a DB rate lands.
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from domain.modelling.products import Products, SolarCostInputs, SolarRates
|
||||
from domain.modelling.recommendation import Cost
|
||||
|
||||
# pv_system EA bands (slate roof, ECOPV06-13) + the fixed adders.
|
||||
_ENABLING = 150.0 + 50.0 + 330.0 # EICR + DNO + 2-way consumer unit = 530
|
||||
_SCAFFOLD_TWO = 900.0 + 450.0 # first elevation + one additional = 1350
|
||||
|
||||
|
||||
def test_solar_bundle_cost_composes_a_small_array_with_cylinder() -> None:
|
||||
# Arrange — a 1.6 kWp array (4 × 400 W) on a dwelling with a cylinder (so a
|
||||
# diverter is added), two elevations of scaffolding, no battery.
|
||||
products = Products()
|
||||
inputs = SolarCostInputs(
|
||||
peak_power_kwp=1.6, has_cylinder=True, has_battery=False, elevations=2
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.solar_bundle_cost(inputs)
|
||||
|
||||
# Assert — pv(1.5 band 2635) + scaffold 1350 + enabling 530 + diverter 980
|
||||
# = 5495, with the separate 15% solar contingency.
|
||||
assert abs(cost.total - (2635.0 + _SCAFFOLD_TWO + _ENABLING + 980.0)) <= 1e-9
|
||||
assert abs(cost.total - 5495.0) <= 1e-9
|
||||
assert abs(cost.contingency_rate - 0.15) <= 1e-9
|
||||
|
||||
|
||||
def test_solar_bundle_cost_composes_a_large_array_with_battery_no_cylinder() -> None:
|
||||
# Arrange — a 4.8 kWp array (12 × 400 W) caps at the 4.5 kWp band; no
|
||||
# cylinder (no diverter), the with-battery variant.
|
||||
products = Products()
|
||||
inputs = SolarCostInputs(
|
||||
peak_power_kwp=4.8, has_cylinder=False, has_battery=True, elevations=2
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.solar_bundle_cost(inputs)
|
||||
|
||||
# Assert — pv(4.5 band 3690) + scaffold 1350 + enabling 530 + battery 2000
|
||||
# = 7570 (no diverter).
|
||||
assert abs(cost.total - (3690.0 + _SCAFFOLD_TWO + _ENABLING + 2000.0)) <= 1e-9
|
||||
assert abs(cost.total - 7570.0) <= 1e-9
|
||||
|
||||
|
||||
def _pv_line(products: Products, peak_power_kwp: float) -> float:
|
||||
"""Isolate the pv_system line: no cylinder, no battery, one elevation
|
||||
(scaffold 900) + enabling 530 = 1430 base, so total - base is the band
|
||||
price for ``peak_power_kwp``."""
|
||||
inputs = SolarCostInputs(
|
||||
peak_power_kwp=peak_power_kwp,
|
||||
has_cylinder=False,
|
||||
has_battery=False,
|
||||
elevations=1,
|
||||
)
|
||||
return products.solar_bundle_cost(inputs).total - (900.0 + _ENABLING)
|
||||
|
||||
|
||||
def test_pv_system_snaps_to_the_nearest_kwp_band() -> None:
|
||||
# Arrange
|
||||
products = Products()
|
||||
|
||||
# Act / Assert — EA bands {1.0:2410, 1.5:2635, 2.0:2890, 2.5:2965, 3.0:3115,
|
||||
# 3.5:3380, 4.0:3490, 4.5:3690}. Sub-1.0 floors to 1.0; above 4.5 caps to
|
||||
# 4.5; otherwise the nearest band.
|
||||
assert abs(_pv_line(products, 0.4) - 2410.0) <= 1e-9 # 1 panel → floor 1.0
|
||||
assert abs(_pv_line(products, 1.0) - 2410.0) <= 1e-9
|
||||
assert abs(_pv_line(products, 1.6) - 2635.0) <= 1e-9 # 4 panels → 1.5
|
||||
assert abs(_pv_line(products, 2.0) - 2890.0) <= 1e-9
|
||||
assert abs(_pv_line(products, 4.8) - 3690.0) <= 1e-9 # 12 panels → cap 4.5
|
||||
assert abs(_pv_line(products, 7.6) - 3690.0) <= 1e-9 # 19 panels → cap 4.5
|
||||
|
||||
|
||||
def test_scaffolding_scales_with_elevations() -> None:
|
||||
# Arrange — isolate scaffolding: 1.0 kWp (2410) + enabling 530, no cylinder,
|
||||
# no battery; total - 2940 is the scaffolding line.
|
||||
products = Products()
|
||||
base = 2410.0 + _ENABLING
|
||||
|
||||
def scaffold(elevations: int) -> float:
|
||||
return (
|
||||
products.solar_bundle_cost(
|
||||
SolarCostInputs(
|
||||
peak_power_kwp=1.0,
|
||||
has_cylinder=False,
|
||||
has_battery=False,
|
||||
elevations=elevations,
|
||||
)
|
||||
).total
|
||||
- base
|
||||
)
|
||||
|
||||
# Act / Assert — £900 first elevation + £450 each additional
|
||||
assert abs(scaffold(1) - 900.0) <= 1e-9
|
||||
assert abs(scaffold(2) - 1350.0) <= 1e-9
|
||||
assert abs(scaffold(3) - 1800.0) <= 1e-9
|
||||
|
||||
|
||||
def test_diverter_is_priced_only_with_a_cylinder() -> None:
|
||||
# Arrange — identical arrays differing only in cylinder presence.
|
||||
products = Products()
|
||||
with_cylinder = SolarCostInputs(
|
||||
peak_power_kwp=2.0, has_cylinder=True, has_battery=False, elevations=2
|
||||
)
|
||||
without_cylinder = replace(with_cylinder, has_cylinder=False)
|
||||
|
||||
# Act / Assert — the cylinder dwelling pays the £980 Myenergi Eddi diverter.
|
||||
delta = (
|
||||
products.solar_bundle_cost(with_cylinder).total
|
||||
- products.solar_bundle_cost(without_cylinder).total
|
||||
)
|
||||
assert abs(delta - 980.0) <= 1e-9
|
||||
|
||||
|
||||
def test_battery_is_priced_only_for_the_with_battery_variant() -> None:
|
||||
# Arrange
|
||||
products = Products()
|
||||
no_battery = SolarCostInputs(
|
||||
peak_power_kwp=2.0, has_cylinder=False, has_battery=False, elevations=2
|
||||
)
|
||||
with_battery = replace(no_battery, has_battery=True)
|
||||
|
||||
# Act / Assert — the £2,000 flagged-estimate battery line.
|
||||
delta = (
|
||||
products.solar_bundle_cost(with_battery).total
|
||||
- products.solar_bundle_cost(no_battery).total
|
||||
)
|
||||
assert abs(delta - 2000.0) <= 1e-9
|
||||
|
||||
|
||||
def test_solar_bundle_cost_uses_injected_rates() -> None:
|
||||
# Arrange — rates are data: a tweaked battery rate prices that battery.
|
||||
rates: SolarRates = replace(SolarRates.default(), battery=1500.0)
|
||||
products = Products(solar_rates=rates)
|
||||
inputs = SolarCostInputs(
|
||||
peak_power_kwp=2.0, has_cylinder=False, has_battery=True, elevations=2
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.solar_bundle_cost(inputs)
|
||||
|
||||
# Assert — pv(2.0 band 2890) + scaffold 1350 + enabling 530 + battery 1500 =
|
||||
# 6270.
|
||||
assert abs(cost.total - 6270.0) <= 1e-9
|
||||
|
||||
|
||||
def test_battery_rate_is_flagged_as_an_estimate() -> None:
|
||||
# Arrange / Act — the £2,000 battery is not on the rate sheet (ADR-0026).
|
||||
rates = SolarRates.default()
|
||||
|
||||
# Assert — flagged so it can be swapped from the DB later.
|
||||
assert rates.battery_estimate is True
|
||||
assert abs(rates.battery - 2000.0) <= 1e-9
|
||||
Loading…
Add table
Reference in a new issue