forked from johanzander/bess-manager-beta
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsettings.py
More file actions
330 lines (280 loc) · 12.8 KB
/
Copy pathsettings.py
File metadata and controls
330 lines (280 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
"""Core configuration values and types for BESS using dataclasses.
IMPORTANT: This file contains DEFAULT VALUES only.
The values in this file serve as:
1. Settings for unit tests and development
2. Internal algorithm parameters not exposed to users
All user-facing settings should be configured and overridden via config.yaml:
- Battery settings (capacity, power, cycle_cost, min_action_profit_threshold)
- Electricity price settings (area, markup_rate, vat_multiplier, additional_costs, tax_reduction)
- Home settings (consumption, voltage, fuse_current, safety_margin_factor)
For production configuration, all user-facing values must be properly configured in config.yaml.
"""
from dataclasses import dataclass, field, fields
from typing import Any
# Price settings defaults
DEFAULT_AREA = ""
MARKUP_RATE = 0.08 # per kWh in configured currency
VAT_MULTIPLIER = 1.25 # 25% VAT
ADDITIONAL_COSTS = (
0.773 # grid transfer + energy tax incl. VAT, e.g. E.ON: (0.2584 + 0.3600) x 1.25
)
TAX_REDUCTION = (
0.1988 # export compensation (Nätnytta) per kWh, e.g. E.ON: 0.1988 SEK/kWh
)
SPOT_MULTIPLIER = 1.0 # multiplicative factor on spot (1.0 = no adjustment)
EXPORT_SPOT_MULTIPLIER = 1.0 # multiplicative factor on spot for sell price
SELL_PRICE_EQUALS_BUY_PRICE = False # net metering (e.g. NL saldering): sell = buy
MIN_PROFIT = 0.2 # Minimum profit per kWh to consider a charge/discharge cycle
USE_ACTUAL_PRICE = False # Use raw Nordpool spot prices or include markup, VAT, etc.
# Battery settings defaults
BATTERY_STORAGE_SIZE_KWH = 30.0
BATTERY_MIN_SOC = 10 # percentage
BATTERY_MAX_SOC = 100 # percentage
BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW = 15.0
BATTERY_CHARGE_CYCLE_COST = 0.40 # per kWh excl. VAT
BATTERY_MIN_ACTION_PROFIT_THRESHOLD = (
0.0 # fixed minimum profit threshold for any battery action (0.0 for tests)
)
BATTERY_DEFAULT_CHARGING_POWER_RATE = 40 # percentage
BATTERY_EFFICIENCY_CHARGE = 0.97 # Mix of solar (98%) and grid (95%) charging
BATTERY_EFFICIENCY_DISCHARGE = 0.95 # DC-AC conversion losses
# Default LFP temperature derating curve: (temp_celsius, charge_rate_percent)
# Based on LFP battery characteristics (Battery University, manufacturer data)
DEFAULT_TEMPERATURE_DERATING_CURVE: list[tuple[float, float]] = [
(
-1.0,
20.0,
), # Below 0°C: heavily limited (battery heaters may allow some charging)
(0.0, 20.0), # At 0°C: heavily limited
(5.0, 50.0), # At 5°C: significant derating
(10.0, 80.0), # At 10°C: mild derating
(15.0, 100.0), # At 15°C+: full rate
]
# Consumption settings defaults
HOME_HOURLY_CONSUMPTION_KWH = 4.6
MIN_CONSUMPTION = 0.1
# Home electrical defaults
HOUSE_MAX_FUSE_CURRENT_A = 25 # Maximum fuse current in amperes
HOUSE_VOLTAGE_V = 230 # Line voltage
SAFETY_MARGIN_FACTOR = 1.0 # Safety margin for power calculations (100%)
# Safe to use 1.0 based on fuse trip characteristics:
# - 108% load: many hours before trip
# - 128% load: 15min-2hrs before trip
# - We monitor every 5min, so 100% is safe
# Currency defaults
DEFAULT_CURRENCY = "SEK" # Default currency for price display (override in config.yaml)
# BATTERY_CHARGE_CYCLE_COST is denominated in SEK. It approximates battery
# wear cost per kWh cycled, so it must be re-based per currency rather than
# reused as-is for non-Swedish installs.
CYCLE_COST_BY_CURRENCY: dict[str, float] = {
"SEK": BATTERY_CHARGE_CYCLE_COST,
"EUR": 0.035,
"GBP": 0.031,
}
@dataclass
class PriceSettings:
"""Price settings for electricity costs."""
area: str = DEFAULT_AREA
markup_rate: float = MARKUP_RATE
vat_multiplier: float = VAT_MULTIPLIER
additional_costs: float = ADDITIONAL_COSTS
tax_reduction: float = TAX_REDUCTION
spot_multiplier: float = SPOT_MULTIPLIER
export_spot_multiplier: float = EXPORT_SPOT_MULTIPLIER
# Net metering (e.g. NL "saldering", in force through 2026): exported
# energy offsets imported energy 1:1, so the effective sell price is the
# full buy price incl. markup, VAT and grid fees — not spot + compensation.
sell_price_equals_buy_price: bool = SELL_PRICE_EQUALS_BUY_PRICE
min_profit: float = MIN_PROFIT
use_actual_price: bool = USE_ACTUAL_PRICE
def update(self, **kwargs: Any) -> None:
"""Update settings from a snake_case dict — the store's native format.
Does not translate camelCase: both the startup and PATCH paths pass
snake_case store field names directly. CamelCase API payloads are
converted to snake_case in the API layer before reaching here
(issue #197).
"""
valid_fields = {f.name for f in fields(self)}
for key, value in kwargs.items():
if key not in valid_fields:
raise AttributeError(f"PriceSettings has no attribute '{key}'")
setattr(self, key, value)
@dataclass
class BatterySettings:
"""Battery settings with canonical snake_case names only."""
total_capacity: float = BATTERY_STORAGE_SIZE_KWH
min_soc: float = BATTERY_MIN_SOC # percentage
max_soc: float = BATTERY_MAX_SOC # percentage
max_charge_power_kw: float = BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW
max_discharge_power_kw: float = BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW
charging_power_rate: float = BATTERY_DEFAULT_CHARGING_POWER_RATE
cycle_cost_per_kwh: float = BATTERY_CHARGE_CYCLE_COST
min_action_profit_threshold: float = (
BATTERY_MIN_ACTION_PROFIT_THRESHOLD # NEW FIELD
)
efficiency_charge: float = BATTERY_EFFICIENCY_CHARGE
efficiency_discharge: float = BATTERY_EFFICIENCY_DISCHARGE
reserved_capacity: float = field(init=False)
min_soe_kwh: float = field(init=False)
max_soe_kwh: float = field(init=False)
def __post_init__(self):
if self.total_capacity <= 0:
raise ValueError(
f"total_capacity must be positive, got {self.total_capacity}"
)
self.min_soe_kwh = self.total_capacity * self.min_soc / 100.0
self.max_soe_kwh = self.total_capacity * self.max_soc / 100.0
self.reserved_capacity = self.min_soe_kwh
def update(self, **kwargs: Any) -> None:
"""Update settings from a snake_case dict — the store's native format.
Does not translate camelCase: both the startup and PATCH paths pass
snake_case store field names directly. CamelCase API payloads are
converted to snake_case in the API layer before reaching here
(issue #197, mirrored to Battery/Home in #219).
"""
valid_fields = {f.name for f in fields(self)}
for key, value in kwargs.items():
if key not in valid_fields:
raise AttributeError(f"BatterySettings has no attribute '{key}'")
setattr(self, key, value)
self.__post_init__()
def from_ha_config(self, config: dict) -> "BatterySettings":
if "battery" in config:
battery_config = config["battery"]
self.total_capacity = battery_config.get(
"total_capacity", BATTERY_STORAGE_SIZE_KWH
)
self.max_charge_power_kw = battery_config.get(
"max_charge_power_kw", BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW
)
self.max_discharge_power_kw = battery_config.get(
"max_discharge_power_kw", BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW
)
self.cycle_cost_per_kwh = battery_config.get(
"cycle_cost_per_kwh", BATTERY_CHARGE_CYCLE_COST
)
self.min_action_profit_threshold = battery_config.get(
"min_action_profit_threshold", BATTERY_MIN_ACTION_PROFIT_THRESHOLD
)
self.__post_init__()
return self
@dataclass
class HomeSettings:
"""Home electrical settings."""
max_fuse_current: int = HOUSE_MAX_FUSE_CURRENT_A
voltage: int = HOUSE_VOLTAGE_V
safety_margin: float = SAFETY_MARGIN_FACTOR
phase_count: int = 3
default_hourly: float = HOME_HOURLY_CONSUMPTION_KWH
min_valid: float = MIN_CONSUMPTION
currency: str = DEFAULT_CURRENCY
consumption_strategy: str = "sensor"
power_monitoring_enabled: bool = False
def __post_init__(self):
assert self.phase_count in (
1,
3,
), f"phase_count must be 1 or 3, got {self.phase_count}"
def update(self, **kwargs: Any) -> None:
"""Update settings from a snake_case dict — the store's native format.
Does not translate camelCase: both the startup and PATCH paths pass
snake_case store field names directly. CamelCase API payloads are
converted to snake_case in the API layer before reaching here
(issue #197, mirrored to Battery/Home in #219).
"""
valid_fields = {f.name for f in fields(self)}
for key, value in kwargs.items():
if key not in valid_fields:
raise AttributeError(f"HomeSettings has no attribute '{key}'")
setattr(self, key, value)
self.__post_init__()
def from_ha_config(self, config: dict) -> "HomeSettings":
"""Create instance from Home Assistant add-on config."""
if "home" in config:
home_config = config["home"]
self.max_fuse_current = home_config.get(
"max_fuse_current", HOUSE_MAX_FUSE_CURRENT_A
)
self.voltage = home_config.get("voltage", HOUSE_VOLTAGE_V)
self.safety_margin = home_config.get(
"safety_margin_factor", SAFETY_MARGIN_FACTOR
)
self.phase_count = home_config.get("phase_count", 3)
self.default_hourly = config["home"].get(
"consumption", HOME_HOURLY_CONSUMPTION_KWH
)
self.currency = config["home"].get("currency", DEFAULT_CURRENCY)
self.consumption_strategy = home_config.get(
"consumption_strategy", "sensor"
)
self.power_monitoring_enabled = home_config["power_monitoring_enabled"]
self.__post_init__()
return self
@dataclass
class TemperatureDeratingSettings:
"""Settings for temperature-based charge power derating.
When enabled, the optimizer reduces max charge power based on forecasted
outdoor temperature. This is important for batteries installed outdoors
where cold temperatures reduce LFP charging capacity.
Disabled by default since most batteries are installed indoors.
"""
enabled: bool = False
weather_entity: str = ""
derating_curve: list[tuple[float, float]] = field(
default_factory=lambda: list(DEFAULT_TEMPERATURE_DERATING_CURVE)
)
def from_ha_config(self, config: dict) -> "TemperatureDeratingSettings":
"""Load from add-on config."""
battery_config = config.get("battery", {})
derating_config = battery_config.get("temperature_derating", {})
if derating_config:
self.enabled = derating_config.get("enabled", False)
self.weather_entity = derating_config.get("weather_entity", "")
raw_curve = derating_config.get("derating_curve")
if raw_curve:
self.derating_curve = [
(float(point[0]), float(point[1])) for point in raw_curve
]
self.derating_curve.sort(key=lambda p: p[0])
return self
def interpolate_derating(temperature: float, curve: list[tuple[float, float]]) -> float:
"""Interpolate the derating curve to get charge rate percentage for a temperature.
Args:
temperature: Outdoor temperature in Celsius.
curve: Sorted list of (temp_celsius, charge_rate_pct) points.
Returns:
Charge rate as a percentage (0-100).
"""
if not curve:
return 100.0
# Below lowest point: use lowest point's value
if temperature <= curve[0][0]:
return curve[0][1]
# Above highest point: use highest point's value
if temperature >= curve[-1][0]:
return curve[-1][1]
# Find the two bracketing points and linearly interpolate
for i in range(len(curve) - 1):
t_low, rate_low = curve[i]
t_high, rate_high = curve[i + 1]
if t_low <= temperature <= t_high:
fraction = (temperature - t_low) / (t_high - t_low)
return rate_low + fraction * (rate_high - rate_low)
return 100.0
def apply_temperature_derating(
max_charge_power_kw: float,
temperatures: list[float],
derating_curve: list[tuple[float, float]],
) -> list[float]:
"""Calculate per-period max charge power based on temperature forecast.
Args:
max_charge_power_kw: Nominal max charge power in kW.
temperatures: List of forecasted temperatures (one per period).
derating_curve: Sorted list of (temp_celsius, charge_rate_pct) points.
Returns:
List of effective max charge power values (kW), one per period.
"""
return [
max_charge_power_kw * interpolate_derating(temp, derating_curve) / 100.0
for temp in temperatures
]