-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathbattery_system_manager.py
More file actions
3776 lines (3293 loc) · 169 KB
/
Copy pathbattery_system_manager.py
File metadata and controls
3776 lines (3293 loc) · 169 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
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Complete replacement for battery_system.py that preserves ALL functionality.
"""
import json
import logging
import os
import traceback
from datetime import UTC, date, datetime, timedelta
from typing import Any, ClassVar
from . import time_utils
from .daily_view_builder import DailyView, DailyViewBuilder
from .daily_view_store import DailyViewStore
from .dp_battery_algorithm import (
OptimizationResult,
optimize_battery_schedule,
print_optimization_results,
)
from .dp_schedule import DPSchedule
from .entsoe_source import EntsoeSource
from .exceptions import (
HAStatisticsUnavailableError,
HistoricalDataUnavailableError,
SystemConfigurationError,
)
from .execution_model import PlatformCapabilities, intra_period_discharge_gate
from .growatt_min_controller import GrowattMinController
from .growatt_sph_controller import GrowattSphController
from .ha_api_controller import HomeAssistantAPIController
from .health_check import describe_failing_checks, run_system_health_checks
from .health_recovery_tracker import HealthRecovery, HealthRecoveryTracker
from .historical_data_store import HistoricalDataStore
from .huawei_controller import HuaweiController
from .influxdb_helper import get_power_sensor_data_batch, is_influxdb_configured
from .inverter_controller import InverterController
from .models import (
DecisionData,
EconomicData,
EconomicSummary,
PeriodData,
apply_export_curtailment_to_period_data,
infer_intent_from_flows,
)
from .octopus_energy_source import OctopusEnergySource
from .official_nordpool_source import OfficialNordpoolSource
from .power_monitor import HomePowerMonitor
from .prediction_snapshot import PredictionSnapshotStore, _period_data_from_dict
from .price_manager import HomeAssistantSource, PriceManager, PriceSource
from .runtime_failure_tracker import RuntimeFailureTracker
from .schedule_store import ScheduleStore
from .sensor_collector import SensorCollector
from .settings import (
BatterySettings,
HomeSettings,
PriceSettings,
TemperatureDeratingSettings,
apply_temperature_derating,
)
from .solax_controller import SolaxController
from .solax_modbus_growatt_controller import SolaxModbusGrowattController
from .solis_modbus_controller import SolisModbusController
from .terminal_value import terminal_value_breakdown
from .time_utils import (
format_period,
get_period_count,
period_index_to_timestamp,
)
from .weather import fetch_temperature_forecast
logger = logging.getLogger(__name__)
class BatterySystemManager:
"""
Complete replacement for the original BatterySystemManager.
This implementation:
- Preserves ALL original functionality
- Maintains the exact same API and interface
- Implements proper component separation
- Fixes all broken functionality in minimal implementations
- Can be used as a drop-in replacement
"""
def __init__(
self,
controller: HomeAssistantAPIController | None = None,
price_source: PriceSource | None = None,
energy_provider_config: dict | None = None,
addon_options: dict | None = None,
):
"""Initialize with same interface as original BatterySystemManager."""
# Initialize settings (preserve original defaults)
self.battery_settings = BatterySettings()
self.home_settings = HomeSettings()
self.price_settings = PriceSettings()
self._energy_provider_config = energy_provider_config or {}
# Initialize temperature derating (opt-in, disabled by default)
self.temperature_derating = TemperatureDeratingSettings()
self.temperature_derating.from_ha_config(addon_options or {})
# Store controller reference
self._controller = controller
# Initialize core data stores with proper component separation
self.historical_store = HistoricalDataStore(self.battery_settings)
self.schedule_store = ScheduleStore()
self.prediction_snapshot_store = PredictionSnapshotStore()
self.daily_view_store = DailyViewStore()
# Initialize specialized components
self.sensor_collector = SensorCollector(controller, self.battery_settings)
# Initialize view builder
self.daily_view_builder = DailyViewBuilder(
self.historical_store,
self.schedule_store,
self.battery_settings,
)
# Resolve initial inverter platform from config.
# On a fresh install no inverter platform is configured yet — the
# controller stays None until the user completes the setup wizard.
self.inverter_platform: str | None = self._resolve_initial_platform(
addon_options or {}
)
self.control_mode: str = self._resolve_control_mode(
addon_options or {}, self.inverter_platform
)
self._inverter_controller: InverterController | None = (
self._create_inverter_controller()
)
# Initialize price manager
if not price_source:
price_source = self._create_price_source(controller)
self._price_manager = PriceManager(
price_source=price_source,
markup_rate=self.price_settings.markup_rate,
vat_multiplier=self.price_settings.vat_multiplier,
additional_costs=self.price_settings.additional_costs,
tax_reduction=self.price_settings.tax_reduction,
area=self.price_settings.area,
spot_multiplier=self.price_settings.spot_multiplier,
export_spot_multiplier=self.price_settings.export_spot_multiplier,
)
# Initialize monitors (created in start() if controller available)
self._power_monitor = None
# Current schedule tracking
self._current_schedule = None
self._initial_soc_pct = None # SOC at midnight (%), set at period 0
# Discharge inhibit tracking
self._desired_discharge_rate: int = 0 # Rate from schedule before inhibit
self._desired_grid_charge: bool = False # grid_charge alongside the rate above
self._desired_block_passive_charging: bool = False # alongside the rate above
self._desired_strategic_intent: str = "" # alongside the rate above
self._last_applied_discharge_rate: int = 0 # Last rate written to inverter
# Export-limit curtailment state (#269) — tracks whether the hardware
# is currently curtailed so release can fire even on a period whose
# own plan doesn't call for export (see _apply_period_schedule).
self._export_limit_curtailed: bool = False
# Consumption forecast cache. Only used for the 'influxdb_7d_avg'
# and 'ha_statistics' strategies, whose value is a window of full
# calendar days ending at today's midnight and so provably can't
# change intraday — the cache is invalidated on date rollover, not
# a clock-based TTL (see issue #395). 'sensor'/'fixed' read a cheap,
# continuously-updating source and always fetch fresh, same as
# solar's controller.get_solar_forecast() every quarterly run.
self._consumption_predictions: list[float] | None = None
self._consumption_predictions_date: date | None = None
# Critical sensor failure tracking for graceful degradation
self._critical_sensor_failures = []
# Hardware write retry: when a write fails, force re-apply next cycle
self._hardware_write_pending = False
# Scheduler reference for one-shot retry jobs (set via set_scheduler)
self._scheduler = None
self._runtime_failure_tracker = RuntimeFailureTracker()
self._health_recovery_tracker = HealthRecoveryTracker()
# Historical-data-incomplete warning dismissal, keyed to the day and
# the exact set of missing hours so a new gap (or the same gap
# recurring on a later day) still surfaces the banner.
self._dismissed_historical_warning_signature: (
tuple[str, tuple[int, ...]] | None
) = None
# Inject failure tracker into controller if available
if self._controller:
self._controller.failure_tracker = self._runtime_failure_tracker
logger.debug("BatterySystemManager initialized")
def set_scheduler(self, scheduler):
"""Set the APScheduler instance for one-shot retry jobs."""
self._scheduler = scheduler
@property
def is_configured(self) -> bool:
"""True when the system has a valid inverter platform and can operate."""
return self._inverter_controller is not None
@property
def controller(self) -> HomeAssistantAPIController:
"""Get the Home Assistant controller."""
if self._controller is None:
raise RuntimeError("Controller not initialized - system not started")
return self._controller
VALID_PLATFORMS: ClassVar[set[str]] = {
"growatt_server_min",
"growatt_server_sph",
"solax_modbus_growatt_min",
"solax_modbus_growatt_sph",
"solax_modbus_native",
"solis_modbus",
"huawei_solar_luna2000",
}
@staticmethod
def _resolve_initial_platform(options: dict) -> str | None:
"""Determine inverter platform from startup config.
``inverter.platform`` is the source of truth; installs predating it are
rewritten by ``SettingsStore._migrate_schema()`` before this runs.
Returns None on a fresh install.
"""
platform = options.get("inverter", {}).get("platform")
if not platform:
logger.info(
"No inverter platform configured — "
"system will start in unconfigured mode"
)
return None
assert platform in BatterySystemManager.VALID_PLATFORMS, (
f"Unknown inverter platform '{platform}', "
f"expected one of {sorted(BatterySystemManager.VALID_PLATFORMS)}"
)
return platform
VALID_CONTROL_MODES: ClassVar[set[str]] = {"tou", "vpp"}
# Strategies whose forecast is a window of full calendar days ending at
# today's midnight — the value can't change intraday, so it's cached
# until the date rolls over instead of refetched every quarterly cycle.
_DATE_CACHED_CONSUMPTION_STRATEGIES: ClassVar[set[str]] = {
"influxdb_7d_avg",
"ha_statistics",
}
@staticmethod
def _resolve_control_mode(options: dict, platform: str | None) -> str:
"""Determine control_mode for solax_modbus_growatt_min/_sph platforms.
GEN3 (solax_modbus_growatt_sph) has no working TOU path — it always
runs "vpp" regardless of what's stored. GEN4 (solax_modbus_growatt_min)
reads ``inverter.control_mode``, defaulting to "tou" (existing
behaviour, unchanged for current installs). Other platforms don't use
this setting at all; it's returned as "tou" but ignored by their
controllers.
"""
if platform == "solax_modbus_growatt_sph":
return "vpp"
mode = options.get("inverter", {}).get("control_mode", "tou")
assert mode in BatterySystemManager.VALID_CONTROL_MODES, (
f"Unknown control_mode '{mode}', "
f"expected one of {sorted(BatterySystemManager.VALID_CONTROL_MODES)}"
)
return mode
@property
def _supports_charge_rate_control(self) -> bool:
if not self._inverter_controller:
return False
return self._inverter_controller.supports_charge_rate_control
@property
def platform_capabilities(self) -> PlatformCapabilities:
"""What the active platform can express (Phase 4a, D2).
The single place these facts are read off the controller, so the
planner and the hardware-write path cannot answer the same question
two different ways -- the drift shape #282/#497/#511/#537 are all
instances of. Without a controller the defaults describe the
TOU-register platform the DP has always assumed, which is what the
pre-4a `discharge_resolution_kw=None` meant.
"""
if self._inverter_controller is None:
return PlatformCapabilities()
return PlatformCapabilities.from_controller(
self._inverter_controller, self.battery_settings
)
@property
def export_curtailment_active(self) -> bool:
"""Whether export curtailment is actually in effect for planning.
Capability-aware, not just the raw user setting (#459 review):
planning for curtailment on a platform that can't actually do it
makes outcomes worse than leaving the feature off (see
optimize_battery_schedule's export_curtailment_active docstring).
Entity misconfiguration on a supported platform is a separate,
self-correcting case surfaced by the runtime failure banner in
_apply_period_schedule, not checked here.
"""
return (
self.battery_settings.export_curtailment_enabled
and self._inverter_controller is not None
and self._inverter_controller.supports_export_limit_control
)
def _create_inverter_controller(self) -> InverterController | None:
"""Create an inverter controller for ``self.inverter_platform``.
Returns None when no platform is configured (fresh install).
"""
if not self.inverter_platform:
return None
if self.inverter_platform == "growatt_server_sph":
return GrowattSphController(battery_settings=self.battery_settings)
if self.inverter_platform == "solax_modbus_native":
return SolaxController(battery_settings=self.battery_settings)
if self.inverter_platform == "solis_modbus":
return SolisModbusController(battery_settings=self.battery_settings)
if self.inverter_platform == "huawei_solar_luna2000":
return HuaweiController(battery_settings=self.battery_settings)
if self.inverter_platform in (
"solax_modbus_growatt_min",
"solax_modbus_growatt_sph",
):
return SolaxModbusGrowattController(
battery_settings=self.battery_settings,
control_mode=self.control_mode,
)
return GrowattMinController(battery_settings=self.battery_settings)
def switch_inverter_platform(self, platform: str) -> None:
"""Switch the inverter controller to a different platform at runtime.
Called when the user changes the inverter platform in Settings.
Recreates the inverter controller if the platform actually changed.
Args:
platform: Target platform string (one of VALID_PLATFORMS)
Raises:
SystemConfigurationError: If platform is not a recognised value.
"""
if platform not in self.VALID_PLATFORMS:
raise SystemConfigurationError(
message=f"Unknown inverter platform '{platform}', "
f"expected one of {sorted(self.VALID_PLATFORMS)}"
)
if platform == self.inverter_platform:
return
logger.info(
"Switching inverter platform: %s → %s",
self.inverter_platform,
platform,
)
if self._inverter_controller is not None:
self._inverter_controller.leave_control_mode(self._controller)
self.inverter_platform = platform
self.control_mode = self._resolve_control_mode({}, platform)
self._inverter_controller = self._create_inverter_controller()
logger.info(
"Inverter controller recreated: %s",
type(self._inverter_controller).__name__,
)
def switch_control_mode(self, control_mode: str) -> None:
"""Switch control_mode (tou/vpp) for the current Growatt-modbus platform.
Only meaningful for ``solax_modbus_growatt_min`` (GEN4) — GEN3
(``solax_modbus_growatt_sph``) always runs "vpp" and rejects any
other value, since it has no working TOU path.
Args:
control_mode: "tou" or "vpp"
Raises:
SystemConfigurationError: If control_mode is invalid, or the
current platform doesn't use this setting.
"""
if control_mode not in self.VALID_CONTROL_MODES:
raise SystemConfigurationError(
message=f"Unknown control_mode '{control_mode}', "
f"expected one of {sorted(self.VALID_CONTROL_MODES)}"
)
if self.inverter_platform not in (
"solax_modbus_growatt_min",
"solax_modbus_growatt_sph",
):
raise SystemConfigurationError(
message=f"control_mode is not applicable to platform "
f"'{self.inverter_platform}'"
)
if (
self.inverter_platform == "solax_modbus_growatt_sph"
and control_mode != "vpp"
):
raise SystemConfigurationError(
message="solax_modbus_growatt_sph (GEN3) has no working TOU "
"path — control_mode must be 'vpp'"
)
if control_mode == self.control_mode:
return
logger.info(
"Switching Growatt-modbus control_mode: %s -> %s",
self.control_mode,
control_mode,
)
self._inverter_controller.leave_control_mode(self._controller)
self.control_mode = control_mode
self._inverter_controller = self._create_inverter_controller()
logger.info(
"Inverter controller recreated: %s",
type(self._inverter_controller).__name__,
)
def _create_price_source(self, controller) -> PriceSource:
"""Create the appropriate price source based on energy_provider config.
Supports four price providers:
- "nordpool_hacs": Custom Nordpool sensor component (HACS)
- "nordpool_official": Official HA Nordpool integration via service calls
- "octopus": Octopus Energy Agile tariff via HA event entities
- "entsoe": ENTSO-e Transparency Platform sensor (e.g. Belpex)
Args:
controller: HomeAssistantAPIController instance
Returns:
Configured PriceSource instance
"""
config = self._energy_provider_config
provider = config["provider"]
if provider == "octopus":
octopus_config = config["octopus"]
price_source = OctopusEnergySource(
ha_controller=controller,
import_today_entity=octopus_config["import_today_entity"],
import_tomorrow_entity=octopus_config["import_tomorrow_entity"],
export_today_entity=octopus_config["export_today_entity"],
export_tomorrow_entity=octopus_config["export_tomorrow_entity"],
)
logger.info("Using Octopus Energy Agile tariff price source")
return price_source
if provider == "nordpool_official":
nordpool_official_config = config["nordpool_official"]
config_entry_id = nordpool_official_config["config_entry_id"]
price_source = OfficialNordpoolSource(
controller,
config_entry_id,
vat_multiplier=self.price_settings.vat_multiplier,
area=self.price_settings.area,
)
logger.info("Using official Home Assistant Nordpool integration")
return price_source
if provider == "nordpool_hacs":
hacs_config = config["nordpool_hacs"]
logger.info("Using HACS custom Nordpool sensor integration")
return HomeAssistantSource(
controller,
vat_multiplier=self.price_settings.vat_multiplier,
entity=hacs_config["entity"],
)
if provider == "entsoe":
entsoe_config = config["entsoe"]
logger.info("Using ENTSO-e Transparency Platform price source")
return EntsoeSource(
ha_controller=controller,
entity=entsoe_config["entity"],
)
raise SystemConfigurationError(
message=f"Unknown energy provider: {provider!r}. Must be 'nordpool_hacs', 'nordpool_official', 'octopus', or 'entsoe'."
)
def start(self, status_callback=None) -> None:
"""Start the system - preserves original functionality.
On a fresh install where no inverter is configured the system starts
in an unconfigured state. The web UI is still reachable so the user
can complete the setup wizard, which will call
``switch_inverter_platform()`` to finish initialization.
Args:
status_callback: Optional callable(str) invoked with a human-readable
description before each startup step, for live UI progress.
"""
def _status(msg: str) -> None:
if status_callback:
status_callback(msg)
if not self.is_configured:
logger.info(
"System is unconfigured — skipping hardware initialization. "
"Complete the setup wizard to begin operation."
)
return
try:
if self._controller:
# Initialize power monitor only when feature is enabled and
# the platform has per-period charge rate control
if (
self.home_settings.power_monitoring_enabled
and self._supports_charge_rate_control
):
self._power_monitor = HomePowerMonitor(
self._controller,
home_settings=self.home_settings,
battery_settings=self.battery_settings,
)
# Run health check before we start using sensors
_status("Checking sensor health...")
self._run_health_check()
# Initialize schedule from inverter before SOC sync so cached
# periods are available (required for SPH write-back)
_status("Reading inverter schedule...")
self._initialize_tou_schedule_from_inverter()
# Write initial hardware config (SOC limits, legacy slot cleanup, etc.)
_status("Syncing battery limits...")
try:
self._inverter_controller.initialize_hardware(self._controller)
except Exception as e:
logger.warning(
"Could not complete hardware initialization at startup "
"(inverter may be temporarily unreachable): %s. "
"Inverter will retain its current settings. System startup will continue.",
e,
)
# Initialize historical data - using improved sensor collector
_status("Fetching historical data...")
self._fetch_and_initialize_historical_data(
status_callback=status_callback
)
# Fetch predictions
_status("Almost there — fetching predictions...")
self._fetch_predictions()
self.log_system_startup()
logger.info("BatterySystemManager started successfully")
except Exception as e:
logger.error(f"Failed to start BatterySystemManager: {e}")
raise
def set_demo_mode(self, enabled: bool) -> None:
"""Switch between demo and live mode.
Sets the HA controller's test mode flag. When going live, mirrors the
startup sequence: read current inverter state first (required by some
controllers before they can write SOC limits), then run hardware init.
"""
self._controller.set_test_mode(enabled)
if not enabled and self._inverter_controller is not None:
try:
self._initialize_tou_schedule_from_inverter()
self._inverter_controller.initialize_hardware(self._controller)
except Exception as e:
logger.warning(
"Could not complete hardware initialization on transition to live "
"(inverter may be temporarily unreachable): %s. "
"Inverter will retain its current settings.",
e,
)
def reinitialize_historical_data(self) -> None:
"""Re-run the historical InfluxDB backfill.
Called after the setup wizard configures sensors so that today's
history is available for the first optimization run.
Re-resolves sensor entity IDs first (they were empty at startup
before the wizard ran), then clears and refills the historical store.
"""
logger.info("Re-initializing historical data after wizard setup")
self.sensor_collector.re_resolve_sensors()
self.historical_store.clear()
self._fetch_and_initialize_historical_data()
def update_battery_schedule(
self, current_period: int, prepare_next_day: bool = False
) -> bool:
"""Main schedule update method for quarterly resolution."""
if not self.is_configured:
logger.warning(
"update_battery_schedule called on unconfigured system — skipping"
)
return False
# Input validation (no upper bound due to DST transitions)
if current_period < 0:
logger.error("Invalid period: %d (must be non-negative)", current_period)
raise SystemConfigurationError(
message=f"Invalid period: {current_period} (must be non-negative)"
)
if prepare_next_day:
logger.info(
"Preparing schedule for next day at period %d (%s)",
current_period,
format_period(current_period),
)
else:
logger.info(
"Updating battery schedule for period %d (%s)",
current_period,
format_period(current_period),
)
is_first_run = self._current_schedule is None
try:
# Handle special cases (midnight, next day prep)
self._handle_special_cases(current_period, prepare_next_day, is_first_run)
# Get price data
prices, price_entries = self._get_price_data(prepare_next_day)
if not prices:
logger.warning("Schedule update aborted: No price data available")
return False
# Update energy data for completed period
self._update_energy_data(current_period, is_first_run, prepare_next_day)
# Get current battery state
current_soc = self._get_current_battery_soc()
if current_soc is None:
logger.error("Failed to get battery SOC")
return False
# Gather optimization data
optimization_data_result = self._gather_optimization_data(
current_period, current_soc, prepare_next_day, len(prices)
)
if optimization_data_result is None:
logger.error("Failed to gather optimization data")
return False
optimization_period, optimization_data = optimization_data_result
# Run optimization using DP algorithm
optimization_result = self._run_optimization(
optimization_period,
optimization_data,
prices,
price_entries,
prepare_next_day,
)
if optimization_result is None:
logger.error("Failed to optimize battery schedule")
return False
# Create new schedule
temp_schedule = self._create_updated_schedule(
optimization_period,
optimization_result,
prices,
optimization_data,
is_first_run,
prepare_next_day,
)
if temp_schedule is None:
logger.error("Failed to create updated schedule")
return False
# Determine if we should apply the new schedule
should_apply, reason = self._should_apply_schedule(
is_first_run,
current_period,
prepare_next_day,
optimization_period,
temp_schedule,
)
# Apply schedule if needed
if should_apply:
self._apply_schedule(
current_period,
temp_schedule,
reason,
prepare_next_day,
)
else:
# Update display data even when nothing changes on hardware.
# Applied TOU/VPP state on self._inverter_controller is left
# untouched — there's only ever one instance now (#369), so
# there's nothing to carry forward or swap in. current_schedule
# IS refreshed here (unlike TOU/VPP state) because
# _apply_period_schedule reads current_schedule.actions for
# every per-period hardware write, even on a not-apply cycle
# where the DP re-optimized battery-action magnitudes without
# changing which TOU/VPP mode is active.
# Nothing in the plan changed, but the inverter may still have
# lost a segment we programmed or restored one we did not
# (issue #551). Since #554 the write path is skipped on cycles
# like this one, so without re-asserting here nothing would
# look at the inverter again until the plan itself changed.
# A no-op on platforms that rewrite everything anyway, and a
# no-op here too when the inverter already agrees.
if self._controller is not None and not prepare_next_day:
try:
self._inverter_controller.reconcile_hardware(
self._controller, current_period
)
except Exception as e:
# Same handling as a failed apply: record it so the
# next cycle retries, and let the optimization stand —
# it needs no inverter at all.
self._hardware_write_pending = True
logger.error(
"Could not re-assert the schedule on the inverter: "
"%s — will retry next cycle",
e,
)
self._current_schedule = temp_schedule
self._inverter_controller.strategic_intents = (
temp_schedule.strategic_intents
)
self._inverter_controller.current_schedule = temp_schedule
# Capture prediction snapshot after schedule is applied
if not prepare_next_day:
self._capture_prediction_snapshot(
optimization_period=optimization_period,
optimization_result=optimization_result,
)
# Apply current period settings
if not prepare_next_day:
self._apply_period_schedule(current_period)
logger.info(
"Applied period settings for period %d (%s)",
current_period,
format_period(current_period),
)
self.log_battery_schedule(current_period)
return True
except Exception as e:
logger.error(f"Failed to update battery schedule: {e}")
return False
def log_battery_schedule(self, current_period: int) -> None:
"""Log the current battery schedule."""
if not self.is_configured:
return
if not self._current_schedule:
logger.warning("No current schedule available for reporting")
return
# Log Growatt TOU schedule and detailed schedule
self._inverter_controller.log_current_TOU_schedule(
"=== GROWATT TOU SCHEDULE ==="
)
self._inverter_controller.log_detailed_schedule(
"=== GROWATT DETAILED SCHEDULE ==="
)
def _capture_prediction_snapshot(
self,
optimization_period: int,
optimization_result: OptimizationResult,
) -> None:
"""Capture snapshot of predictions and actuals using DailyView.
Args:
optimization_period: Period when optimization ran (0-95)
optimization_result: Result from DP optimization
"""
try:
# Build daily view (merges actuals + predictions)
daily_view = self.daily_view_builder.build_daily_view(
optimization_period, self.export_curtailment_active
)
# Get current Growatt schedule
growatt_schedule = self._inverter_controller.tou_intervals.copy()
# Store snapshot
self.prediction_snapshot_store.store_snapshot(
snapshot_timestamp=time_utils.now(),
optimization_period=optimization_period,
daily_view=daily_view,
growatt_schedule=growatt_schedule,
predicted_daily_savings=(
optimization_result.economic_summary.grid_to_battery_solar_savings
if optimization_result.economic_summary
else 0.0
),
)
logger.debug(
"Captured prediction snapshot at period %d with %d TOU intervals",
optimization_period,
len(growatt_schedule),
)
except Exception as e:
logger.warning(f"Failed to capture prediction snapshot: {e}")
def _initialize_tou_schedule_from_inverter(self) -> None:
"""Initialize schedule from current inverter settings."""
try:
logger.info("Reading current TOU schedule from inverter")
if self._controller is None:
logger.error(
"Controller is not available for reading inverter segments"
)
return
current_hour = time_utils.now().hour
self._inverter_controller.read_and_initialize_from_hardware(
self._controller, current_hour
)
except Exception as e:
logger.error(f"Failed to read current inverter schedule: {e}")
def _load_historical_seed(self, current_period: int) -> bool:
"""Seed the historical store from BESS_HISTORICAL_SEED_FILE if set.
Returns True if seeding succeeded and InfluxDB backfill should be skipped.
"""
seed_file = os.environ.get("BESS_HISTORICAL_SEED_FILE", "")
if not seed_file:
return False
try:
with open(seed_file, encoding="utf-8") as f:
periods: list = json.load(f)
except Exception as e:
logger.warning("Failed to load historical seed file '%s': %s", seed_file, e)
return False
loaded = 0
for entry in periods:
if entry is None:
continue
try:
period_data = _period_data_from_dict(entry)
if period_data.period < current_period:
self.historical_store.record_period(period_data.period, period_data)
loaded += 1
except Exception as e:
logger.warning("Skipping malformed seed period: %s", e)
logger.info("Historical seed loaded: %d periods from '%s'", loaded, seed_file)
return loaded > 0
def _load_today_from_disk(self, current_period: int) -> None:
"""Seed historical_store from today's persisted DailyView, if any.
Only periods marked data_source == "actual" are trusted as real
recovered data. Periods the file marked "predicted" or "missing"
(e.g. a period a scheduler tick never got around to recording, see
issue #403) are deliberately left unseeded so the InfluxDB backfill
that runs after this can still attempt them.
"""
view = self.daily_view_store.load_day(time_utils.today())
if view is None:
return
seeded = 0
for period_data in view.periods:
if period_data.data_source != "actual":
continue
if not 0 <= period_data.period < current_period:
continue
try:
self.historical_store.record_period(period_data.period, period_data)
seeded += 1
except ValueError as e:
logger.warning(
"Could not seed period %d from disk: %s", period_data.period, e
)
if seeded:
logger.info("Seeded %d period(s) from today's persisted file", seeded)
def _fetch_and_initialize_historical_data(self, status_callback=None) -> None:
"""Fetch and initialize historical data using quarterly resolution."""
try:
now = time_utils.now()
current_period = now.hour * 4 + now.minute // 15
logger.info(
f"Fetching historical data - current period: {current_period} ({format_period(current_period)})"
)
if current_period > 0 and self._load_historical_seed(current_period):
self.sensor_collector.warm_readings_cache()
return
if current_period > 0:
self._load_today_from_disk(current_period)
if not is_influxdb_configured():
logger.info(
"InfluxDB is not configured — skipping historical data backfill"
)
return
if current_period > 0:
# Get prices once for all periods (fetch outside loop to avoid repeated API calls)
try:
buy_prices, sell_prices = self.price_manager.get_available_prices()
except Exception as e:
logger.warning(f"Could not get prices for historical data: {e}")
buy_prices, sell_prices = [], []
# Collect quarterly data for all completed periods
for period in range(0, current_period):
# Report progress at each hour boundary (every 4th period)
if status_callback and period % 4 == 0:
hour = period // 4
total_hours = current_period // 4
status_callback(
f"Fetching historical data ({hour}/{total_hours}h)..."
)
if self.historical_store.get_period(period) is not None:
continue
try:
# Collect cumulative sensor readings at period boundary (calculate deltas for energy flows)
period_energy_data = self.sensor_collector.collect_energy_data(
period
)
# Calculate economic data using pre-fetched prices
if period < len(buy_prices):
buy_price = buy_prices[period]
sell_price = sell_prices[period]
# Calculate battery cycle cost based on actual charging
battery_cycle_cost_sek = (
period_energy_data.battery_charged
* self.battery_settings.cycle_cost_per_kwh
)
# Use standard economic calculation from EconomicData
economic_data = EconomicData.from_energy_data(
energy_data=period_energy_data,
buy_price=buy_price,
sell_price=sell_price,
battery_cycle_cost=battery_cycle_cost_sek,
)
else:
# Period beyond available prices
economic_data = EconomicData(
buy_price=0.0, sell_price=0.0, hourly_savings=0.0
)
# Store period data with both planned and observed intents
# Get DP-planned intent (authoritative) if available
planned_intent = self._get_planned_intent_for_period(period)
# Infer observed intent from actual flows
battery_power = period_energy_data.battery_net_change
observed = infer_intent_from_flows(
battery_power, period_energy_data
)
period_data = PeriodData(