forked from johanzander/bess-manager-beta
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinverter_controller.py
More file actions
553 lines (464 loc) · 22.1 KB
/
Copy pathinverter_controller.py
File metadata and controls
553 lines (464 loc) · 22.1 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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
"""Base class for inverter controllers.
Follows the PriceSource pattern (core/bess/price_manager.py). Subclasses
implement hardware-specific schedule conversion and deployment.
"""
import logging
from abc import ABC, abstractmethod
from typing import ClassVar
from .dp_schedule import DPSchedule
from .settings import BatterySettings
logger = logging.getLogger(__name__)
class InverterController(ABC):
"""Abstract base class for inverter controllers.
Provides shared state and methods common to all inverter types.
Subclasses implement hardware-specific schedule conversion and deployment.
Strategic Intent → Control Mapping:
- GRID_CHARGING → grid_charge=True, charge_rate=<action-derived>, discharge_rate=0
- SOLAR_STORAGE → grid_charge=False, charge_rate=100, discharge_rate=0
- LOAD_SUPPORT → grid_charge=False, charge_rate=0, discharge_rate=<action-derived>
- BATTERY_EXPORT → grid_charge=False, charge_rate=0, discharge_rate=<action-derived>
- SOLAR_EXPORT → grid_charge=False, charge_rate=100, discharge_rate=0
- IDLE → grid_charge=False, charge_rate=100, discharge_rate=0
"""
# Map strategic intents to inverter control settings.
# Shared across all inverter types: determines grid_charge, charge_rate, discharge_rate.
INTENT_TO_CONTROL: ClassVar[dict[str, dict[str, bool | int]]] = {
"GRID_CHARGING": {"grid_charge": True, "charge_rate": 100, "discharge_rate": 0},
"SOLAR_STORAGE": {
"grid_charge": False,
"charge_rate": 100,
"discharge_rate": 0,
},
"LOAD_SUPPORT": {"grid_charge": False, "charge_rate": 0, "discharge_rate": 100},
"BATTERY_EXPORT": {
"grid_charge": False,
"charge_rate": 0,
"discharge_rate": 100,
},
"SOLAR_EXPORT": {"grid_charge": False, "charge_rate": 100, "discharge_rate": 0},
"IDLE": {"grid_charge": False, "charge_rate": 100, "discharge_rate": 0},
}
# Map strategic intents to battery modes (shared across Growatt MIN and SPH).
INTENT_TO_MODE: ClassVar[dict[str, str]] = {
"GRID_CHARGING": "battery_first",
"SOLAR_STORAGE": "load_first",
"LOAD_SUPPORT": "load_first",
"BATTERY_EXPORT": "grid_first",
"SOLAR_EXPORT": "load_first",
"IDLE": "load_first",
}
# Human-readable descriptions of strategic intents.
INTENT_DESCRIPTIONS: ClassVar[dict[str, str]] = {
"GRID_CHARGING": "Storing cheap grid energy for later use",
"SOLAR_STORAGE": "Storing excess solar energy for evening/night",
"LOAD_SUPPORT": "Using battery to support home consumption",
"BATTERY_EXPORT": "Selling stored energy to grid for profit",
"SOLAR_EXPORT": "Solar surplus exporting directly to grid",
"IDLE": "No significant battery activity",
}
# ── Platform capabilities ──────────────────────────────────────────────
# Subclasses override to declare what the hardware supports.
# Per-period charge/discharge rate register that power monitoring can
# read and write. False on platforms that bake power % into atomic
# TOU schedule writes (SPH, SolaX native).
supports_charge_rate_control: ClassVar[bool] = True
def __init__(self, battery_settings: BatterySettings) -> None:
"""Initialize shared inverter controller state."""
if battery_settings is None:
raise ValueError("battery_settings is required and cannot be None")
self.battery_settings = battery_settings
self.max_charge_power_kw = battery_settings.max_charge_power_kw
self.max_discharge_power_kw = battery_settings.max_discharge_power_kw
self.current_schedule: DPSchedule | None = None
self.strategic_intents: list[str] = []
self.tou_intervals: list[dict] = []
self.corruption_detected: bool = False
# ── Period utility ────────────────────────────────────────────────────────
def _period_to_time(self, period: int) -> tuple[int, int]:
"""Convert period number (0-95) to (hour, minute).
Note: During DST fall-back, periods >= 96 produce hour >= 24.
Callers must handle this (e.g., cap to 23:59 for TOU schedules).
"""
return period // 4, (period % 4) * 15
# ── Intent → hardware rates ───────────────────────────────────────────────
def compute_rates_for_period(
self, period: int, battery_action_kw: float
) -> tuple[bool, int]:
"""Map strategic intent for a period to hardware control rates.
Args:
period: 15-minute period index (0-95)
battery_action_kw: Battery power in kW (positive=charge, negative=discharge)
Returns:
Tuple of (grid_charge, discharge_rate_percent)
"""
intent = self.strategic_intents[period]
return self._map_intent_to_rates(intent, battery_action_kw)
@staticmethod
def _scale_to_percent(power_kw: float, max_power_kw: float) -> int:
"""Scale a power value to a 0-100 percent rate, clamped to range."""
return min(100, max(0, round(power_kw / max_power_kw * 100)))
def _compute_charge_rate(
self, intent: str, control: dict[str, bool | int], battery_action_kw: float
) -> int:
"""Compute charge_rate for a period, action-derived for GRID_CHARGING.
Args:
intent: Strategic intent string
control: The INTENT_TO_CONTROL entry for this intent
battery_action_kw: Battery power in kW (positive=charge)
Returns:
Charge rate percent (0-100)
"""
if intent == "GRID_CHARGING" and battery_action_kw > 0.01:
return self._scale_to_percent(battery_action_kw, self.max_charge_power_kw)
return control["charge_rate"]
def _effective_grid_charge(self, intent: str, grid_charge: bool) -> bool:
"""Apply the external_solar_mode override for SOLAR_STORAGE.
On AC-coupled PV setups the battery inverter has no DC solar input,
so the only physical charging path is the grid (surplus solar
returns through the meter). When external_solar_mode is enabled,
SOLAR_STORAGE periods must use grid_charge=True or the battery
sits idle the entire solar window.
"""
if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
return True
return grid_charge
def _effective_mode_for_intent(self, intent: str, default_mode: str) -> str:
"""Apply the external_solar_mode override for the battery mode.
For DC-coupled setups, Load First mode is correct for SOLAR_STORAGE
because the inverter naturally routes surplus solar (seen on its own
MPPT) to the battery. On AC-coupled setups the battery inverter has
no DC solar input, so Load First mode produces no charging action
even with grid_charge enabled — the EMS waits for a trigger that
never comes. Switching SOLAR_STORAGE to Battery First makes the
inverter actively charge from the AC side during the planned solar
window.
Trade-off: Battery First charges at the configured rate regardless
of actual solar surplus, so during a SOLAR_STORAGE period with
insufficient solar export the battery will draw from the grid.
BESS only plans SOLAR_STORAGE when the forecast shows surplus, so
the risk is bounded by forecast accuracy.
"""
if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
return "battery_first"
return default_mode
def _map_intent_to_rates(
self, intent: str, battery_action_kw: float
) -> tuple[bool, int]:
"""Map a strategic intent to (grid_charge, discharge_rate).
Args:
intent: Strategic intent string
battery_action_kw: Battery power in kW (used for BATTERY_EXPORT and LOAD_SUPPORT scaling)
Returns:
Tuple of (grid_charge, discharge_rate_percent)
"""
if intent == "GRID_CHARGING":
return True, 0
elif intent == "SOLAR_STORAGE":
return self._effective_grid_charge(intent, False), 0
elif intent in ("LOAD_SUPPORT", "BATTERY_EXPORT"):
if battery_action_kw < -0.01:
discharge_rate = self._scale_to_percent(
abs(battery_action_kw), self.max_discharge_power_kw
)
else:
discharge_rate = 0
return False, discharge_rate
elif intent == "SOLAR_EXPORT":
return False, 0
elif intent == "IDLE":
return False, 0
else:
raise ValueError(f"Unknown strategic intent: {intent}")
def apply_period(
self, controller, grid_charge: bool, discharge_rate: int
) -> tuple[bool, str]:
"""Write period control settings to hardware.
The caller (BatterySystemManager) is responsible for applying the
discharge inhibit check before calling this method.
Args:
controller: HomeAssistantAPIController instance
grid_charge: Whether to enable grid charging
discharge_rate: Discharge power rate (0-100%), post-inhibit
Returns:
Tuple of (success, error_message). error_message is empty on success.
"""
return self._write_period_to_hardware(controller, grid_charge, discharge_rate)
def get_period_settings(self, period: int) -> dict:
"""Get control settings for a specific 15-minute period.
Args:
period: Period index (0-95 normally, varies during DST)
Returns:
Dict with grid_charge, charge_rate, discharge_rate,
strategic_intent, batt_mode
"""
if not self.strategic_intents:
raise ValueError("No strategic intents available")
if period < 0 or period >= len(self.strategic_intents):
raise ValueError(
f"Period {period} out of range [0, {len(self.strategic_intents)})"
)
intent = self.strategic_intents[period]
mode = self._effective_mode_for_intent(intent, self.INTENT_TO_MODE[intent])
if (
self.current_schedule is not None
and self.current_schedule.actions
and period < len(self.current_schedule.actions)
):
battery_action_kwh = self.current_schedule.actions[period]
num_periods = len(self.current_schedule.actions)
period_duration_hours = 24.0 / num_periods
battery_action_kw = battery_action_kwh / period_duration_hours
grid_charge, discharge_rate = self.compute_rates_for_period(
period, battery_action_kw
)
charge_rate = self._compute_charge_rate(
intent, self.INTENT_TO_CONTROL[intent], battery_action_kw
)
else:
control = self.INTENT_TO_CONTROL[intent]
grid_charge = self._effective_grid_charge(intent, control["grid_charge"])
charge_rate = control["charge_rate"]
discharge_rate = control["discharge_rate"]
return {
"grid_charge": grid_charge,
"charge_rate": charge_rate,
"discharge_rate": discharge_rate,
"strategic_intent": intent,
"batt_mode": mode,
}
def get_strategic_intent_summary(self) -> dict:
"""Get a summary of strategic intents for the day (aggregated from quarterly periods)."""
if not self.strategic_intents:
return {}
num_periods = len(self.strategic_intents)
num_hours = (num_periods + 3) // 4
intent_hours: dict[str, list[int]] = {}
for hour in range(num_hours):
start_p = hour * 4
end_p = min(start_p + 4, num_periods)
period_intents = [self.strategic_intents[p] for p in range(start_p, end_p)]
intent_counts: dict[str, int] = {}
for intent in period_intents:
intent_counts[intent] = intent_counts.get(intent, 0) + 1
max_count = max(intent_counts.values())
dominant = min(i for i, c in intent_counts.items() if c == max_count)
if dominant not in intent_hours:
intent_hours[dominant] = []
intent_hours[dominant].append(hour)
return {
intent: {
"hours": hours,
"count": len(hours),
"description": self.INTENT_DESCRIPTIONS.get(intent, "Unknown intent"),
}
for intent, hours in intent_hours.items()
}
def _get_intent_description(self, intent: str) -> str:
"""Get human-readable description of strategic intent."""
return self.INTENT_DESCRIPTIONS.get(intent, "Unknown intent")
def get_detailed_period_groups(
self,
intents: list[str] | None = None,
actions: list[float] | None = None,
soc_values: list[float | None] | None = None,
) -> list[dict]:
"""Get period groups with full control parameters for display/API.
Groups consecutive 15-minute periods ONLY when ALL parameters are identical:
strategic intent, battery mode, grid charge, charge rate, and discharge rate.
Args:
intents: Optional list of strategic intents to group. If None,
uses self.strategic_intents (today's schedule).
actions: Optional list of battery actions in kWh per period (negative=discharge).
If None, reads from self.current_schedule.actions. If current_schedule
is also None or the period is out of range, action defaults to 0.0.
soc_values: Optional per-period SOC end values (%). The last period's value
in each group is exposed as soc_end_pct in the result.
Returns:
List of period groups with all control parameters and time strings
"""
effective_intents = intents if intents is not None else self.strategic_intents
if not effective_intents:
return []
num_periods = len(effective_intents)
schedule_actions: list[float] | None = None
if actions is not None:
schedule_actions = actions
elif self.current_schedule is not None:
schedule_actions = self.current_schedule.actions
period_settings = []
for period in range(num_periods):
intent = effective_intents[period]
mode = self._effective_mode_for_intent(
intent, self.INTENT_TO_MODE.get(intent, "load_first")
)
control = self.INTENT_TO_CONTROL.get(
intent,
{"grid_charge": False, "charge_rate": 100, "discharge_rate": 0},
)
action_kwh = 0.0
if schedule_actions is not None and period < len(schedule_actions):
action_kwh = schedule_actions[period]
action_kw = action_kwh / 0.25
_, discharge_rate = self._map_intent_to_rates(intent, action_kw)
charge_rate = self._compute_charge_rate(intent, control, action_kw)
period_settings.append(
{
"period": period,
"intent": intent,
"mode": mode,
"grid_charge": self._effective_grid_charge(
intent, control["grid_charge"]
),
"charge_rate": charge_rate,
"discharge_rate": discharge_rate,
"action_kwh": action_kwh,
}
)
groups = []
current_group: dict | None = None
for ps in period_settings:
if current_group is not None and (
ps["intent"] == current_group["intent"]
and ps["mode"] == current_group["mode"]
and ps["grid_charge"] == current_group["grid_charge"]
and ps["charge_rate"] == current_group["charge_rate"]
and ps["discharge_rate"] == current_group["discharge_rate"]
):
current_group["end_period"] = ps["period"]
current_group["count"] += 1
current_group["total_action_kwh"] += ps["action_kwh"]
else:
if current_group is not None:
groups.append(current_group)
current_group = {
"start_period": ps["period"],
"end_period": ps["period"],
"intent": ps["intent"],
"mode": ps["mode"],
"grid_charge": ps["grid_charge"],
"charge_rate": ps["charge_rate"],
"discharge_rate": ps["discharge_rate"],
"count": 1,
"total_action_kwh": ps["action_kwh"],
}
if current_group is not None:
groups.append(current_group)
result = []
for group in groups:
start_h, start_m = self._period_to_time(group["start_period"])
end_h, end_m = self._period_to_time(group["end_period"])
end_m += 14
if end_h >= 24:
end_h = 23
end_m = 59
end_period = group["end_period"]
soc_end: float | None = None
if soc_values is not None and end_period < len(soc_values):
soc_end = soc_values[end_period]
result.append(
{
"start_time": f"{start_h:02d}:{start_m:02d}",
"end_time": f"{end_h:02d}:{end_m:02d}",
"start_period": group["start_period"],
"end_period": group["end_period"],
"intent": group["intent"],
"mode": group["mode"],
"grid_charge": group["grid_charge"],
"charge_rate": group["charge_rate"],
"discharge_rate": group["discharge_rate"],
"period_count": group["count"],
"duration_minutes": group["count"] * 15,
"total_action_kwh": group["total_action_kwh"],
"soc_end_pct": soc_end,
}
)
return result
# ── Abstract interface ────────────────────────────────────────────────────
@property
@abstractmethod
def active_tou_intervals(self) -> list[dict]:
"""Return the subset of TOU intervals currently written to hardware."""
@abstractmethod
def create_schedule(
self,
schedule: DPSchedule,
current_period: int = 0,
previous_tou_intervals: list[dict] | None = None,
) -> None:
"""Build hardware-specific schedule from DPSchedule."""
@abstractmethod
def write_schedule_to_hardware(
self,
controller,
effective_period: int,
current_tou: list,
) -> tuple[int, int]:
"""Write schedule to inverter hardware.
Returns:
Tuple of (writes, disables)
"""
@abstractmethod
def compare_schedules(
self, other_schedule: "InverterController", from_period: int = 0
) -> tuple[bool, str]:
"""Compare schedules. Returns (schedules_differ, reason)."""
@abstractmethod
def read_and_initialize_from_hardware(self, controller, current_hour: int) -> None:
"""Read current schedule from inverter and initialize this controller."""
@abstractmethod
def sync_soc_limits(self, controller) -> None:
"""Sync SOC limits from config to inverter hardware."""
def initialize_hardware(self, controller) -> None: # noqa: B027
"""Write initial hardware configuration required before normal operation.
Called once at startup (after demo mode blocks are cleared). Subclasses
override to perform whatever one-time writes their hardware requires.
The default is a no-op so controllers with no startup writes need not
override it.
"""
def _write_period_to_hardware(
self, controller, grid_charge: bool, discharge_rate: int
) -> tuple[bool, str]:
"""Write per-period control settings to hardware.
Default implementation uses Growatt register interface (grid_charge +
discharge_rate). SolaX overrides with VPP commands.
Args:
controller: HomeAssistantAPIController instance
grid_charge: Whether to enable grid charging
discharge_rate: Discharge power rate (0-100%)
Returns:
Tuple of (success, error_message). error_message is empty on success.
"""
errors = []
try:
controller.set_grid_charge(grid_charge)
except Exception as e:
logger.error("FAILED: set_grid_charge(%s): %s", grid_charge, e)
errors.append(str(e))
try:
controller.set_discharging_power_rate(discharge_rate)
except Exception as e:
logger.error(
"FAILED: set_discharging_power_rate(%s): %s", discharge_rate, e
)
errors.append(str(e))
if errors:
return False, "; ".join(errors)
return True, ""
@abstractmethod
def get_all_tou_segments(self) -> list[dict]:
"""Return all TOU segments for API/display consumption."""
@abstractmethod
def get_daily_TOU_settings(self) -> list[dict]:
"""Return TOU settings for display/API consumption."""
@abstractmethod
def log_current_TOU_schedule(self, header: str = "") -> None:
"""Log current TOU schedule."""
@abstractmethod
def log_detailed_schedule(self, header: str = "") -> None:
"""Log detailed schedule with per-period information."""
@abstractmethod
def check_health(self, controller) -> list:
"""Check inverter control capabilities.
Returns:
List of health check result dicts
"""