emhass 0.10.6__py3-none-any.whl → 0.15.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
emhass/command_line.py CHANGED
@@ -1,33 +1,621 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
 
4
3
  import argparse
4
+ import asyncio
5
+ import copy
6
+ import logging
5
7
  import os
6
- import time
7
8
  import pathlib
8
- import logging
9
- import json
10
- import copy
11
9
  import pickle
12
- from datetime import datetime, timezone
13
- from typing import Optional, Tuple
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime, timedelta
14
12
  from importlib.metadata import version
13
+
14
+ import aiofiles
15
15
  import numpy as np
16
+ import orjson
16
17
  import pandas as pd
17
18
 
18
- from distutils.util import strtobool
19
-
20
- from emhass.retrieve_hass import RetrieveHass
19
+ from emhass import utils
21
20
  from emhass.forecast import Forecast
22
21
  from emhass.machine_learning_forecaster import MLForecaster
23
- from emhass.optimization import Optimization
24
22
  from emhass.machine_learning_regressor import MLRegressor
25
- from emhass import utils
23
+ from emhass.optimization import Optimization
24
+ from emhass.retrieve_hass import RetrieveHass
25
+
26
+ default_csv_filename = "opt_res_latest.csv"
27
+ default_pkl_suffix = "_mlf.pkl"
28
+ default_metadata_json = "metadata.json"
29
+ test_df_literal = "test_df_final.pkl"
30
+
31
+
32
+ @dataclass
33
+ class SetupContext:
34
+ """Context object for optimization preparation helpers."""
35
+
36
+ retrieve_hass_conf: dict
37
+ optim_conf: dict
38
+ plant_conf: dict
39
+ emhass_conf: dict
40
+ params: dict
41
+ logger: logging.Logger
42
+ get_data_from_file: bool
43
+ rh: RetrieveHass
44
+ fcst: Forecast | None = None
45
+
46
+
47
+ @dataclass
48
+ class PublishContext:
49
+ """Context object for data publishing helpers."""
50
+
51
+ input_data_dict: dict
52
+ params: dict
53
+ idx: int
54
+ common_kwargs: dict
55
+ logger: logging.Logger
56
+
57
+ @property
58
+ def rh(self) -> RetrieveHass:
59
+ return self.input_data_dict["rh"]
60
+
61
+ @property
62
+ def opt(self) -> Optimization:
63
+ return self.input_data_dict["opt"]
64
+
65
+ @property
66
+ def fcst(self) -> Forecast:
67
+ return self.input_data_dict["fcst"]
68
+
69
+
70
+ async def _retrieve_from_file(
71
+ emhass_conf: dict,
72
+ test_df_literal: str,
73
+ rh: RetrieveHass,
74
+ retrieve_hass_conf: dict,
75
+ optim_conf: dict,
76
+ ) -> tuple[bool, object]:
77
+ """Helper to retrieve data from a pickle file and configure variables."""
78
+ async with aiofiles.open(emhass_conf["data_path"] / test_df_literal, "rb") as inp:
79
+ content = await inp.read()
80
+ rh.df_final, days_list, var_list, rh.ha_config = pickle.loads(content)
81
+ rh.var_list = var_list
82
+ # Assign variables based on set_type
83
+ retrieve_hass_conf["sensor_power_load_no_var_loads"] = str(var_list[0])
84
+ if optim_conf.get("set_use_pv", True):
85
+ retrieve_hass_conf["sensor_power_photovoltaics"] = str(var_list[1])
86
+ retrieve_hass_conf["sensor_linear_interp"] = [
87
+ retrieve_hass_conf["sensor_power_photovoltaics"],
88
+ retrieve_hass_conf["sensor_power_load_no_var_loads"],
89
+ ]
90
+ retrieve_hass_conf["sensor_replace_zero"] = [
91
+ retrieve_hass_conf["sensor_power_photovoltaics"],
92
+ var_list[2],
93
+ ]
94
+ else:
95
+ retrieve_hass_conf["sensor_linear_interp"] = [
96
+ retrieve_hass_conf["sensor_power_load_no_var_loads"]
97
+ ]
98
+ retrieve_hass_conf["sensor_replace_zero"] = []
99
+ return True, days_list
100
+
101
+
102
+ async def _retrieve_from_hass(
103
+ set_type: str,
104
+ retrieve_hass_conf: dict,
105
+ optim_conf: dict,
106
+ rh: RetrieveHass,
107
+ logger: logging.Logger | None,
108
+ ) -> tuple[bool, object]:
109
+ """Helper to retrieve live data from Home Assistant."""
110
+ # Determine days_list based on set_type
111
+ if set_type == "perfect-optim" or set_type == "adjust_pv":
112
+ days_list = utils.get_days_list(retrieve_hass_conf["historic_days_to_retrieve"])
113
+ elif set_type == "naive-mpc-optim":
114
+ days_list = utils.get_days_list(1)
115
+ else:
116
+ days_list = None # Not needed for dayahead
117
+ var_list = [retrieve_hass_conf["sensor_power_load_no_var_loads"]]
118
+ if optim_conf.get("set_use_pv", True):
119
+ var_list.append(retrieve_hass_conf["sensor_power_photovoltaics"])
120
+ if optim_conf.get("set_use_adjusted_pv", True):
121
+ var_list.append(retrieve_hass_conf["sensor_power_photovoltaics_forecast"])
122
+ if logger:
123
+ logger.debug(f"Variable list for data retrieval: {var_list}")
124
+ success = await rh.get_data(
125
+ days_list, var_list, minimal_response=False, significant_changes_only=False
126
+ )
127
+ return success, days_list
128
+
129
+
130
+ async def retrieve_home_assistant_data(
131
+ set_type: str,
132
+ get_data_from_file: bool,
133
+ retrieve_hass_conf: dict,
134
+ optim_conf: dict,
135
+ rh: RetrieveHass,
136
+ emhass_conf: dict,
137
+ test_df_literal: str,
138
+ logger: logging.Logger | None = None,
139
+ ) -> tuple[bool, pd.DataFrame | None, list | None]:
140
+ """Retrieve data from Home Assistant or file and prepare it for optimization."""
141
+
142
+ if get_data_from_file:
143
+ success, days_list = await _retrieve_from_file(
144
+ emhass_conf, test_df_literal, rh, retrieve_hass_conf, optim_conf
145
+ )
146
+ else:
147
+ success, days_list = await _retrieve_from_hass(
148
+ set_type, retrieve_hass_conf, optim_conf, rh, logger
149
+ )
150
+ if not success:
151
+ return False, None, days_list
152
+ rh.prepare_data(
153
+ retrieve_hass_conf["sensor_power_load_no_var_loads"],
154
+ load_negative=retrieve_hass_conf["load_negative"],
155
+ set_zero_min=retrieve_hass_conf["set_zero_min"],
156
+ var_replace_zero=retrieve_hass_conf["sensor_replace_zero"],
157
+ var_interp=retrieve_hass_conf["sensor_linear_interp"],
158
+ )
159
+ return True, rh.df_final.copy(), days_list
160
+
161
+
162
+ def is_model_outdated(model_path: pathlib.Path, max_age_hours: int, logger: logging.Logger) -> bool:
163
+ """
164
+ Check if the saved model file is outdated based on its modification time.
165
+
166
+ :param model_path: Path to the saved model file.
167
+ :type model_path: pathlib.Path
168
+ :param max_age_hours: Maximum age in hours before model is considered outdated.
169
+ :type max_age_hours: int
170
+ :param logger: Logger object for logging information.
171
+ :type logger: logging.Logger
172
+ :return: True if model is outdated or doesn't exist, False otherwise.
173
+ :rtype: bool
174
+ """
175
+ if not model_path.exists():
176
+ logger.info("Adjusted PV model file does not exist, will train new model")
177
+ return True
178
+
179
+ if max_age_hours <= 0:
180
+ logger.info("adjusted_pv_model_max_age is set to 0, forcing model re-fit")
181
+ return True
182
+
183
+ model_mtime = datetime.fromtimestamp(model_path.stat().st_mtime)
184
+ model_age = datetime.now() - model_mtime
185
+ max_age = timedelta(hours=max_age_hours)
186
+
187
+ if model_age > max_age:
188
+ logger.info(
189
+ f"Adjusted PV model is outdated (age: {model_age.total_seconds() / 3600:.1f}h, "
190
+ f"max: {max_age_hours}h), will train new model"
191
+ )
192
+ return True
193
+ else:
194
+ logger.info(
195
+ f"Using existing adjusted PV model (age: {model_age.total_seconds() / 3600:.1f}h, "
196
+ f"max: {max_age_hours}h)"
197
+ )
198
+ return False
199
+
200
+
201
+ async def _retrieve_and_fit_pv_model(
202
+ fcst: Forecast,
203
+ get_data_from_file: bool,
204
+ retrieve_hass_conf: dict,
205
+ optim_conf: dict,
206
+ rh: RetrieveHass,
207
+ emhass_conf: dict,
208
+ test_df_literal: pd.DataFrame,
209
+ ) -> bool:
210
+ """
211
+ Helper function to retrieve data and fit the PV adjustment model.
212
+
213
+ :param fcst: Forecast object used for PV forecast adjustment.
214
+ :type fcst: Forecast
215
+ :param get_data_from_file: Whether to retrieve data from a file instead of Home Assistant.
216
+ :type get_data_from_file: bool
217
+ :param retrieve_hass_conf: Configuration dictionary for retrieving data from Home Assistant.
218
+ :type retrieve_hass_conf: dict
219
+ :param optim_conf: Configuration dictionary for optimization settings.
220
+ :type optim_conf: dict
221
+ :param rh: RetrieveHass object for interacting with Home Assistant.
222
+ :type rh: RetrieveHass
223
+ :param emhass_conf: Configuration dictionary for emhass paths and settings.
224
+ :type emhass_conf: dict
225
+ :param test_df_literal: DataFrame containing test data for debugging purposes.
226
+ :type test_df_literal: pd.DataFrame
227
+ :return: True if successful, False otherwise.
228
+ :rtype: bool
229
+ """
230
+ # Retrieve data from Home Assistant
231
+ success, df_input_data, _ = await retrieve_home_assistant_data(
232
+ "adjust_pv",
233
+ get_data_from_file,
234
+ retrieve_hass_conf,
235
+ optim_conf,
236
+ rh,
237
+ emhass_conf,
238
+ test_df_literal,
239
+ )
240
+ if not success:
241
+ return False
242
+ # Call data preparation method
243
+ fcst.adjust_pv_forecast_data_prep(df_input_data)
244
+ # Call the fit method
245
+ await fcst.adjust_pv_forecast_fit(
246
+ n_splits=5,
247
+ regression_model=optim_conf["adjusted_pv_regression_model"],
248
+ )
249
+ return True
250
+
251
+
252
+ async def adjust_pv_forecast(
253
+ logger: logging.Logger,
254
+ fcst: Forecast,
255
+ p_pv_forecast: pd.Series,
256
+ get_data_from_file: bool,
257
+ retrieve_hass_conf: dict,
258
+ optim_conf: dict,
259
+ rh: RetrieveHass,
260
+ emhass_conf: dict,
261
+ test_df_literal: pd.DataFrame,
262
+ ) -> pd.Series:
263
+ """
264
+ Adjust the photovoltaic (PV) forecast using historical data and a regression model.
265
+
266
+ This method retrieves historical data, prepares it for model fitting, trains a regression
267
+ model, and adjusts the provided PV forecast based on the trained model.
268
+
269
+ :param logger: Logger object for logging information and errors.
270
+ :type logger: logging.Logger
271
+ :param fcst: Forecast object used for PV forecast adjustment.
272
+ :type fcst: Forecast
273
+ :param p_pv_forecast: The initial PV forecast to be adjusted.
274
+ :type p_pv_forecast: pd.Series
275
+ :param get_data_from_file: Whether to retrieve data from a file instead of Home Assistant.
276
+ :type get_data_from_file: bool
277
+ :param retrieve_hass_conf: Configuration dictionary for retrieving data from Home Assistant.
278
+ :type retrieve_hass_conf: dict
279
+ :param optim_conf: Configuration dictionary for optimization settings.
280
+ :type optim_conf: dict
281
+ :param rh: RetrieveHass object for interacting with Home Assistant.
282
+ :type rh: RetrieveHass
283
+ :param emhass_conf: Configuration dictionary for emhass paths and settings.
284
+ :type emhass_conf: dict
285
+ :param test_df_literal: DataFrame containing test data for debugging purposes.
286
+ :type test_df_literal: pd.DataFrame
287
+ :return: The adjusted PV forecast as a pandas Series.
288
+ :rtype: pd.Series
289
+ """
290
+ # Normalize data_path to Path object for safety (handles both str and Path types)
291
+ data_path = pathlib.Path(emhass_conf["data_path"])
292
+ model_filename = "adjust_pv_regressor.pkl"
293
+ model_path = data_path / model_filename
294
+ max_age_hours = optim_conf.get("adjusted_pv_model_max_age", 24)
295
+ # Check if model needs to be re-fitted
296
+ if is_model_outdated(model_path, max_age_hours, logger):
297
+ logger.info("Adjusting PV forecast, retrieving history data for model fit")
298
+ success = await _retrieve_and_fit_pv_model(
299
+ fcst,
300
+ get_data_from_file,
301
+ retrieve_hass_conf,
302
+ optim_conf,
303
+ rh,
304
+ emhass_conf,
305
+ test_df_literal,
306
+ )
307
+ if not success:
308
+ return False
309
+ else:
310
+ # Load existing model
311
+ logger.info("Loading existing adjusted PV model from file")
312
+ try:
313
+ async with aiofiles.open(model_path, "rb") as inp:
314
+ content = await inp.read()
315
+ fcst.model_adjust_pv = pickle.loads(content)
316
+ except (pickle.UnpicklingError, EOFError, AttributeError, ImportError) as e:
317
+ logger.error(f"Failed to load existing adjusted PV model: {type(e).__name__}: {str(e)}")
318
+ logger.warning(
319
+ "Model file may be corrupted or incompatible. Falling back to re-fitting the model."
320
+ )
321
+ # Use helper function to retrieve data and re-fit model
322
+ success = await _retrieve_and_fit_pv_model(
323
+ fcst,
324
+ get_data_from_file,
325
+ retrieve_hass_conf,
326
+ optim_conf,
327
+ rh,
328
+ emhass_conf,
329
+ test_df_literal,
330
+ )
331
+ if not success:
332
+ logger.error("Failed to retrieve data for model re-fit after load error")
333
+ return False
334
+ logger.info("Successfully re-fitted model after load failure")
335
+ except Exception as e:
336
+ logger.error(
337
+ f"Unexpected error loading adjusted PV model: {type(e).__name__}: {str(e)}"
338
+ )
339
+ logger.error("Cannot recover from this error")
340
+ return False
341
+ # Call the predict method
342
+ p_pv_forecast = p_pv_forecast.rename("forecast").to_frame()
343
+ p_pv_forecast = fcst.adjust_pv_forecast_predict(forecasted_pv=p_pv_forecast)
344
+ # Update the PV forecast
345
+ return p_pv_forecast["adjusted_forecast"].rename(None)
346
+
347
+
348
+ async def _prepare_perfect_optim(ctx: SetupContext):
349
+ """Helper to prepare data for perfect optimization."""
350
+ success, df_input_data, days_list = await retrieve_home_assistant_data(
351
+ "perfect-optim",
352
+ ctx.get_data_from_file,
353
+ ctx.retrieve_hass_conf,
354
+ ctx.optim_conf,
355
+ ctx.rh,
356
+ ctx.emhass_conf,
357
+ test_df_literal,
358
+ ctx.logger,
359
+ )
360
+ if not success:
361
+ return None
362
+ return {
363
+ "df_input_data": df_input_data,
364
+ "days_list": days_list,
365
+ }
366
+
367
+
368
+ async def _get_dayahead_pv_forecast(ctx: SetupContext):
369
+ """Helper to retrieve and optionally adjust PV forecast."""
370
+ # Check if we should calculate PV forecast
371
+ if not (
372
+ ctx.optim_conf["set_use_pv"]
373
+ or ctx.optim_conf.get("weather_forecast_method", None) == "list"
374
+ ):
375
+ return pd.Series(0, index=ctx.fcst.forecast_dates), None
376
+ # Get weather forecast
377
+ df_weather = await ctx.fcst.get_weather_forecast(
378
+ method=ctx.optim_conf["weather_forecast_method"]
379
+ )
380
+ if isinstance(df_weather, bool) and not df_weather:
381
+ return None, None
382
+ p_pv_forecast = ctx.fcst.get_power_from_weather(df_weather)
383
+ # Adjust PV forecast if needed
384
+ if ctx.optim_conf["set_use_adjusted_pv"]:
385
+ p_pv_forecast = await adjust_pv_forecast(
386
+ ctx.logger,
387
+ ctx.fcst,
388
+ p_pv_forecast,
389
+ ctx.get_data_from_file,
390
+ ctx.retrieve_hass_conf,
391
+ ctx.optim_conf,
392
+ ctx.rh,
393
+ ctx.emhass_conf,
394
+ test_df_literal,
395
+ )
396
+ return p_pv_forecast, df_weather
397
+
398
+
399
+ def _apply_df_freq_horizon(
400
+ df: pd.DataFrame, retrieve_hass_conf: dict, prediction_horizon: int | None
401
+ ) -> pd.DataFrame:
402
+ """Helper to apply frequency adjustment and prediction horizon slicing."""
403
+ # Handle Frequency
404
+ if retrieve_hass_conf.get("optimization_time_step"):
405
+ step = retrieve_hass_conf["optimization_time_step"]
406
+ if not isinstance(step, pd._libs.tslibs.timedeltas.Timedelta):
407
+ step = pd.to_timedelta(step, "minute")
408
+ df = df.asfreq(step)
409
+ else:
410
+ df = utils.set_df_index_freq(df)
411
+ # Handle Prediction Horizon
412
+ if prediction_horizon:
413
+ # Slice the dataframe up to the horizon
414
+ df = copy.deepcopy(df)[df.index[0] : df.index[prediction_horizon - 1]]
415
+ return df
416
+
417
+
418
+ async def _prepare_dayahead_optim(ctx: SetupContext):
419
+ """Helper to prepare data for day-ahead optimization."""
420
+ # Get PV Forecast
421
+ p_pv_forecast, df_weather = await _get_dayahead_pv_forecast(ctx)
422
+ if p_pv_forecast is None:
423
+ return None
424
+ # Get Load Forecast
425
+ p_load_forecast = await ctx.fcst.get_load_forecast(
426
+ days_min_load_forecast=ctx.optim_conf["delta_forecast_daily"].days,
427
+ method=ctx.optim_conf["load_forecast_method"],
428
+ )
429
+ if isinstance(p_load_forecast, bool) and not p_load_forecast:
430
+ ctx.logger.error("Unable to get load forecast.")
431
+ return None
432
+ # Build Input DataFrame
433
+ df_input_data_dayahead = pd.DataFrame(
434
+ np.transpose(np.vstack([p_pv_forecast.values, p_load_forecast.values])),
435
+ index=p_pv_forecast.index,
436
+ columns=["p_pv_forecast", "p_load_forecast"],
437
+ )
438
+ # Apply Frequency and Prediction Horizon
439
+ # Use explicitly passed horizon, avoiding JSON re-parsing
440
+ prediction_horizon = ctx.params["passed_data"].get("prediction_horizon")
441
+ df_input_data_dayahead = _apply_df_freq_horizon(
442
+ df_input_data_dayahead, ctx.retrieve_hass_conf, prediction_horizon
443
+ )
444
+ return {
445
+ "df_input_data_dayahead": df_input_data_dayahead,
446
+ "df_weather": df_weather,
447
+ "p_pv_forecast": p_pv_forecast,
448
+ "p_load_forecast": p_load_forecast,
449
+ }
450
+
451
+
452
+ async def _get_naive_mpc_history(ctx: SetupContext):
453
+ """Helper to retrieve historical data for Naive MPC."""
454
+ # Check if we need to skip historical data retrieval
455
+ is_list_forecast = ctx.optim_conf.get("load_forecast_method") == "list"
456
+ is_list_weather = ctx.optim_conf.get("weather_forecast_method") == "list"
457
+ no_pv = not ctx.optim_conf["set_use_pv"]
458
+
459
+ if (is_list_forecast and is_list_weather) or (is_list_forecast and no_pv):
460
+ return True, None, None, False # success, df, days_list, set_mix_forecast
461
+ # Retrieve data from Home Assistant
462
+ success, df_input_data, days_list = await retrieve_home_assistant_data(
463
+ "naive-mpc-optim",
464
+ ctx.get_data_from_file,
465
+ ctx.retrieve_hass_conf,
466
+ ctx.optim_conf,
467
+ ctx.rh,
468
+ ctx.emhass_conf,
469
+ test_df_literal,
470
+ ctx.logger,
471
+ )
472
+ return success, df_input_data, days_list, True
473
+
26
474
 
475
+ async def _get_naive_mpc_pv_forecast(ctx: SetupContext, set_mix_forecast, df_input_data):
476
+ """Helper to generate PV forecast for Naive MPC."""
477
+ # If PV is disabled and no weather list, return zero series
478
+ if not (
479
+ ctx.optim_conf["set_use_pv"] or ctx.optim_conf.get("weather_forecast_method") == "list"
480
+ ):
481
+ return pd.Series(0, index=ctx.fcst.forecast_dates), None
482
+ # Get weather forecast
483
+ df_weather = await ctx.fcst.get_weather_forecast(
484
+ method=ctx.optim_conf["weather_forecast_method"]
485
+ )
486
+ if isinstance(df_weather, bool) and not df_weather:
487
+ return None, None
488
+ # Calculate PV power
489
+ p_pv_forecast = ctx.fcst.get_power_from_weather(
490
+ df_weather, set_mix_forecast=set_mix_forecast, df_now=df_input_data
491
+ )
492
+ # Adjust PV forecast if needed
493
+ if ctx.optim_conf["set_use_adjusted_pv"]:
494
+ p_pv_forecast = await adjust_pv_forecast(
495
+ ctx.logger,
496
+ ctx.fcst,
497
+ p_pv_forecast,
498
+ ctx.get_data_from_file,
499
+ ctx.retrieve_hass_conf,
500
+ ctx.optim_conf,
501
+ ctx.rh,
502
+ ctx.emhass_conf,
503
+ test_df_literal,
504
+ )
505
+ return p_pv_forecast, df_weather
506
+
507
+
508
+ async def _prepare_naive_mpc_optim(ctx: SetupContext):
509
+ """Helper to prepare data for Naive MPC optimization."""
510
+ # Retrieve Historical Data
511
+ success, df_input_data, days_list, set_mix_forecast = await _get_naive_mpc_history(ctx)
512
+ if not success:
513
+ return None
514
+ # Get PV Forecast
515
+ p_pv_forecast, df_weather = await _get_naive_mpc_pv_forecast(
516
+ ctx, set_mix_forecast, df_input_data
517
+ )
518
+ if p_pv_forecast is None:
519
+ return None
520
+ # Get Load Forecast
521
+ p_load_forecast = await ctx.fcst.get_load_forecast(
522
+ days_min_load_forecast=ctx.optim_conf["delta_forecast_daily"].days,
523
+ method=ctx.optim_conf["load_forecast_method"],
524
+ set_mix_forecast=set_mix_forecast,
525
+ df_now=df_input_data,
526
+ )
527
+ if isinstance(p_load_forecast, bool) and not p_load_forecast:
528
+ return None
529
+ # Build and Format Input DataFrame
530
+ df_input_data_dayahead = pd.concat([p_pv_forecast, p_load_forecast], axis=1)
531
+ df_input_data_dayahead.columns = ["p_pv_forecast", "p_load_forecast"]
532
+ # Reuse freq/horizon helper
533
+ prediction_horizon = ctx.params["passed_data"].get("prediction_horizon")
534
+ df_input_data_dayahead = _apply_df_freq_horizon(
535
+ df_input_data_dayahead, ctx.retrieve_hass_conf, prediction_horizon
536
+ )
537
+ return {
538
+ "df_input_data": df_input_data,
539
+ "days_list": days_list,
540
+ "df_input_data_dayahead": df_input_data_dayahead,
541
+ "df_weather": df_weather,
542
+ "p_pv_forecast": p_pv_forecast,
543
+ "p_load_forecast": p_load_forecast,
544
+ }
545
+
546
+
547
+ async def _prepare_ml_fit_predict(ctx: SetupContext):
548
+ """Helper to prepare data for ML fit/predict/tune."""
549
+ days_to_retrieve = ctx.params["passed_data"]["historic_days_to_retrieve"]
550
+ model_type = ctx.params["passed_data"]["model_type"]
551
+ var_model = ctx.params["passed_data"]["var_model"]
552
+ if ctx.get_data_from_file:
553
+ filename = model_type + ".pkl"
554
+ filename_path = ctx.emhass_conf["data_path"] / filename
555
+ async with aiofiles.open(filename_path, "rb") as inp:
556
+ content = await inp.read()
557
+ df_input_data, _, _, _ = pickle.loads(content)
558
+ df_input_data = df_input_data[df_input_data.index[-1] - pd.offsets.Day(days_to_retrieve) :]
559
+ return {"df_input_data": df_input_data}
560
+ else:
561
+ days_list = utils.get_days_list(days_to_retrieve)
562
+ var_list = [var_model]
563
+ if not await ctx.rh.get_data(days_list, var_list):
564
+ return None
565
+ ctx.rh.prepare_data(
566
+ var_model,
567
+ load_negative=ctx.retrieve_hass_conf.get("load_negative", False),
568
+ set_zero_min=ctx.retrieve_hass_conf.get("set_zero_min", True),
569
+ var_replace_zero=ctx.retrieve_hass_conf.get("sensor_replace_zero", []),
570
+ var_interp=ctx.retrieve_hass_conf.get("sensor_linear_interp", []),
571
+ skip_renaming=True,
572
+ )
573
+ return {"df_input_data": ctx.rh.df_final.copy()}
574
+
575
+
576
+ def _prepare_regressor_fit(ctx: SetupContext):
577
+ """Helper to prepare data for Regressor fit/predict."""
578
+ csv_file = ctx.params["passed_data"].get("csv_file", None)
579
+ if not csv_file:
580
+ ctx.logger.error("csv_file is required for regressor actions but was not provided.")
581
+ return None
582
+ if ctx.get_data_from_file:
583
+ base_path = ctx.emhass_conf["data_path"]
584
+ filename_path = pathlib.Path(base_path) / csv_file
585
+ else:
586
+ filename_path = ctx.emhass_conf["data_path"] / csv_file
587
+ if filename_path.is_file():
588
+ df_input_data = pd.read_csv(filename_path, parse_dates=True)
589
+ else:
590
+ ctx.logger.error(
591
+ f"The CSV file {csv_file} was not found in path: {ctx.emhass_conf['data_path']}"
592
+ )
593
+ return None
594
+ # Validate columns
595
+ required_columns = []
596
+ if "features" in ctx.params["passed_data"]:
597
+ required_columns.extend(ctx.params["passed_data"]["features"])
598
+ if "target" in ctx.params["passed_data"]:
599
+ required_columns.append(ctx.params["passed_data"]["target"])
600
+ if "timestamp" in ctx.params["passed_data"]:
601
+ required_columns.append(ctx.params["passed_data"]["timestamp"])
602
+ if not set(required_columns).issubset(df_input_data.columns):
603
+ ctx.logger.error(
604
+ f"The csv file does not contain the required columns: {', '.join(required_columns)}"
605
+ )
606
+ return None
607
+ return {"df_input_data": df_input_data}
27
608
 
28
- def set_input_data_dict(emhass_conf: dict, costfun: str,
29
- params: str, runtimeparams: str, set_type: str, logger: logging.Logger,
30
- get_data_from_file: Optional[bool] = False) -> dict:
609
+
610
+ async def set_input_data_dict(
611
+ emhass_conf: dict,
612
+ costfun: str,
613
+ params: str,
614
+ runtimeparams: str,
615
+ set_type: str,
616
+ logger: logging.Logger,
617
+ get_data_from_file: bool | None = False,
618
+ ) -> dict:
31
619
  """
32
620
  Set up some of the data needed for the different actions.
33
621
 
@@ -50,205 +638,135 @@ def set_input_data_dict(emhass_conf: dict, costfun: str,
50
638
 
51
639
  """
52
640
  logger.info("Setting up needed data")
53
- # Parsing yaml
54
- retrieve_hass_conf, optim_conf, plant_conf = utils.get_yaml_parse(
55
- emhass_conf, use_secrets=not(get_data_from_file), params=params)
56
- # Treat runtimeparams
57
- params, retrieve_hass_conf, optim_conf, plant_conf = utils.treat_runtimeparams(
58
- runtimeparams, params, retrieve_hass_conf, optim_conf, plant_conf, set_type, logger)
59
- # Define main objects
60
- rh = RetrieveHass(retrieve_hass_conf['hass_url'], retrieve_hass_conf['long_lived_token'],
61
- retrieve_hass_conf['freq'], retrieve_hass_conf['time_zone'],
62
- params, emhass_conf, logger, get_data_from_file=get_data_from_file)
63
- fcst = Forecast(retrieve_hass_conf, optim_conf, plant_conf,
64
- params, emhass_conf, logger, get_data_from_file=get_data_from_file)
65
- opt = Optimization(retrieve_hass_conf, optim_conf, plant_conf,
66
- fcst.var_load_cost, fcst.var_prod_price,
67
- costfun, emhass_conf, logger)
68
- # Perform setup based on type of action
641
+ # Parse Parameters
642
+ if (params is not None) and (params != "null"):
643
+ if isinstance(params, str):
644
+ params = dict(orjson.loads(params))
645
+ else:
646
+ params = {}
647
+ retrieve_hass_conf, optim_conf, plant_conf = utils.get_yaml_parse(params, logger)
648
+ if type(retrieve_hass_conf) is bool:
649
+ return False
650
+ (
651
+ params,
652
+ retrieve_hass_conf,
653
+ optim_conf,
654
+ plant_conf,
655
+ ) = await utils.treat_runtimeparams(
656
+ runtimeparams,
657
+ params,
658
+ retrieve_hass_conf,
659
+ optim_conf,
660
+ plant_conf,
661
+ set_type,
662
+ logger,
663
+ emhass_conf,
664
+ )
665
+ if isinstance(params, str):
666
+ params = dict(orjson.loads(params))
667
+ # Initialize Core Objects
668
+ rh = RetrieveHass(
669
+ retrieve_hass_conf["hass_url"],
670
+ retrieve_hass_conf["long_lived_token"],
671
+ retrieve_hass_conf["optimization_time_step"],
672
+ retrieve_hass_conf["time_zone"],
673
+ params,
674
+ emhass_conf,
675
+ logger,
676
+ get_data_from_file=get_data_from_file,
677
+ )
678
+ # Retrieve HA config
679
+ if get_data_from_file:
680
+ async with aiofiles.open(emhass_conf["data_path"] / test_df_literal, "rb") as inp:
681
+ content = await inp.read()
682
+ _, _, _, rh.ha_config = pickle.loads(content)
683
+ elif not await rh.get_ha_config():
684
+ return False
685
+ if isinstance(params, dict):
686
+ params_str = orjson.dumps(params).decode("utf-8")
687
+ params = utils.update_params_with_ha_config(params_str, rh.ha_config)
688
+ else:
689
+ params = utils.update_params_with_ha_config(params, rh.ha_config)
690
+ if isinstance(params, str):
691
+ params = dict(orjson.loads(params))
692
+ costfun = optim_conf.get("costfun", costfun)
693
+ fcst = Forecast(
694
+ retrieve_hass_conf,
695
+ optim_conf,
696
+ plant_conf,
697
+ params,
698
+ emhass_conf,
699
+ logger,
700
+ get_data_from_file=get_data_from_file,
701
+ )
702
+ opt = Optimization(
703
+ retrieve_hass_conf,
704
+ optim_conf,
705
+ plant_conf,
706
+ fcst.var_load_cost,
707
+ fcst.var_prod_price,
708
+ costfun,
709
+ emhass_conf,
710
+ logger,
711
+ )
712
+ # Create SetupContext
713
+ ctx = SetupContext(
714
+ retrieve_hass_conf=retrieve_hass_conf,
715
+ optim_conf=optim_conf,
716
+ plant_conf=plant_conf,
717
+ emhass_conf=emhass_conf,
718
+ params=params,
719
+ logger=logger,
720
+ get_data_from_file=get_data_from_file,
721
+ rh=rh,
722
+ fcst=fcst,
723
+ )
724
+ # Initialize Default Return Data
725
+ data_results = {
726
+ "df_input_data": None,
727
+ "df_input_data_dayahead": None,
728
+ "df_weather": None,
729
+ "p_pv_forecast": None,
730
+ "p_load_forecast": None,
731
+ "days_list": None,
732
+ }
733
+ # Delegate to Helpers based on set_type
734
+ result = None
69
735
  if set_type == "perfect-optim":
70
- # Retrieve data from hass
71
- if get_data_from_file:
72
- with open(emhass_conf['data_path'] / 'test_df_final.pkl', 'rb') as inp:
73
- rh.df_final, days_list, var_list = pickle.load(inp)
74
- retrieve_hass_conf['var_load'] = str(var_list[0])
75
- retrieve_hass_conf['var_PV'] = str(var_list[1])
76
- retrieve_hass_conf['var_interp'] = [
77
- retrieve_hass_conf['var_PV'], retrieve_hass_conf['var_load']]
78
- retrieve_hass_conf['var_replace_zero'] = [
79
- retrieve_hass_conf['var_PV']]
80
- else:
81
- days_list = utils.get_days_list(
82
- retrieve_hass_conf["days_to_retrieve"])
83
- var_list = [retrieve_hass_conf["var_load"],
84
- retrieve_hass_conf["var_PV"]]
85
- if not rh.get_data(days_list, var_list, minimal_response=False, significant_changes_only=False):
86
- return False
87
- if not rh.prepare_data(retrieve_hass_conf["var_load"],
88
- load_negative=retrieve_hass_conf["load_negative"],
89
- set_zero_min=retrieve_hass_conf["set_zero_min"],
90
- var_replace_zero=retrieve_hass_conf["var_replace_zero"],
91
- var_interp=retrieve_hass_conf["var_interp"]):
92
- return False
93
- df_input_data = rh.df_final.copy()
94
- # What we don't need for this type of action
95
- P_PV_forecast, P_load_forecast, df_input_data_dayahead = None, None, None
736
+ result = await _prepare_perfect_optim(ctx)
96
737
  elif set_type == "dayahead-optim":
97
- # Get PV and load forecasts
98
- df_weather = fcst.get_weather_forecast(
99
- method=optim_conf["weather_forecast_method"])
100
- if isinstance(df_weather, bool) and not df_weather:
101
- return False
102
- P_PV_forecast = fcst.get_power_from_weather(df_weather)
103
- P_load_forecast = fcst.get_load_forecast(
104
- method=optim_conf['load_forecast_method'])
105
- if isinstance(P_load_forecast, bool) and not P_load_forecast:
106
- logger.error(
107
- "Unable to get sensor power photovoltaics, or sensor power load no var loads. Check HA sensors and their daily data")
108
- return False
109
- df_input_data_dayahead = pd.DataFrame(np.transpose(np.vstack(
110
- [P_PV_forecast.values, P_load_forecast.values])), index=P_PV_forecast.index,
111
- columns=["P_PV_forecast", "P_load_forecast"])
112
- df_input_data_dayahead = utils.set_df_index_freq(df_input_data_dayahead)
113
- params = json.loads(params)
114
- if ("prediction_horizon" in params["passed_data"] and params["passed_data"]["prediction_horizon"] is not None):
115
- prediction_horizon = params["passed_data"]["prediction_horizon"]
116
- df_input_data_dayahead = copy.deepcopy(df_input_data_dayahead)[
117
- df_input_data_dayahead.index[0]: df_input_data_dayahead.index[prediction_horizon - 1]]
118
- # What we don't need for this type of action
119
- df_input_data, days_list = None, None
738
+ result = await _prepare_dayahead_optim(ctx)
120
739
  elif set_type == "naive-mpc-optim":
121
- # Retrieve data from hass
122
- if get_data_from_file:
123
- with open(emhass_conf['data_path'] / 'test_df_final.pkl', 'rb') as inp:
124
- rh.df_final, days_list, var_list = pickle.load(inp)
125
- retrieve_hass_conf['var_load'] = str(var_list[0])
126
- retrieve_hass_conf['var_PV'] = str(var_list[1])
127
- retrieve_hass_conf['var_interp'] = [
128
- retrieve_hass_conf['var_PV'], retrieve_hass_conf['var_load']]
129
- retrieve_hass_conf['var_replace_zero'] = [
130
- retrieve_hass_conf['var_PV']]
131
- else:
132
- days_list = utils.get_days_list(1)
133
- var_list = [retrieve_hass_conf["var_load"],
134
- retrieve_hass_conf["var_PV"]]
135
- if not rh.get_data(days_list, var_list, minimal_response=False, significant_changes_only=False):
136
- return False
137
- if not rh.prepare_data(retrieve_hass_conf["var_load"],
138
- load_negative=retrieve_hass_conf["load_negative"],
139
- set_zero_min=retrieve_hass_conf["set_zero_min"],
140
- var_replace_zero=retrieve_hass_conf["var_replace_zero"],
141
- var_interp=retrieve_hass_conf["var_interp"]):
142
- return False
143
- df_input_data = rh.df_final.copy()
144
- # Get PV and load forecasts
145
- df_weather = fcst.get_weather_forecast(
146
- method=optim_conf['weather_forecast_method'])
147
- if isinstance(df_weather, bool) and not df_weather:
148
- return False
149
- P_PV_forecast = fcst.get_power_from_weather(
150
- df_weather, set_mix_forecast=True, df_now=df_input_data)
151
- P_load_forecast = fcst.get_load_forecast(
152
- method=optim_conf['load_forecast_method'], set_mix_forecast=True, df_now=df_input_data)
153
- if isinstance(P_load_forecast, bool) and not P_load_forecast:
154
- logger.error(
155
- "Unable to get sensor power photovoltaics, or sensor power load no var loads. Check HA sensors and their daily data")
156
- return False
157
- df_input_data_dayahead = pd.concat([P_PV_forecast, P_load_forecast], axis=1)
158
- df_input_data_dayahead = utils.set_df_index_freq(df_input_data_dayahead)
159
- df_input_data_dayahead.columns = ["P_PV_forecast", "P_load_forecast"]
160
- params = json.loads(params)
161
- if ("prediction_horizon" in params["passed_data"] and params["passed_data"]["prediction_horizon"] is not None):
162
- prediction_horizon = params["passed_data"]["prediction_horizon"]
163
- df_input_data_dayahead = copy.deepcopy(df_input_data_dayahead)[
164
- df_input_data_dayahead.index[0]: df_input_data_dayahead.index[prediction_horizon - 1]]
165
- elif (set_type == "forecast-model-fit" or set_type == "forecast-model-predict" or set_type == "forecast-model-tune"):
166
- df_input_data_dayahead = None
167
- P_PV_forecast, P_load_forecast = None, None
168
- params = json.loads(params)
169
- # Retrieve data from hass
170
- days_to_retrieve = params["passed_data"]["days_to_retrieve"]
171
- model_type = params["passed_data"]["model_type"]
172
- var_model = params["passed_data"]["var_model"]
173
- if get_data_from_file:
174
- days_list = None
175
- filename = 'data_train_'+model_type+'.pkl'
176
- filename_path = emhass_conf['data_path'] / filename
177
- with open(filename_path, 'rb') as inp:
178
- df_input_data, _ = pickle.load(inp)
179
- df_input_data = df_input_data[df_input_data.index[-1] - pd.offsets.Day(days_to_retrieve):]
180
- else:
181
- days_list = utils.get_days_list(days_to_retrieve)
182
- var_list = [var_model]
183
- if not rh.get_data(days_list, var_list):
184
- return False
185
- df_input_data = rh.df_final.copy()
186
- elif set_type == "regressor-model-fit" or set_type == "regressor-model-predict":
187
- df_input_data, df_input_data_dayahead = None, None
188
- P_PV_forecast, P_load_forecast = None, None
189
- params = json.loads(params)
190
- days_list = None
191
- csv_file = params["passed_data"].get("csv_file", None)
192
- if "features" in params["passed_data"]:
193
- features = params["passed_data"]["features"]
194
- if "target" in params["passed_data"]:
195
- target = params["passed_data"]["target"]
196
- if "timestamp" in params["passed_data"]:
197
- timestamp = params["passed_data"]["timestamp"]
198
- if csv_file:
199
- if get_data_from_file:
200
- base_path = emhass_conf["data_path"] # + "/data"
201
- filename_path = pathlib.Path(base_path) / csv_file
202
- else:
203
- filename_path = emhass_conf["data_path"] / csv_file
204
- if filename_path.is_file():
205
- df_input_data = pd.read_csv(filename_path, parse_dates=True)
206
- else:
207
- logger.error("The CSV file " + csv_file +
208
- " was not found in path: " + str(emhass_conf["data_path"]))
209
- return False
210
- # raise ValueError("The CSV file " + csv_file + " was not found.")
211
- required_columns = []
212
- required_columns.extend(features)
213
- required_columns.append(target)
214
- if timestamp is not None:
215
- required_columns.append(timestamp)
216
- if not set(required_columns).issubset(df_input_data.columns):
217
- logger.error(
218
- "The cvs file does not contain the required columns.")
219
- msg = f"CSV file should contain the following columns: {', '.join(required_columns)}"
220
- logger.error(msg)
221
- return False
222
- elif set_type == "publish-data":
223
- df_input_data, df_input_data_dayahead = None, None
224
- P_PV_forecast, P_load_forecast = None, None
225
- days_list = None
740
+ result = await _prepare_naive_mpc_optim(ctx)
741
+ elif set_type in ["forecast-model-fit", "forecast-model-predict", "forecast-model-tune"]:
742
+ result = await _prepare_ml_fit_predict(ctx)
743
+ elif set_type in ["regressor-model-fit", "regressor-model-predict"]:
744
+ result = _prepare_regressor_fit(ctx)
745
+ elif set_type == "publish-data" or set_type == "export-influxdb-to-csv":
746
+ result = {}
226
747
  else:
227
- logger.error(
228
- "The passed action argument and hence the set_type parameter for setup is not valid",
229
- )
230
- df_input_data, df_input_data_dayahead = None, None
231
- P_PV_forecast, P_load_forecast = None, None
232
- days_list = None
233
- # The input data dictionary to return
748
+ logger.error(f"The passed action set_type parameter '{set_type}' is not valid")
749
+ result = {}
750
+ if result is None:
751
+ return False
752
+ data_results.update(result)
753
+ # Build Final Dictionary
234
754
  input_data_dict = {
235
- 'emhass_conf': emhass_conf,
236
- 'retrieve_hass_conf': retrieve_hass_conf,
237
- 'rh': rh,
238
- 'opt': opt,
239
- 'fcst': fcst,
240
- 'df_input_data': df_input_data,
241
- 'df_input_data_dayahead': df_input_data_dayahead,
242
- 'P_PV_forecast': P_PV_forecast,
243
- 'P_load_forecast': P_load_forecast,
244
- 'costfun': costfun,
245
- 'params': params,
246
- 'days_list': days_list
755
+ "emhass_conf": emhass_conf,
756
+ "retrieve_hass_conf": retrieve_hass_conf,
757
+ "rh": rh,
758
+ "opt": opt,
759
+ "fcst": fcst,
760
+ "costfun": costfun,
761
+ "params": params,
762
+ **data_results,
247
763
  }
248
764
  return input_data_dict
249
765
 
250
- def weather_forecast_cache(emhass_conf: dict, params: str,
251
- runtimeparams: str, logger: logging.Logger) -> bool:
766
+
767
+ async def weather_forecast_cache(
768
+ emhass_conf: dict, params: str, runtimeparams: str, logger: logging.Logger
769
+ ) -> bool:
252
770
  """
253
771
  Perform a call to get forecast function, intend to save results to cache.
254
772
 
@@ -264,36 +782,46 @@ def weather_forecast_cache(emhass_conf: dict, params: str,
264
782
  :rtype: bool
265
783
 
266
784
  """
267
-
268
785
  # Parsing yaml
269
- retrieve_hass_conf, optim_conf, plant_conf = utils.get_yaml_parse(
270
- emhass_conf, use_secrets=True, params=params)
271
-
786
+ retrieve_hass_conf, optim_conf, plant_conf = utils.get_yaml_parse(params, logger)
272
787
  # Treat runtimeparams
273
- params, retrieve_hass_conf, optim_conf, plant_conf = utils.treat_runtimeparams(
274
- runtimeparams, params, retrieve_hass_conf, optim_conf, plant_conf, "forecast", logger)
275
-
788
+ (
789
+ params,
790
+ retrieve_hass_conf,
791
+ optim_conf,
792
+ plant_conf,
793
+ ) = await utils.treat_runtimeparams(
794
+ runtimeparams,
795
+ params,
796
+ retrieve_hass_conf,
797
+ optim_conf,
798
+ plant_conf,
799
+ "forecast",
800
+ logger,
801
+ emhass_conf,
802
+ )
276
803
  # Make sure weather_forecast_cache is true
277
- if (params != None) and (params != "null"):
278
- params = json.loads(params)
804
+ if (params is not None) and (params != "null"):
805
+ params = orjson.loads(params)
279
806
  else:
280
807
  params = {}
281
808
  params["passed_data"]["weather_forecast_cache"] = True
282
- params = json.dumps(params)
283
-
809
+ params = orjson.dumps(params).decode("utf-8")
284
810
  # Create Forecast object
285
- fcst = Forecast(retrieve_hass_conf, optim_conf, plant_conf,
286
- params, emhass_conf, logger)
287
-
288
- result = fcst.get_weather_forecast(optim_conf["weather_forecast_method"])
811
+ fcst = Forecast(retrieve_hass_conf, optim_conf, plant_conf, params, emhass_conf, logger)
812
+ result = await fcst.get_weather_forecast(optim_conf["weather_forecast_method"])
289
813
  if isinstance(result, bool) and not result:
290
814
  return False
291
815
 
292
816
  return True
293
817
 
294
- def perfect_forecast_optim(input_data_dict: dict, logger: logging.Logger,
295
- save_data_to_file: Optional[bool] = True,
296
- debug: Optional[bool] = False) -> pd.DataFrame:
818
+
819
+ async def perfect_forecast_optim(
820
+ input_data_dict: dict,
821
+ logger: logging.Logger,
822
+ save_data_to_file: bool | None = True,
823
+ debug: bool | None = False,
824
+ ) -> pd.DataFrame:
297
825
  """
298
826
  Perform a call to the perfect forecast optimization routine.
299
827
 
@@ -311,43 +839,131 @@ def perfect_forecast_optim(input_data_dict: dict, logger: logging.Logger,
311
839
  """
312
840
  logger.info("Performing perfect forecast optimization")
313
841
  # Load cost and prod price forecast
314
- df_input_data = input_data_dict['fcst'].get_load_cost_forecast(
315
- input_data_dict['df_input_data'],
316
- method=input_data_dict['fcst'].optim_conf['load_cost_forecast_method'],
317
- list_and_perfect=True)
842
+ df_input_data = input_data_dict["fcst"].get_load_cost_forecast(
843
+ input_data_dict["df_input_data"],
844
+ method=input_data_dict["fcst"].optim_conf["load_cost_forecast_method"],
845
+ list_and_perfect=True,
846
+ )
318
847
  if isinstance(df_input_data, bool) and not df_input_data:
319
848
  return False
320
- df_input_data = input_data_dict['fcst'].get_prod_price_forecast(
321
- df_input_data, method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method'],
322
- list_and_perfect=True)
849
+ df_input_data = input_data_dict["fcst"].get_prod_price_forecast(
850
+ df_input_data,
851
+ method=input_data_dict["fcst"].optim_conf["production_price_forecast_method"],
852
+ list_and_perfect=True,
853
+ )
323
854
  if isinstance(df_input_data, bool) and not df_input_data:
324
855
  return False
325
- opt_res = input_data_dict['opt'].perform_perfect_forecast_optim(
326
- df_input_data, input_data_dict['days_list'])
856
+ opt_res = input_data_dict["opt"].perform_perfect_forecast_optim(
857
+ df_input_data, input_data_dict["days_list"]
858
+ )
327
859
  # Save CSV file for analysis
328
860
  if save_data_to_file:
329
- filename = "opt_res_perfect_optim_" + \
330
- input_data_dict["costfun"] + ".csv"
861
+ filename = "opt_res_perfect_optim_" + input_data_dict["costfun"] + ".csv"
331
862
  else: # Just save the latest optimization results
332
- filename = "opt_res_latest.csv"
863
+ filename = default_csv_filename
333
864
  if not debug:
334
865
  opt_res.to_csv(
335
- input_data_dict['emhass_conf']['data_path'] / filename, index_label='timestamp')
336
- if not isinstance(input_data_dict["params"],dict):
337
- params = json.loads(input_data_dict["params"])
866
+ input_data_dict["emhass_conf"]["data_path"] / filename,
867
+ index_label="timestamp",
868
+ )
869
+ if not isinstance(input_data_dict["params"], dict):
870
+ params = orjson.loads(input_data_dict["params"])
338
871
  else:
339
872
  params = input_data_dict["params"]
340
873
 
341
874
  # if continual_publish, save perfect results to data_path/entities json
342
- if input_data_dict["retrieve_hass_conf"].get("continual_publish",False) or params["passed_data"].get("entity_save",False):
343
- #Trigger the publish function, save entity data and not post to HA
344
- publish_data(input_data_dict, logger, entity_save=True, dont_post=True)
875
+ if input_data_dict["retrieve_hass_conf"].get("continual_publish", False) or params[
876
+ "passed_data"
877
+ ].get("entity_save", False):
878
+ # Trigger the publish function, save entity data and not post to HA
879
+ await publish_data(input_data_dict, logger, entity_save=True, dont_post=True)
345
880
 
346
881
  return opt_res
347
882
 
348
- def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger,
349
- save_data_to_file: Optional[bool] = False,
350
- debug: Optional[bool] = False) -> pd.DataFrame:
883
+
884
+ def prepare_forecast_and_weather_data(
885
+ input_data_dict: dict,
886
+ logger: logging.Logger,
887
+ warn_on_resolution: bool = False,
888
+ ) -> pd.DataFrame | bool:
889
+ """
890
+ Prepare forecast data with load costs, production prices, outdoor temperature, and GHI.
891
+
892
+ This helper function eliminates duplication between dayahead_forecast_optim and naive_mpc_optim.
893
+
894
+ :param input_data_dict: Dictionary with forecast and input data
895
+ :type input_data_dict: dict
896
+ :param logger: Logger object
897
+ :type logger: logging.Logger
898
+ :param warn_on_resolution: Whether to warn about GHI resolution mismatch
899
+ :type warn_on_resolution: bool
900
+ :return: Prepared DataFrame or False on error
901
+ :rtype: pd.DataFrame | bool
902
+ """
903
+ # Get load cost forecast
904
+ df_input_data_dayahead = input_data_dict["fcst"].get_load_cost_forecast(
905
+ input_data_dict["df_input_data_dayahead"],
906
+ method=input_data_dict["fcst"].optim_conf["load_cost_forecast_method"],
907
+ )
908
+ if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
909
+ return False
910
+
911
+ # Get production price forecast
912
+ df_input_data_dayahead = input_data_dict["fcst"].get_prod_price_forecast(
913
+ df_input_data_dayahead,
914
+ method=input_data_dict["fcst"].optim_conf["production_price_forecast_method"],
915
+ )
916
+ if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
917
+ return False
918
+
919
+ # Add outdoor temperature if provided
920
+ if "outdoor_temperature_forecast" in input_data_dict["params"]["passed_data"]:
921
+ df_input_data_dayahead["outdoor_temperature_forecast"] = input_data_dict["params"][
922
+ "passed_data"
923
+ ]["outdoor_temperature_forecast"]
924
+
925
+ # Merge GHI (Global Horizontal Irradiance) from weather forecast if available
926
+ if input_data_dict["df_weather"] is not None and "ghi" in input_data_dict["df_weather"].columns:
927
+ dayahead_index = df_input_data_dayahead.index
928
+
929
+ # Check time resolution if requested
930
+ if (
931
+ warn_on_resolution
932
+ and len(input_data_dict["df_weather"].index) > 1
933
+ and len(dayahead_index) > 1
934
+ ):
935
+ weather_index = input_data_dict["df_weather"].index
936
+ weather_freq = (weather_index[1] - weather_index[0]).total_seconds()
937
+ dayahead_freq = (dayahead_index[1] - dayahead_index[0]).total_seconds()
938
+ if weather_freq > 2 * dayahead_freq:
939
+ logger.warning(
940
+ "Weather data time resolution (%.0fs) is much coarser than dayahead index (%.0fs). "
941
+ "Step changes in GHI may occur.",
942
+ weather_freq,
943
+ dayahead_freq,
944
+ )
945
+
946
+ # Align GHI data to dayahead index using interpolation
947
+ df_input_data_dayahead["ghi"] = (
948
+ input_data_dict["df_weather"]["ghi"]
949
+ .reindex(dayahead_index)
950
+ .interpolate(method="time", limit_direction="both")
951
+ )
952
+ logger.debug(
953
+ "Merged GHI data into optimization input: mean=%.1f W/m², max=%.1f W/m²",
954
+ df_input_data_dayahead["ghi"].mean(),
955
+ df_input_data_dayahead["ghi"].max(),
956
+ )
957
+
958
+ return df_input_data_dayahead
959
+
960
+
961
+ async def dayahead_forecast_optim(
962
+ input_data_dict: dict,
963
+ logger: logging.Logger,
964
+ save_data_to_file: bool | None = False,
965
+ debug: bool | None = False,
966
+ ) -> pd.DataFrame:
351
967
  """
352
968
  Perform a call to the day-ahead optimization routine.
353
969
 
@@ -364,49 +980,50 @@ def dayahead_forecast_optim(input_data_dict: dict, logger: logging.Logger,
364
980
 
365
981
  """
366
982
  logger.info("Performing day-ahead forecast optimization")
367
- # Load cost and prod price forecast
368
- df_input_data_dayahead = input_data_dict['fcst'].get_load_cost_forecast(
369
- input_data_dict['df_input_data_dayahead'],
370
- method=input_data_dict['fcst'].optim_conf['load_cost_forecast_method'])
983
+ # Prepare forecast data with costs, prices, outdoor temp, and GHI
984
+ df_input_data_dayahead = prepare_forecast_and_weather_data(
985
+ input_data_dict, logger, warn_on_resolution=False
986
+ )
371
987
  if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
372
988
  return False
373
- df_input_data_dayahead = input_data_dict['fcst'].get_prod_price_forecast(
989
+ opt_res_dayahead = input_data_dict["opt"].perform_dayahead_forecast_optim(
374
990
  df_input_data_dayahead,
375
- method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method'])
376
- if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
377
- return False
378
- if "outdoor_temperature_forecast" in input_data_dict["params"]["passed_data"]:
379
- df_input_data_dayahead["outdoor_temperature_forecast"] = \
380
- input_data_dict["params"]["passed_data"]["outdoor_temperature_forecast"]
381
- opt_res_dayahead = input_data_dict['opt'].perform_dayahead_forecast_optim(
382
- df_input_data_dayahead, input_data_dict['P_PV_forecast'], input_data_dict['P_load_forecast'])
991
+ input_data_dict["p_pv_forecast"],
992
+ input_data_dict["p_load_forecast"],
993
+ )
383
994
  # Save CSV file for publish_data
384
995
  if save_data_to_file:
385
- today = datetime.now(timezone.utc).replace(
386
- hour=0, minute=0, second=0, microsecond=0
387
- )
996
+ today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
388
997
  filename = "opt_res_dayahead_" + today.strftime("%Y_%m_%d") + ".csv"
389
998
  else: # Just save the latest optimization results
390
- filename = "opt_res_latest.csv"
999
+ filename = default_csv_filename
391
1000
  if not debug:
392
1001
  opt_res_dayahead.to_csv(
393
- input_data_dict['emhass_conf']['data_path'] / filename, index_label='timestamp')
394
-
395
- if not isinstance(input_data_dict["params"],dict):
396
- params = json.loads(input_data_dict["params"])
1002
+ input_data_dict["emhass_conf"]["data_path"] / filename,
1003
+ index_label="timestamp",
1004
+ )
1005
+
1006
+ if not isinstance(input_data_dict["params"], dict):
1007
+ params = orjson.loads(input_data_dict["params"])
397
1008
  else:
398
1009
  params = input_data_dict["params"]
399
-
1010
+
400
1011
  # if continual_publish, save day_ahead results to data_path/entities json
401
- if input_data_dict["retrieve_hass_conf"].get("continual_publish",False) or params["passed_data"].get("entity_save",False):
402
- #Trigger the publish function, save entity data and not post to HA
403
- publish_data(input_data_dict, logger, entity_save=True, dont_post=True)
404
-
1012
+ if input_data_dict["retrieve_hass_conf"].get("continual_publish", False) or params[
1013
+ "passed_data"
1014
+ ].get("entity_save", False):
1015
+ # Trigger the publish function, save entity data and not post to HA
1016
+ await publish_data(input_data_dict, logger, entity_save=True, dont_post=True)
1017
+
405
1018
  return opt_res_dayahead
406
1019
 
407
- def naive_mpc_optim(input_data_dict: dict, logger: logging.Logger,
408
- save_data_to_file: Optional[bool] = False,
409
- debug: Optional[bool] = False) -> pd.DataFrame:
1020
+
1021
+ async def naive_mpc_optim(
1022
+ input_data_dict: dict,
1023
+ logger: logging.Logger,
1024
+ save_data_to_file: bool | None = False,
1025
+ debug: bool | None = False,
1026
+ ) -> pd.DataFrame:
410
1027
  """
411
1028
  Perform a call to the naive Model Predictive Controller optimization routine.
412
1029
 
@@ -423,56 +1040,70 @@ def naive_mpc_optim(input_data_dict: dict, logger: logging.Logger,
423
1040
 
424
1041
  """
425
1042
  logger.info("Performing naive MPC optimization")
426
- # Load cost and prod price forecast
427
- df_input_data_dayahead = input_data_dict['fcst'].get_load_cost_forecast(
428
- input_data_dict['df_input_data_dayahead'],
429
- method=input_data_dict['fcst'].optim_conf['load_cost_forecast_method'])
430
- if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
431
- return False
432
- df_input_data_dayahead = input_data_dict['fcst'].get_prod_price_forecast(
433
- df_input_data_dayahead, method=input_data_dict['fcst'].optim_conf['prod_price_forecast_method'])
1043
+ # Prepare forecast data with costs, prices, outdoor temp, and GHI (with resolution warning)
1044
+ df_input_data_dayahead = prepare_forecast_and_weather_data(
1045
+ input_data_dict, logger, warn_on_resolution=True
1046
+ )
434
1047
  if isinstance(df_input_data_dayahead, bool) and not df_input_data_dayahead:
435
1048
  return False
436
- if "outdoor_temperature_forecast" in input_data_dict["params"]["passed_data"]:
437
- df_input_data_dayahead["outdoor_temperature_forecast"] = \
438
- input_data_dict["params"]["passed_data"]["outdoor_temperature_forecast"]
439
1049
  # The specifics params for the MPC at runtime
440
1050
  prediction_horizon = input_data_dict["params"]["passed_data"]["prediction_horizon"]
441
1051
  soc_init = input_data_dict["params"]["passed_data"]["soc_init"]
442
1052
  soc_final = input_data_dict["params"]["passed_data"]["soc_final"]
443
- def_total_hours = input_data_dict["params"]["passed_data"]["def_total_hours"]
444
- def_start_timestep = input_data_dict["params"]["passed_data"]["def_start_timestep"]
445
- def_end_timestep = input_data_dict["params"]["passed_data"]["def_end_timestep"]
1053
+ def_total_hours = input_data_dict["params"]["optim_conf"].get(
1054
+ "operating_hours_of_each_deferrable_load", None
1055
+ )
1056
+ def_total_timestep = input_data_dict["params"]["optim_conf"].get(
1057
+ "operating_timesteps_of_each_deferrable_load", None
1058
+ )
1059
+ def_start_timestep = input_data_dict["params"]["optim_conf"][
1060
+ "start_timesteps_of_each_deferrable_load"
1061
+ ]
1062
+ def_end_timestep = input_data_dict["params"]["optim_conf"][
1063
+ "end_timesteps_of_each_deferrable_load"
1064
+ ]
446
1065
  opt_res_naive_mpc = input_data_dict["opt"].perform_naive_mpc_optim(
447
- df_input_data_dayahead, input_data_dict["P_PV_forecast"], input_data_dict["P_load_forecast"],
448
- prediction_horizon, soc_init, soc_final, def_total_hours,
449
- def_start_timestep, def_end_timestep)
1066
+ df_input_data_dayahead,
1067
+ input_data_dict["p_pv_forecast"],
1068
+ input_data_dict["p_load_forecast"],
1069
+ prediction_horizon,
1070
+ soc_init,
1071
+ soc_final,
1072
+ def_total_hours,
1073
+ def_total_timestep,
1074
+ def_start_timestep,
1075
+ def_end_timestep,
1076
+ )
450
1077
  # Save CSV file for publish_data
451
1078
  if save_data_to_file:
452
- today = datetime.now(timezone.utc).replace(
453
- hour=0, minute=0, second=0, microsecond=0
454
- )
1079
+ today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
455
1080
  filename = "opt_res_naive_mpc_" + today.strftime("%Y_%m_%d") + ".csv"
456
1081
  else: # Just save the latest optimization results
457
- filename = "opt_res_latest.csv"
1082
+ filename = default_csv_filename
458
1083
  if not debug:
459
1084
  opt_res_naive_mpc.to_csv(
460
- input_data_dict['emhass_conf']['data_path'] / filename, index_label='timestamp')
461
-
462
- if not isinstance(input_data_dict["params"],dict):
463
- params = json.loads(input_data_dict["params"])
1085
+ input_data_dict["emhass_conf"]["data_path"] / filename,
1086
+ index_label="timestamp",
1087
+ )
1088
+
1089
+ if not isinstance(input_data_dict["params"], dict):
1090
+ params = orjson.loads(input_data_dict["params"])
464
1091
  else:
465
1092
  params = input_data_dict["params"]
466
1093
 
467
1094
  # if continual_publish, save mpc results to data_path/entities json
468
- if input_data_dict["retrieve_hass_conf"].get("continual_publish",False) or params["passed_data"].get("entity_save",False):
469
- #Trigger the publish function, save entity data and not post to HA
470
- publish_data(input_data_dict, logger, entity_save=True, dont_post=True)
1095
+ if input_data_dict["retrieve_hass_conf"].get("continual_publish", False) or params[
1096
+ "passed_data"
1097
+ ].get("entity_save", False):
1098
+ # Trigger the publish function, save entity data and not post to HA
1099
+ await publish_data(input_data_dict, logger, entity_save=True, dont_post=True)
471
1100
 
472
1101
  return opt_res_naive_mpc
473
1102
 
474
- def forecast_model_fit(input_data_dict: dict, logger: logging.Logger,
475
- debug: Optional[bool] = False) -> Tuple[pd.DataFrame, pd.DataFrame, MLForecaster]:
1103
+
1104
+ async def forecast_model_fit(
1105
+ input_data_dict: dict, logger: logging.Logger, debug: bool | None = False
1106
+ ) -> tuple[pd.DataFrame, pd.DataFrame, MLForecaster]:
476
1107
  """Perform a forecast model fit from training data retrieved from Home Assistant.
477
1108
 
478
1109
  :param input_data_dict: A dictionnary with multiple data used by the action functions
@@ -484,32 +1115,44 @@ def forecast_model_fit(input_data_dict: dict, logger: logging.Logger,
484
1115
  :return: The DataFrame containing the forecast data results without and with backtest and the `mlforecaster` object
485
1116
  :rtype: Tuple[pd.DataFrame, pd.DataFrame, mlforecaster]
486
1117
  """
487
- data = copy.deepcopy(input_data_dict['df_input_data'])
488
- model_type = input_data_dict['params']['passed_data']['model_type']
489
- var_model = input_data_dict['params']['passed_data']['var_model']
490
- sklearn_model = input_data_dict['params']['passed_data']['sklearn_model']
491
- num_lags = input_data_dict['params']['passed_data']['num_lags']
492
- split_date_delta = input_data_dict['params']['passed_data']['split_date_delta']
493
- perform_backtest = input_data_dict['params']['passed_data']['perform_backtest']
1118
+ data = copy.deepcopy(input_data_dict["df_input_data"])
1119
+ model_type = input_data_dict["params"]["passed_data"]["model_type"]
1120
+ var_model = input_data_dict["params"]["passed_data"]["var_model"]
1121
+ sklearn_model = input_data_dict["params"]["passed_data"]["sklearn_model"]
1122
+ num_lags = input_data_dict["params"]["passed_data"]["num_lags"]
1123
+ split_date_delta = input_data_dict["params"]["passed_data"]["split_date_delta"]
1124
+ perform_backtest = input_data_dict["params"]["passed_data"]["perform_backtest"]
494
1125
  # The ML forecaster object
495
- mlf = MLForecaster(data, model_type, var_model, sklearn_model,
496
- num_lags, input_data_dict['emhass_conf'], logger)
1126
+ mlf = MLForecaster(
1127
+ data,
1128
+ model_type,
1129
+ var_model,
1130
+ sklearn_model,
1131
+ num_lags,
1132
+ input_data_dict["emhass_conf"],
1133
+ logger,
1134
+ )
497
1135
  # Fit the ML model
498
- df_pred, df_pred_backtest = mlf.fit(
1136
+ df_pred, df_pred_backtest = await mlf.fit(
499
1137
  split_date_delta=split_date_delta, perform_backtest=perform_backtest
500
1138
  )
501
1139
  # Save model
502
1140
  if not debug:
503
- filename = model_type+'_mlf.pkl'
504
- filename_path = input_data_dict['emhass_conf']['data_path'] / filename
505
- with open(filename_path, 'wb') as outp:
506
- pickle.dump(mlf, outp, pickle.HIGHEST_PROTOCOL)
1141
+ filename = model_type + default_pkl_suffix
1142
+ filename_path = input_data_dict["emhass_conf"]["data_path"] / filename
1143
+ async with aiofiles.open(filename_path, "wb") as outp:
1144
+ await outp.write(pickle.dumps(mlf, pickle.HIGHEST_PROTOCOL))
1145
+ logger.debug("saved model to " + str(filename_path))
507
1146
  return df_pred, df_pred_backtest, mlf
508
1147
 
509
- def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
510
- use_last_window: Optional[bool] = True,
511
- debug: Optional[bool] = False, mlf: Optional[MLForecaster] = None
512
- ) -> pd.DataFrame:
1148
+
1149
+ async def forecast_model_predict(
1150
+ input_data_dict: dict,
1151
+ logger: logging.Logger,
1152
+ use_last_window: bool | None = True,
1153
+ debug: bool | None = False,
1154
+ mlf: MLForecaster | None = None,
1155
+ ) -> pd.DataFrame:
513
1156
  r"""Perform a forecast model predict using a previously trained skforecast model.
514
1157
 
515
1158
  :param input_data_dict: A dictionnary with multiple data used by the action functions
@@ -531,16 +1174,20 @@ def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
531
1174
  :rtype: pd.DataFrame
532
1175
  """
533
1176
  # Load model
534
- model_type = input_data_dict['params']['passed_data']['model_type']
535
- filename = model_type+'_mlf.pkl'
536
- filename_path = input_data_dict['emhass_conf']['data_path'] / filename
1177
+ model_type = input_data_dict["params"]["passed_data"]["model_type"]
1178
+ filename = model_type + default_pkl_suffix
1179
+ filename_path = input_data_dict["emhass_conf"]["data_path"] / filename
537
1180
  if not debug:
538
1181
  if filename_path.is_file():
539
- with open(filename_path, "rb") as inp:
540
- mlf = pickle.load(inp)
1182
+ async with aiofiles.open(filename_path, "rb") as inp:
1183
+ content = await inp.read()
1184
+ mlf = pickle.loads(content)
1185
+ logger.debug("loaded saved model from " + str(filename_path))
541
1186
  else:
542
1187
  logger.error(
543
- "The ML forecaster file was not found, please run a model fit method before this predict method",
1188
+ "The ML forecaster file ("
1189
+ + str(filename_path)
1190
+ + ") was not found, please run a model fit method before this predict method",
544
1191
  )
545
1192
  return
546
1193
  # Make predictions
@@ -548,13 +1195,12 @@ def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
548
1195
  data_last_window = copy.deepcopy(input_data_dict["df_input_data"])
549
1196
  else:
550
1197
  data_last_window = None
551
- predictions = mlf.predict(data_last_window)
1198
+ predictions = await mlf.predict(data_last_window)
552
1199
  # Publish data to a Home Assistant sensor
553
- model_predict_publish = input_data_dict["params"]["passed_data"][
554
- "model_predict_publish"
555
- ]
556
- model_predict_entity_id = input_data_dict["params"]["passed_data"][
557
- "model_predict_entity_id"
1200
+ model_predict_publish = input_data_dict["params"]["passed_data"]["model_predict_publish"]
1201
+ model_predict_entity_id = input_data_dict["params"]["passed_data"]["model_predict_entity_id"]
1202
+ model_predict_device_class = input_data_dict["params"]["passed_data"][
1203
+ "model_predict_device_class"
558
1204
  ]
559
1205
  model_predict_unit_of_measurement = input_data_dict["params"]["passed_data"][
560
1206
  "model_predict_unit_of_measurement"
@@ -565,9 +1211,9 @@ def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
565
1211
  publish_prefix = input_data_dict["params"]["passed_data"]["publish_prefix"]
566
1212
  if model_predict_publish is True:
567
1213
  # Estimate the current index
568
- now_precise = datetime.now(
569
- input_data_dict["retrieve_hass_conf"]["time_zone"]
570
- ).replace(second=0, microsecond=0)
1214
+ now_precise = datetime.now(input_data_dict["retrieve_hass_conf"]["time_zone"]).replace(
1215
+ second=0, microsecond=0
1216
+ )
571
1217
  if input_data_dict["retrieve_hass_conf"]["method_ts_round"] == "nearest":
572
1218
  idx_closest = predictions.index.get_indexer([now_precise], method="nearest")[0]
573
1219
  elif input_data_dict["retrieve_hass_conf"]["method_ts_round"] == "first":
@@ -577,15 +1223,25 @@ def forecast_model_predict(input_data_dict: dict, logger: logging.Logger,
577
1223
  if idx_closest == -1:
578
1224
  idx_closest = predictions.index.get_indexer([now_precise], method="nearest")[0]
579
1225
  # Publish Load forecast
580
- input_data_dict["rh"].post_data(
581
- predictions, idx_closest, model_predict_entity_id,
582
- model_predict_unit_of_measurement, model_predict_friendly_name,
583
- type_var="mlforecaster", publish_prefix=publish_prefix)
1226
+ await input_data_dict["rh"].post_data(
1227
+ predictions,
1228
+ idx_closest,
1229
+ model_predict_entity_id,
1230
+ model_predict_device_class,
1231
+ model_predict_unit_of_measurement,
1232
+ model_predict_friendly_name,
1233
+ type_var="mlforecaster",
1234
+ publish_prefix=publish_prefix,
1235
+ )
584
1236
  return predictions
585
1237
 
586
- def forecast_model_tune(input_data_dict: dict, logger: logging.Logger,
587
- debug: Optional[bool] = False, mlf: Optional[MLForecaster] = None
588
- ) -> Tuple[pd.DataFrame, MLForecaster]:
1238
+
1239
+ async def forecast_model_tune(
1240
+ input_data_dict: dict,
1241
+ logger: logging.Logger,
1242
+ debug: bool | None = False,
1243
+ mlf: MLForecaster | None = None,
1244
+ ) -> tuple[pd.DataFrame, MLForecaster]:
589
1245
  """Tune a forecast model hyperparameters using bayesian optimization.
590
1246
 
591
1247
  :param input_data_dict: A dictionnary with multiple data used by the action functions
@@ -601,30 +1257,44 @@ def forecast_model_tune(input_data_dict: dict, logger: logging.Logger,
601
1257
  :rtype: pd.DataFrame
602
1258
  """
603
1259
  # Load model
604
- model_type = input_data_dict['params']['passed_data']['model_type']
605
- filename = model_type+'_mlf.pkl'
606
- filename_path = input_data_dict['emhass_conf']['data_path'] / filename
1260
+ model_type = input_data_dict["params"]["passed_data"]["model_type"]
1261
+ filename = model_type + default_pkl_suffix
1262
+ filename_path = input_data_dict["emhass_conf"]["data_path"] / filename
607
1263
  if not debug:
608
1264
  if filename_path.is_file():
609
- with open(filename_path, "rb") as inp:
610
- mlf = pickle.load(inp)
1265
+ async with aiofiles.open(filename_path, "rb") as inp:
1266
+ content = await inp.read()
1267
+ mlf = pickle.loads(content)
1268
+ logger.debug("loaded saved model from " + str(filename_path))
611
1269
  else:
612
1270
  logger.error(
613
- "The ML forecaster file was not found, please run a model fit method before this tune method",
1271
+ "The ML forecaster file ("
1272
+ + str(filename_path)
1273
+ + ") was not found, please run a model fit method before this tune method",
614
1274
  )
615
1275
  return None, None
616
1276
  # Tune the model
617
- df_pred_optim = mlf.tune(debug=debug)
1277
+ split_date_delta = input_data_dict["params"]["passed_data"]["split_date_delta"]
1278
+ if debug:
1279
+ n_trials = 5
1280
+ else:
1281
+ n_trials = input_data_dict["params"]["passed_data"]["n_trials"]
1282
+ df_pred_optim = await mlf.tune(
1283
+ split_date_delta=split_date_delta, n_trials=n_trials, debug=debug
1284
+ )
618
1285
  # Save model
619
1286
  if not debug:
620
- filename = model_type+'_mlf.pkl'
621
- filename_path = input_data_dict['emhass_conf']['data_path'] / filename
622
- with open(filename_path, 'wb') as outp:
623
- pickle.dump(mlf, outp, pickle.HIGHEST_PROTOCOL)
1287
+ filename = model_type + default_pkl_suffix
1288
+ filename_path = input_data_dict["emhass_conf"]["data_path"] / filename
1289
+ async with aiofiles.open(filename_path, "wb") as outp:
1290
+ await outp.write(pickle.dumps(mlf, pickle.HIGHEST_PROTOCOL))
1291
+ logger.debug("Saved model to " + str(filename_path))
624
1292
  return df_pred_optim, mlf
625
1293
 
626
- def regressor_model_fit(input_data_dict: dict, logger: logging.Logger,
627
- debug: Optional[bool] = False) -> MLRegressor:
1294
+
1295
+ async def regressor_model_fit(
1296
+ input_data_dict: dict, logger: logging.Logger, debug: bool | None = False
1297
+ ) -> MLRegressor:
628
1298
  """Perform a forecast model fit from training data retrieved from Home Assistant.
629
1299
 
630
1300
  :param input_data_dict: A dictionnary with multiple data used by the action functions
@@ -668,18 +1338,24 @@ def regressor_model_fit(input_data_dict: dict, logger: logging.Logger,
668
1338
  # The MLRegressor object
669
1339
  mlr = MLRegressor(data, model_type, regression_model, features, target, timestamp, logger)
670
1340
  # Fit the ML model
671
- mlr.fit(date_features=date_features)
1341
+ fit = await mlr.fit(date_features=date_features)
1342
+ if not fit:
1343
+ return False
672
1344
  # Save model
673
1345
  if not debug:
674
1346
  filename = model_type + "_mlr.pkl"
675
1347
  filename_path = input_data_dict["emhass_conf"]["data_path"] / filename
676
- with open(filename_path, "wb") as outp:
677
- pickle.dump(mlr, outp, pickle.HIGHEST_PROTOCOL)
1348
+ async with aiofiles.open(filename_path, "wb") as outp:
1349
+ await outp.write(pickle.dumps(mlr, pickle.HIGHEST_PROTOCOL))
678
1350
  return mlr
679
1351
 
680
- def regressor_model_predict(input_data_dict: dict, logger: logging.Logger,
681
- debug: Optional[bool] = False, mlr: Optional[MLRegressor] = None
682
- ) -> np.ndarray:
1352
+
1353
+ async def regressor_model_predict(
1354
+ input_data_dict: dict,
1355
+ logger: logging.Logger,
1356
+ debug: bool | None = False,
1357
+ mlr: MLRegressor | None = None,
1358
+ ) -> np.ndarray:
683
1359
  """Perform a prediction from csv file.
684
1360
 
685
1361
  :param input_data_dict: A dictionnary with multiple data used by the action functions
@@ -698,8 +1374,9 @@ def regressor_model_predict(input_data_dict: dict, logger: logging.Logger,
698
1374
  filename_path = input_data_dict["emhass_conf"]["data_path"] / filename
699
1375
  if not debug:
700
1376
  if filename_path.is_file():
701
- with open(filename_path, "rb") as inp:
702
- mlr = pickle.load(inp)
1377
+ async with aiofiles.open(filename_path, "rb") as inp:
1378
+ content = await inp.read()
1379
+ mlr = pickle.loads(content)
703
1380
  else:
704
1381
  logger.error(
705
1382
  "The ML forecaster file was not found, please run a model fit method before this predict method",
@@ -711,328 +1388,584 @@ def regressor_model_predict(input_data_dict: dict, logger: logging.Logger,
711
1388
  logger.error("parameter: 'new_values' not passed")
712
1389
  return False
713
1390
  # Predict from csv file
714
- prediction = mlr.predict(new_values)
1391
+ prediction = await mlr.predict(new_values)
715
1392
  mlr_predict_entity_id = input_data_dict["params"]["passed_data"].get(
716
- "mlr_predict_entity_id", "sensor.mlr_predict")
1393
+ "mlr_predict_entity_id", "sensor.mlr_predict"
1394
+ )
1395
+ mlr_predict_device_class = input_data_dict["params"]["passed_data"].get(
1396
+ "mlr_predict_device_class", "power"
1397
+ )
717
1398
  mlr_predict_unit_of_measurement = input_data_dict["params"]["passed_data"].get(
718
- "mlr_predict_unit_of_measurement", "h")
1399
+ "mlr_predict_unit_of_measurement", "W"
1400
+ )
719
1401
  mlr_predict_friendly_name = input_data_dict["params"]["passed_data"].get(
720
- "mlr_predict_friendly_name", "mlr predictor")
1402
+ "mlr_predict_friendly_name", "mlr predictor"
1403
+ )
721
1404
  # Publish prediction
722
1405
  idx = 0
723
1406
  if not debug:
724
- input_data_dict["rh"].post_data(prediction, idx, mlr_predict_entity_id,
725
- mlr_predict_unit_of_measurement, mlr_predict_friendly_name,
726
- type_var="mlregressor")
1407
+ await input_data_dict["rh"].post_data(
1408
+ prediction,
1409
+ idx,
1410
+ mlr_predict_entity_id,
1411
+ mlr_predict_device_class,
1412
+ mlr_predict_unit_of_measurement,
1413
+ mlr_predict_friendly_name,
1414
+ type_var="mlregressor",
1415
+ )
727
1416
  return prediction
728
1417
 
729
- def publish_data(input_data_dict: dict, logger: logging.Logger,
730
- save_data_to_file: Optional[bool] = False,
731
- opt_res_latest: Optional[pd.DataFrame] = None,
732
- entity_save: Optional[bool] = False,
733
- dont_post: Optional[bool] = False) -> pd.DataFrame:
734
- """
735
- Publish the data obtained from the optimization results.
736
1418
 
737
- :param input_data_dict: A dictionnary with multiple data used by the action functions
738
- :type input_data_dict: dict
739
- :param logger: The passed logger object
740
- :type logger: logging object
741
- :param save_data_to_file: If True we will read data from optimization results in dayahead CSV file
742
- :type save_data_to_file: bool, optional
743
- :return: The output data of the optimization readed from a CSV file in the data folder
744
- :rtype: pd.DataFrame
745
- :param entity_save: Save built entities to data_path/entities
746
- :type entity_save: bool, optional
747
- :param dont_post: Do not post to Home Assistant. Works with entity_save
748
- :type dont_post: bool, optional
1419
+ async def export_influxdb_to_csv(
1420
+ input_data_dict: dict | None,
1421
+ logger: logging.Logger,
1422
+ emhass_conf: dict | None = None,
1423
+ params: str | None = None,
1424
+ runtimeparams: str | None = None,
1425
+ ) -> bool:
1426
+ """Export data from InfluxDB to CSV file.
749
1427
 
1428
+ This function can be called in two ways:
1429
+ 1. With input_data_dict (from web_server via set_input_data_dict)
1430
+ 2. Without input_data_dict (direct call from command line or web_server before set_input_data_dict)
1431
+
1432
+ :param input_data_dict: Dictionary containing configuration and parameters (optional)
1433
+ :type input_data_dict: dict | None
1434
+ :param logger: Logger object
1435
+ :type logger: logging.Logger
1436
+ :param emhass_conf: Dictionary containing EMHASS configuration paths (used when input_data_dict is None)
1437
+ :type emhass_conf: dict | None
1438
+ :param params: JSON string of params (used when input_data_dict is None)
1439
+ :type params: str | None
1440
+ :param runtimeparams: JSON string of runtime parameters (used when input_data_dict is None)
1441
+ :type runtimeparams: str | None
1442
+ :return: Success status
1443
+ :rtype: bool
750
1444
  """
751
- logger.info("Publishing data to HASS instance")
752
- if not isinstance(input_data_dict["params"],dict):
753
- params = json.loads(input_data_dict["params"])
1445
+ # Handle two calling modes
1446
+ if input_data_dict is None:
1447
+ # Direct mode: parse params and create RetrieveHass
1448
+ if emhass_conf is None or params is None:
1449
+ logger.error("emhass_conf and params are required when input_data_dict is None")
1450
+ return False
1451
+ # Parse params
1452
+ if isinstance(params, str):
1453
+ params = orjson.loads(params)
1454
+ if isinstance(runtimeparams, str):
1455
+ runtimeparams = orjson.loads(runtimeparams)
1456
+ # Get configuration
1457
+ retrieve_hass_conf, optim_conf, plant_conf = utils.get_yaml_parse(params, logger)
1458
+ if isinstance(retrieve_hass_conf, bool):
1459
+ return False
1460
+ # Treat runtime params
1461
+ (
1462
+ params,
1463
+ retrieve_hass_conf,
1464
+ optim_conf,
1465
+ plant_conf,
1466
+ ) = await utils.treat_runtimeparams(
1467
+ orjson.dumps(runtimeparams).decode("utf-8") if runtimeparams else "{}",
1468
+ params,
1469
+ retrieve_hass_conf,
1470
+ optim_conf,
1471
+ plant_conf,
1472
+ "export-influxdb-to-csv",
1473
+ logger,
1474
+ emhass_conf,
1475
+ )
1476
+ # Parse params again if it's a string
1477
+ if isinstance(params, str):
1478
+ params = orjson.loads(params)
1479
+ # Create RetrieveHass object
1480
+ rh = RetrieveHass(
1481
+ retrieve_hass_conf["hass_url"],
1482
+ retrieve_hass_conf["long_lived_token"],
1483
+ retrieve_hass_conf["optimization_time_step"],
1484
+ retrieve_hass_conf["time_zone"],
1485
+ params,
1486
+ emhass_conf,
1487
+ logger,
1488
+ )
1489
+ time_zone = rh.time_zone
1490
+ data_path = emhass_conf["data_path"]
754
1491
  else:
1492
+ # Standard mode: use input_data_dict
755
1493
  params = input_data_dict["params"]
756
- # Check if a day ahead optimization has been performed (read CSV file)
757
- if save_data_to_file:
758
- today = datetime.now(timezone.utc).replace(
759
- hour=0, minute=0, second=0, microsecond=0
1494
+ if isinstance(params, str):
1495
+ params = orjson.loads(params)
1496
+ rh = input_data_dict["rh"]
1497
+ time_zone = rh.time_zone
1498
+ data_path = input_data_dict["emhass_conf"]["data_path"]
1499
+ # Extract parameters from passed_data
1500
+ if "sensor_list" not in params.get("passed_data", {}):
1501
+ logger.error("parameter: 'sensor_list' not passed")
1502
+ return False
1503
+ sensor_list = params["passed_data"]["sensor_list"]
1504
+ if "csv_filename" not in params.get("passed_data", {}):
1505
+ logger.error("parameter: 'csv_filename' not passed")
1506
+ return False
1507
+ csv_filename = params["passed_data"]["csv_filename"]
1508
+ if "start_time" not in params.get("passed_data", {}):
1509
+ logger.error("parameter: 'start_time' not passed")
1510
+ return False
1511
+ start_time = params["passed_data"]["start_time"]
1512
+ # Optional parameters with defaults
1513
+ end_time = params["passed_data"].get("end_time", None)
1514
+ resample_freq = params["passed_data"].get("resample_freq", "1h")
1515
+ timestamp_col = params["passed_data"].get("timestamp_col_name", "timestamp")
1516
+ decimal_places = params["passed_data"].get("decimal_places", 2)
1517
+ handle_nan = params["passed_data"].get("handle_nan", "keep")
1518
+ # Check if InfluxDB is enabled
1519
+ if not rh.use_influxdb:
1520
+ logger.error(
1521
+ "InfluxDB is not enabled in configuration. Set use_influxdb: true in config.json"
760
1522
  )
1523
+ return False
1524
+ # Parse time range
1525
+ start_dt, end_dt = utils.parse_export_time_range(start_time, end_time, time_zone, logger)
1526
+ if start_dt is False:
1527
+ return False
1528
+ # Create days list for data retrieval
1529
+ days_list = pd.date_range(start=start_dt.date(), end=end_dt.date(), freq="D", tz=time_zone)
1530
+ if len(days_list) == 0:
1531
+ logger.error("No days to retrieve. Check start_time and end_time.")
1532
+ return False
1533
+ logger.info(
1534
+ f"Retrieving {len(sensor_list)} sensors from {start_dt} to {end_dt} ({len(days_list)} days)"
1535
+ )
1536
+ logger.info(f"Sensors: {sensor_list}")
1537
+ # Retrieve data from InfluxDB
1538
+ success = rh.get_data(days_list, sensor_list)
1539
+ if not success or rh.df_final is None or rh.df_final.empty:
1540
+ logger.error("Failed to retrieve data from InfluxDB")
1541
+ return False
1542
+ # Filter and resample data
1543
+ df_export = utils.resample_and_filter_data(rh.df_final, start_dt, end_dt, resample_freq, logger)
1544
+ if df_export is False:
1545
+ return False
1546
+ # Reset index to make timestamp a column
1547
+ # Handle custom index names by renaming the index first
1548
+ df_export = df_export.rename_axis(timestamp_col).reset_index()
1549
+ # Clean column names
1550
+ df_export = utils.clean_sensor_column_names(df_export, timestamp_col)
1551
+ # Handle NaN values
1552
+ df_export = utils.handle_nan_values(df_export, handle_nan, timestamp_col, logger)
1553
+ # Round numeric columns to specified decimal places
1554
+ numeric_cols = df_export.select_dtypes(include=[np.number]).columns
1555
+ df_export[numeric_cols] = df_export[numeric_cols].round(decimal_places)
1556
+ # Save to CSV
1557
+ csv_path = pathlib.Path(data_path) / csv_filename
1558
+ df_export.to_csv(csv_path, index=False)
1559
+ logger.info(f"✓ Successfully exported to {csv_filename}")
1560
+ logger.info(f" Rows: {df_export.shape[0]}")
1561
+ logger.info(f" Columns: {list(df_export.columns)}")
1562
+ logger.info(
1563
+ f" Time range: {df_export[timestamp_col].min()} to {df_export[timestamp_col].max()}"
1564
+ )
1565
+ logger.info(f" File location: {csv_path}")
1566
+ return True
1567
+
1568
+
1569
+ def _get_params(input_data_dict: dict) -> dict:
1570
+ """Helper to extract params from input_data_dict."""
1571
+ if input_data_dict:
1572
+ if not isinstance(input_data_dict.get("params", {}), dict):
1573
+ return orjson.loads(input_data_dict["params"])
1574
+ return input_data_dict.get("params", {})
1575
+ return {}
1576
+
1577
+
1578
+ async def _publish_from_saved_entities(
1579
+ input_data_dict: dict, logger: logging.Logger, params: dict
1580
+ ) -> pd.DataFrame | None:
1581
+ """
1582
+ Helper to publish data from saved entity JSON files if publish_prefix is set.
1583
+ Returns DataFrame if successful, None if fallback to CSV is needed.
1584
+ """
1585
+ publish_prefix = params["passed_data"].get("publish_prefix", "")
1586
+ entity_path = input_data_dict["emhass_conf"]["data_path"] / "entities"
1587
+ if not entity_path.exists() or not os.listdir(entity_path):
1588
+ logger.warning(f"No saved entity json files in path: {entity_path}")
1589
+ logger.warning("Falling back to opt_res_latest")
1590
+ return None
1591
+ entity_path_contents = os.listdir(entity_path)
1592
+ matches_prefix = any(publish_prefix in entity for entity in entity_path_contents)
1593
+ if not (matches_prefix or publish_prefix == "all"):
1594
+ logger.warning(f"No saved entity json files that match prefix: {publish_prefix}")
1595
+ logger.warning("Falling back to opt_res_latest")
1596
+ return None
1597
+ opt_res_list = []
1598
+ opt_res_list_names = []
1599
+ for entity in entity_path_contents:
1600
+ if entity == default_metadata_json:
1601
+ continue
1602
+ if publish_prefix == "all" or publish_prefix in entity:
1603
+ entity_data = await publish_json(entity, input_data_dict, entity_path, logger)
1604
+ if isinstance(entity_data, bool):
1605
+ return None # Error occurred
1606
+ opt_res_list.append(entity_data)
1607
+ opt_res_list_names.append(entity.replace(".json", ""))
1608
+ opt_res = pd.concat(opt_res_list, axis=1)
1609
+ opt_res.columns = opt_res_list_names
1610
+ return opt_res
1611
+
1612
+
1613
+ def _load_opt_res_latest(
1614
+ input_data_dict: dict, logger: logging.Logger, save_data_to_file: bool
1615
+ ) -> pd.DataFrame | None:
1616
+ """Helper to load the optimization results DataFrame from CSV."""
1617
+ if save_data_to_file:
1618
+ today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
761
1619
  filename = "opt_res_dayahead_" + today.strftime("%Y_%m_%d") + ".csv"
762
- # If publish_prefix is passed, check if there is saved entities in data_path/entities with prefix, publish to results
763
- elif params["passed_data"].get("publish_prefix","") != "" and not dont_post:
764
- opt_res_list = []
765
- opt_res_list_names = []
766
- publish_prefix = params["passed_data"]["publish_prefix"]
767
- entity_path = input_data_dict['emhass_conf']['data_path'] / "entities"
768
- # Check if items in entity_path
769
- if os.path.exists(entity_path) and len(os.listdir(entity_path)) > 0:
770
- # Obtain all files in entity_path
771
- entity_path_contents = os.listdir(entity_path)
772
- for entity in entity_path_contents:
773
- if entity != "metadata.json":
774
- # If publish_prefix is "all" publish all saved entities to Home Assistant
775
- # If publish_prefix matches the prefix from saved entities, publish to Home Assistant
776
- if publish_prefix in entity or publish_prefix == "all":
777
- entity_data = publish_json(entity,input_data_dict,entity_path,logger)
778
- if not isinstance(entity_data, bool):
779
- opt_res_list.append(entity_data)
780
- opt_res_list_names.append(entity.replace(".json", ""))
781
- else:
782
- return False
783
- # Build a DataFrame with published entities
784
- opt_res = pd.concat(opt_res_list, axis=1)
785
- opt_res.columns = opt_res_list_names
786
- return opt_res
787
- else:
788
- logger.warning("no saved entity json files in path:" + str(entity_path))
789
- logger.warning("falling back to opt_res_latest")
790
- filename = "opt_res_latest.csv"
791
1620
  else:
792
- filename = "opt_res_latest.csv"
793
- if opt_res_latest is None:
794
- if not os.path.isfile(input_data_dict['emhass_conf']['data_path'] / filename):
795
- logger.error(
796
- "File not found error, run an optimization task first.")
797
- return
798
- else:
799
- opt_res_latest = pd.read_csv(
800
- input_data_dict['emhass_conf']['data_path'] / filename, index_col='timestamp')
801
- opt_res_latest.index = pd.to_datetime(opt_res_latest.index)
802
- opt_res_latest.index.freq = input_data_dict["retrieve_hass_conf"]["freq"]
803
- # Estimate the current index
804
- now_precise = datetime.now(
805
- input_data_dict["retrieve_hass_conf"]["time_zone"]
806
- ).replace(second=0, microsecond=0)
807
- if input_data_dict["retrieve_hass_conf"]["method_ts_round"] == "nearest":
808
- idx_closest = opt_res_latest.index.get_indexer([now_precise], method="nearest")[0]
809
- elif input_data_dict["retrieve_hass_conf"]["method_ts_round"] == "first":
810
- idx_closest = opt_res_latest.index.get_indexer(
811
- [now_precise], method="ffill")[0]
812
- elif input_data_dict["retrieve_hass_conf"]["method_ts_round"] == "last":
813
- idx_closest = opt_res_latest.index.get_indexer(
814
- [now_precise], method="bfill")[0]
815
- if idx_closest == -1:
816
- idx_closest = opt_res_latest.index.get_indexer([now_precise], method="nearest")[0]
817
- # Publish the data
818
- publish_prefix = params["passed_data"]["publish_prefix"]
819
- # Publish PV forecast
820
- custom_pv_forecast_id = params["passed_data"]["custom_pv_forecast_id"]
821
- input_data_dict["rh"].post_data(
1621
+ filename = default_csv_filename
1622
+ file_path = input_data_dict["emhass_conf"]["data_path"] / filename
1623
+ if not file_path.exists():
1624
+ logger.error("File not found error, run an optimization task first.")
1625
+ return None
1626
+ opt_res_latest = pd.read_csv(file_path, index_col="timestamp")
1627
+ opt_res_latest.index = pd.to_datetime(opt_res_latest.index)
1628
+ opt_res_latest.index.freq = input_data_dict["retrieve_hass_conf"]["optimization_time_step"]
1629
+ return opt_res_latest
1630
+
1631
+
1632
+ def _get_closest_index(retrieve_hass_conf: dict, index: pd.DatetimeIndex) -> int:
1633
+ """Helper to find the closest index in the DataFrame to the current time."""
1634
+ now_precise = datetime.now(retrieve_hass_conf["time_zone"]).replace(second=0, microsecond=0)
1635
+ method = retrieve_hass_conf["method_ts_round"]
1636
+ if method == "nearest":
1637
+ return index.get_indexer([now_precise], method="nearest")[0]
1638
+ elif method == "first":
1639
+ return index.get_indexer([now_precise], method="ffill")[0]
1640
+ elif method == "last":
1641
+ return index.get_indexer([now_precise], method="bfill")[0]
1642
+ return index.get_indexer([now_precise], method="nearest")[0]
1643
+
1644
+
1645
+ async def _publish_standard_forecasts(
1646
+ ctx: PublishContext, opt_res_latest: pd.DataFrame
1647
+ ) -> list[str]:
1648
+ """Publish PV, Load, Curtailment, and Hybrid Inverter data."""
1649
+ cols = []
1650
+ # PV Forecast
1651
+ custom_pv = ctx.params["passed_data"]["custom_pv_forecast_id"]
1652
+ await ctx.rh.post_data(
822
1653
  opt_res_latest["P_PV"],
823
- idx_closest,
824
- custom_pv_forecast_id["entity_id"],
825
- custom_pv_forecast_id["unit_of_measurement"],
826
- custom_pv_forecast_id["friendly_name"],
1654
+ ctx.idx,
1655
+ custom_pv["entity_id"],
1656
+ "power",
1657
+ custom_pv["unit_of_measurement"],
1658
+ custom_pv["friendly_name"],
827
1659
  type_var="power",
828
- publish_prefix=publish_prefix,
829
- save_entities=entity_save,
830
- dont_post=dont_post
1660
+ **ctx.common_kwargs,
831
1661
  )
832
- # Publish Load forecast
833
- custom_load_forecast_id = params["passed_data"]["custom_load_forecast_id"]
834
- input_data_dict["rh"].post_data(
1662
+ cols.append("P_PV")
1663
+ # Load Forecast
1664
+ custom_load = ctx.params["passed_data"]["custom_load_forecast_id"]
1665
+ await ctx.rh.post_data(
835
1666
  opt_res_latest["P_Load"],
836
- idx_closest,
837
- custom_load_forecast_id["entity_id"],
838
- custom_load_forecast_id["unit_of_measurement"],
839
- custom_load_forecast_id["friendly_name"],
1667
+ ctx.idx,
1668
+ custom_load["entity_id"],
1669
+ "power",
1670
+ custom_load["unit_of_measurement"],
1671
+ custom_load["friendly_name"],
840
1672
  type_var="power",
841
- publish_prefix=publish_prefix,
842
- save_entities=entity_save,
843
- dont_post=dont_post
844
- )
845
- cols_published = ["P_PV", "P_Load"]
846
- # Publish PV curtailment
847
- if input_data_dict["fcst"].plant_conf['compute_curtailment']:
848
- custom_pv_curtailment_id = params["passed_data"]["custom_pv_curtailment_id"]
849
- input_data_dict["rh"].post_data(
1673
+ **ctx.common_kwargs,
1674
+ )
1675
+ cols.append("P_Load")
1676
+ # Curtailment
1677
+ if ctx.fcst.plant_conf["compute_curtailment"]:
1678
+ custom_curt = ctx.params["passed_data"]["custom_pv_curtailment_id"]
1679
+ await ctx.rh.post_data(
850
1680
  opt_res_latest["P_PV_curtailment"],
851
- idx_closest,
852
- custom_pv_curtailment_id["entity_id"],
853
- custom_pv_curtailment_id["unit_of_measurement"],
854
- custom_pv_curtailment_id["friendly_name"],
1681
+ ctx.idx,
1682
+ custom_curt["entity_id"],
1683
+ "power",
1684
+ custom_curt["unit_of_measurement"],
1685
+ custom_curt["friendly_name"],
855
1686
  type_var="power",
856
- publish_prefix=publish_prefix,
857
- save_entities=entity_save,
858
- dont_post=dont_post
1687
+ **ctx.common_kwargs,
859
1688
  )
860
- cols_published = cols_published + ["P_PV_curtailment"]
861
- # Publish P_hybrid_inverter
862
- if input_data_dict["fcst"].plant_conf['inverter_is_hybrid']:
863
- custom_hybrid_inverter_id = params["passed_data"]["custom_hybrid_inverter_id"]
864
- input_data_dict["rh"].post_data(
1689
+ cols.append("P_PV_curtailment")
1690
+ # Hybrid Inverter
1691
+ if ctx.fcst.plant_conf["inverter_is_hybrid"]:
1692
+ custom_inv = ctx.params["passed_data"]["custom_hybrid_inverter_id"]
1693
+ await ctx.rh.post_data(
865
1694
  opt_res_latest["P_hybrid_inverter"],
866
- idx_closest,
867
- custom_hybrid_inverter_id["entity_id"],
868
- custom_hybrid_inverter_id["unit_of_measurement"],
869
- custom_hybrid_inverter_id["friendly_name"],
1695
+ ctx.idx,
1696
+ custom_inv["entity_id"],
1697
+ "power",
1698
+ custom_inv["unit_of_measurement"],
1699
+ custom_inv["friendly_name"],
870
1700
  type_var="power",
871
- publish_prefix=publish_prefix,
872
- save_entities=entity_save,
873
- dont_post=dont_post
1701
+ **ctx.common_kwargs,
874
1702
  )
875
- cols_published = cols_published + ["P_hybrid_inverter"]
876
- # Publish deferrable loads
877
- custom_deferrable_forecast_id = params["passed_data"][
878
- "custom_deferrable_forecast_id"
879
- ]
880
- for k in range(input_data_dict["opt"].optim_conf["num_def_loads"]):
881
- if "P_deferrable{}".format(k) not in opt_res_latest.columns:
882
- logger.error(
883
- "P_deferrable{}".format(k)
884
- + " was not found in results DataFrame. Optimization task may need to be relaunched or it did not converge to a solution.",
885
- )
886
- else:
887
- input_data_dict["rh"].post_data(
888
- opt_res_latest["P_deferrable{}".format(k)],
889
- idx_closest,
890
- custom_deferrable_forecast_id[k]["entity_id"],
891
- custom_deferrable_forecast_id[k]["unit_of_measurement"],
892
- custom_deferrable_forecast_id[k]["friendly_name"],
893
- type_var="deferrable",
894
- publish_prefix=publish_prefix,
895
- save_entities=entity_save,
896
- dont_post=dont_post
897
- )
898
- cols_published = cols_published + ["P_deferrable{}".format(k)]
899
- # Publish thermal model data (predicted temperature)
900
- custom_predicted_temperature_id = params["passed_data"][
901
- "custom_predicted_temperature_id"
902
- ]
903
- for k in range(input_data_dict["opt"].optim_conf["num_def_loads"]):
904
- if "def_load_config" in input_data_dict["opt"].optim_conf.keys():
905
- if "thermal_config" in input_data_dict["opt"].optim_conf["def_load_config"][k]:
906
- input_data_dict["rh"].post_data(
907
- opt_res_latest["predicted_temp_heater{}".format(k)],
908
- idx_closest,
909
- custom_predicted_temperature_id[k]["entity_id"],
910
- custom_predicted_temperature_id[k]["unit_of_measurement"],
911
- custom_predicted_temperature_id[k]["friendly_name"],
912
- type_var="temperature",
913
- publish_prefix=publish_prefix,
914
- save_entities=entity_save,
915
- dont_post=dont_post
916
- )
917
- cols_published = cols_published + ["predicted_temp_heater{}".format(k)]
918
- # Publish battery power
919
- if input_data_dict["opt"].optim_conf["set_use_battery"]:
920
- if "P_batt" not in opt_res_latest.columns:
921
- logger.error(
922
- "P_batt was not found in results DataFrame. Optimization task may need to be relaunched or it did not converge to a solution.",
923
- )
924
- else:
925
- custom_batt_forecast_id = params["passed_data"]["custom_batt_forecast_id"]
926
- input_data_dict["rh"].post_data(
927
- opt_res_latest["P_batt"],
928
- idx_closest,
929
- custom_batt_forecast_id["entity_id"],
930
- custom_batt_forecast_id["unit_of_measurement"],
931
- custom_batt_forecast_id["friendly_name"],
932
- type_var="batt",
933
- publish_prefix=publish_prefix,
934
- save_entities=entity_save,
935
- dont_post=dont_post
936
- )
937
- cols_published = cols_published + ["P_batt"]
938
- custom_batt_soc_forecast_id = params["passed_data"][
939
- "custom_batt_soc_forecast_id"
940
- ]
941
- input_data_dict["rh"].post_data(
942
- opt_res_latest["SOC_opt"] * 100,
943
- idx_closest,
944
- custom_batt_soc_forecast_id["entity_id"],
945
- custom_batt_soc_forecast_id["unit_of_measurement"],
946
- custom_batt_soc_forecast_id["friendly_name"],
947
- type_var="SOC",
948
- publish_prefix=publish_prefix,
949
- save_entities=entity_save,
950
- dont_post=dont_post
1703
+ cols.append("P_hybrid_inverter")
1704
+ return cols
1705
+
1706
+
1707
+ async def _publish_deferrable_loads(ctx: PublishContext, opt_res_latest: pd.DataFrame) -> list[str]:
1708
+ """Publish data for all deferrable loads."""
1709
+ cols = []
1710
+ custom_def = ctx.params["passed_data"]["custom_deferrable_forecast_id"]
1711
+ for k in range(ctx.opt.optim_conf["number_of_deferrable_loads"]):
1712
+ col_name = f"P_deferrable{k}"
1713
+ if col_name not in opt_res_latest.columns:
1714
+ ctx.logger.error(f"{col_name} was not found in results DataFrame.")
1715
+ continue
1716
+ await ctx.rh.post_data(
1717
+ opt_res_latest[col_name],
1718
+ ctx.idx,
1719
+ custom_def[k]["entity_id"],
1720
+ "power",
1721
+ custom_def[k]["unit_of_measurement"],
1722
+ custom_def[k]["friendly_name"],
1723
+ type_var="deferrable",
1724
+ **ctx.common_kwargs,
1725
+ )
1726
+ cols.append(col_name)
1727
+ return cols
1728
+
1729
+
1730
+ async def _publish_thermal_variable(
1731
+ rh, opt_res_latest, idx, k, custom_ids, col_prefix, type_var, unit_type, kwargs
1732
+ ) -> str | None:
1733
+ """Helper to publish a single thermal variable if valid."""
1734
+ if custom_ids and k < len(custom_ids):
1735
+ col_name = f"{col_prefix}{k}"
1736
+ if col_name in opt_res_latest.columns:
1737
+ entity_conf = custom_ids[k]
1738
+ await rh.post_data(
1739
+ opt_res_latest[col_name],
1740
+ idx,
1741
+ entity_conf["entity_id"],
1742
+ unit_type,
1743
+ entity_conf["unit_of_measurement"],
1744
+ entity_conf["friendly_name"],
1745
+ type_var=type_var,
1746
+ **kwargs,
951
1747
  )
952
- cols_published = cols_published + ["SOC_opt"]
953
- # Publish grid power
954
- custom_grid_forecast_id = params["passed_data"]["custom_grid_forecast_id"]
955
- input_data_dict["rh"].post_data(
1748
+ return col_name
1749
+ return None
1750
+
1751
+
1752
+ async def _publish_thermal_loads(ctx: PublishContext, opt_res_latest: pd.DataFrame) -> list[str]:
1753
+ """Publish predicted temperature and heating demand for thermal loads."""
1754
+ cols = []
1755
+ if "custom_predicted_temperature_id" not in ctx.params["passed_data"]:
1756
+ return cols
1757
+ custom_temp = ctx.params["passed_data"]["custom_predicted_temperature_id"]
1758
+ custom_heat = ctx.params["passed_data"].get("custom_heating_demand_id")
1759
+ def_load_config = ctx.opt.optim_conf.get("def_load_config", [])
1760
+ if not isinstance(def_load_config, list):
1761
+ def_load_config = []
1762
+ for k in range(ctx.opt.optim_conf["number_of_deferrable_loads"]):
1763
+ if k >= len(def_load_config):
1764
+ continue
1765
+ load_cfg = def_load_config[k]
1766
+ if "thermal_config" not in load_cfg and "thermal_battery" not in load_cfg:
1767
+ continue
1768
+ col_t = await _publish_thermal_variable(
1769
+ ctx.rh,
1770
+ opt_res_latest,
1771
+ ctx.idx,
1772
+ k,
1773
+ custom_temp,
1774
+ "predicted_temp_heater",
1775
+ "temperature",
1776
+ "temperature",
1777
+ ctx.common_kwargs,
1778
+ )
1779
+ if col_t:
1780
+ cols.append(col_t)
1781
+ col_h = await _publish_thermal_variable(
1782
+ ctx.rh,
1783
+ opt_res_latest,
1784
+ ctx.idx,
1785
+ k,
1786
+ custom_heat,
1787
+ "heating_demand_heater",
1788
+ "energy",
1789
+ "energy",
1790
+ ctx.common_kwargs,
1791
+ )
1792
+ if col_h:
1793
+ cols.append(col_h)
1794
+ return cols
1795
+
1796
+
1797
+ async def _publish_battery_data(ctx: PublishContext, opt_res_latest: pd.DataFrame) -> list[str]:
1798
+ """Publish Battery Power and SOC."""
1799
+ cols = []
1800
+ if not ctx.opt.optim_conf["set_use_battery"]:
1801
+ return cols
1802
+ if "P_batt" not in opt_res_latest.columns:
1803
+ ctx.logger.error("P_batt was not found in results DataFrame.")
1804
+ return cols
1805
+ # Power
1806
+ custom_batt = ctx.params["passed_data"]["custom_batt_forecast_id"]
1807
+ await ctx.rh.post_data(
1808
+ opt_res_latest["P_batt"],
1809
+ ctx.idx,
1810
+ custom_batt["entity_id"],
1811
+ "power",
1812
+ custom_batt["unit_of_measurement"],
1813
+ custom_batt["friendly_name"],
1814
+ type_var="batt",
1815
+ **ctx.common_kwargs,
1816
+ )
1817
+ cols.append("P_batt")
1818
+ # SOC
1819
+ custom_soc = ctx.params["passed_data"]["custom_batt_soc_forecast_id"]
1820
+ await ctx.rh.post_data(
1821
+ opt_res_latest["SOC_opt"] * 100,
1822
+ ctx.idx,
1823
+ custom_soc["entity_id"],
1824
+ "battery",
1825
+ custom_soc["unit_of_measurement"],
1826
+ custom_soc["friendly_name"],
1827
+ type_var="SOC",
1828
+ **ctx.common_kwargs,
1829
+ )
1830
+ cols.append("SOC_opt")
1831
+ return cols
1832
+
1833
+
1834
+ async def _publish_grid_and_costs(ctx: PublishContext, opt_res_latest: pd.DataFrame) -> list[str]:
1835
+ """Publish Grid Power, Costs, and Optimization Status."""
1836
+ cols = []
1837
+ # Grid
1838
+ custom_grid = ctx.params["passed_data"]["custom_grid_forecast_id"]
1839
+ await ctx.rh.post_data(
956
1840
  opt_res_latest["P_grid"],
957
- idx_closest,
958
- custom_grid_forecast_id["entity_id"],
959
- custom_grid_forecast_id["unit_of_measurement"],
960
- custom_grid_forecast_id["friendly_name"],
1841
+ ctx.idx,
1842
+ custom_grid["entity_id"],
1843
+ "power",
1844
+ custom_grid["unit_of_measurement"],
1845
+ custom_grid["friendly_name"],
961
1846
  type_var="power",
962
- publish_prefix=publish_prefix,
963
- save_entities=entity_save,
964
- dont_post=dont_post
1847
+ **ctx.common_kwargs,
965
1848
  )
966
- cols_published = cols_published + ["P_grid"]
967
- # Publish total value of cost function
968
- custom_cost_fun_id = params["passed_data"]["custom_cost_fun_id"]
1849
+ cols.append("P_grid")
1850
+ # Cost Function
1851
+ custom_cost = ctx.params["passed_data"]["custom_cost_fun_id"]
969
1852
  col_cost_fun = [i for i in opt_res_latest.columns if "cost_fun_" in i]
970
- input_data_dict["rh"].post_data(
1853
+ await ctx.rh.post_data(
971
1854
  opt_res_latest[col_cost_fun],
972
- idx_closest,
973
- custom_cost_fun_id["entity_id"],
974
- custom_cost_fun_id["unit_of_measurement"],
975
- custom_cost_fun_id["friendly_name"],
1855
+ ctx.idx,
1856
+ custom_cost["entity_id"],
1857
+ "monetary",
1858
+ custom_cost["unit_of_measurement"],
1859
+ custom_cost["friendly_name"],
976
1860
  type_var="cost_fun",
977
- publish_prefix=publish_prefix,
978
- save_entities=entity_save,
979
- dont_post=dont_post
1861
+ **ctx.common_kwargs,
980
1862
  )
981
- # cols_published = cols_published + col_cost_fun
982
- # Publish the optimization status
983
- custom_cost_fun_id = params["passed_data"]["custom_optim_status_id"]
1863
+ # Optim Status
1864
+ custom_status = ctx.params["passed_data"]["custom_optim_status_id"]
984
1865
  if "optim_status" not in opt_res_latest:
985
1866
  opt_res_latest["optim_status"] = "Optimal"
986
- logger.warning(
987
- "no optim_status in opt_res_latest, run an optimization task first",
988
- )
989
- else:
990
- input_data_dict["rh"].post_data(
991
- opt_res_latest["optim_status"],
992
- idx_closest,
993
- custom_cost_fun_id["entity_id"],
994
- custom_cost_fun_id["unit_of_measurement"],
995
- custom_cost_fun_id["friendly_name"],
996
- type_var="optim_status",
997
- publish_prefix=publish_prefix,
998
- save_entities=entity_save,
999
- dont_post=dont_post
1867
+ ctx.logger.warning("no optim_status in opt_res_latest")
1868
+ status_val = opt_res_latest["optim_status"]
1869
+ await ctx.rh.post_data(
1870
+ status_val,
1871
+ ctx.idx,
1872
+ custom_status["entity_id"],
1873
+ "",
1874
+ "",
1875
+ custom_status["friendly_name"],
1876
+ type_var="optim_status",
1877
+ **ctx.common_kwargs,
1878
+ )
1879
+ cols.append("optim_status")
1880
+ # Unit Costs
1881
+ for key, var_name in [
1882
+ ("custom_unit_load_cost_id", "unit_load_cost"),
1883
+ ("custom_unit_prod_price_id", "unit_prod_price"),
1884
+ ]:
1885
+ custom_id = ctx.params["passed_data"][key]
1886
+ await ctx.rh.post_data(
1887
+ opt_res_latest[var_name],
1888
+ ctx.idx,
1889
+ custom_id["entity_id"],
1890
+ "monetary",
1891
+ custom_id["unit_of_measurement"],
1892
+ custom_id["friendly_name"],
1893
+ type_var=var_name,
1894
+ **ctx.common_kwargs,
1000
1895
  )
1001
- cols_published = cols_published + ["optim_status"]
1002
- # Publish unit_load_cost
1003
- custom_unit_load_cost_id = params["passed_data"]["custom_unit_load_cost_id"]
1004
- input_data_dict["rh"].post_data(
1005
- opt_res_latest["unit_load_cost"],
1006
- idx_closest,
1007
- custom_unit_load_cost_id["entity_id"],
1008
- custom_unit_load_cost_id["unit_of_measurement"],
1009
- custom_unit_load_cost_id["friendly_name"],
1010
- type_var="unit_load_cost",
1011
- publish_prefix=publish_prefix,
1012
- save_entities=entity_save,
1013
- dont_post=dont_post
1014
- )
1015
- cols_published = cols_published + ["unit_load_cost"]
1016
- # Publish unit_prod_price
1017
- custom_unit_prod_price_id = params["passed_data"]["custom_unit_prod_price_id"]
1018
- input_data_dict["rh"].post_data(
1019
- opt_res_latest["unit_prod_price"],
1020
- idx_closest,
1021
- custom_unit_prod_price_id["entity_id"],
1022
- custom_unit_prod_price_id["unit_of_measurement"],
1023
- custom_unit_prod_price_id["friendly_name"],
1024
- type_var="unit_prod_price",
1025
- publish_prefix=publish_prefix,
1026
- save_entities=entity_save,
1027
- dont_post=dont_post
1028
- )
1029
- cols_published = cols_published + ["unit_prod_price"]
1030
- # Create a DF resuming what has been published
1031
- opt_res = opt_res_latest[cols_published].loc[[
1032
- opt_res_latest.index[idx_closest]]]
1896
+ cols.append(var_name)
1897
+ return cols
1898
+
1899
+
1900
+ async def publish_data(
1901
+ input_data_dict: dict,
1902
+ logger: logging.Logger,
1903
+ save_data_to_file: bool | None = False,
1904
+ opt_res_latest: pd.DataFrame | None = None,
1905
+ entity_save: bool | None = False,
1906
+ dont_post: bool | None = False,
1907
+ ) -> pd.DataFrame:
1908
+ """
1909
+ Publish the data obtained from the optimization results.
1910
+
1911
+ :param input_data_dict: A dictionnary with multiple data used by the action functions
1912
+ :type input_data_dict: dict
1913
+ :param logger: The passed logger object
1914
+ :type logger: logging object
1915
+ :param save_data_to_file: If True we will read data from optimization results in dayahead CSV file
1916
+ :type save_data_to_file: bool, optional
1917
+ :return: The output data of the optimization readed from a CSV file in the data folder
1918
+ :rtype: pd.DataFrame
1919
+ :param entity_save: Save built entities to data_path/entities
1920
+ :type entity_save: bool, optional
1921
+ :param dont_post: Do not post to Home Assistant. Works with entity_save
1922
+ :type dont_post: bool, optional
1923
+
1924
+ """
1925
+ logger.info("Publishing data to HASS instance")
1926
+ # Parse Parameters
1927
+ params = _get_params(input_data_dict)
1928
+ # Check for Entity Publishing (Prefix mode)
1929
+ publish_prefix = params["passed_data"].get("publish_prefix", "")
1930
+ if not save_data_to_file and publish_prefix != "" and not dont_post:
1931
+ opt_res = await _publish_from_saved_entities(input_data_dict, logger, params)
1932
+ if opt_res is not None:
1933
+ return opt_res
1934
+ # Load Optimization Results (if not passed)
1935
+ if opt_res_latest is None:
1936
+ opt_res_latest = _load_opt_res_latest(input_data_dict, logger, save_data_to_file)
1937
+ if opt_res_latest is None:
1938
+ return None
1939
+ # Determine Closest Index
1940
+ idx_closest = _get_closest_index(input_data_dict["retrieve_hass_conf"], opt_res_latest.index)
1941
+ # Create Context
1942
+ common_kwargs = {
1943
+ "publish_prefix": publish_prefix,
1944
+ "save_entities": entity_save,
1945
+ "dont_post": dont_post,
1946
+ }
1947
+ ctx = PublishContext(
1948
+ input_data_dict=input_data_dict,
1949
+ params=params,
1950
+ idx=idx_closest,
1951
+ common_kwargs=common_kwargs,
1952
+ logger=logger,
1953
+ )
1954
+ # Publish Data Components
1955
+ cols_published = []
1956
+ cols_published.extend(await _publish_standard_forecasts(ctx, opt_res_latest))
1957
+ cols_published.extend(await _publish_deferrable_loads(ctx, opt_res_latest))
1958
+ cols_published.extend(await _publish_thermal_loads(ctx, opt_res_latest))
1959
+ cols_published.extend(await _publish_battery_data(ctx, opt_res_latest))
1960
+ cols_published.extend(await _publish_grid_and_costs(ctx, opt_res_latest))
1961
+ # Return Summary DataFrame
1962
+ opt_res = opt_res_latest[cols_published].loc[[opt_res_latest.index[idx_closest]]]
1033
1963
  return opt_res
1034
1964
 
1035
- def continual_publish(input_data_dict: dict, entity_path: pathlib.Path, logger: logging.Logger):
1965
+
1966
+ async def continual_publish(
1967
+ input_data_dict: dict, entity_path: pathlib.Path, logger: logging.Logger
1968
+ ):
1036
1969
  """
1037
1970
  If continual_publish is true and a entity file saved in /data_path/entities, continually publish sensor on freq rate, updating entity current state value based on timestamp
1038
1971
 
@@ -1042,27 +1975,63 @@ def continual_publish(input_data_dict: dict, entity_path: pathlib.Path, logger:
1042
1975
  :type entity_path: Path
1043
1976
  :param logger: The passed logger object
1044
1977
  :type logger: logging.Logger
1045
-
1046
1978
  """
1047
1979
  logger.info("Continual publish thread service started")
1048
- freq = input_data_dict['retrieve_hass_conf'].get("freq", pd.to_timedelta(1, "minutes"))
1049
- entity_path_contents = []
1980
+ freq = input_data_dict["retrieve_hass_conf"].get(
1981
+ "optimization_time_step", pd.to_timedelta(1, "minutes")
1982
+ )
1050
1983
  while True:
1051
1984
  # Sleep for x seconds (using current time as a reference for time left)
1052
- time.sleep(max(0,freq.total_seconds() - (datetime.now(input_data_dict["retrieve_hass_conf"]["time_zone"]).timestamp() % 60)))
1053
- # Loop through all saved entity files
1054
- if os.path.exists(entity_path) and len(os.listdir(entity_path)) > 0:
1055
- entity_path_contents = os.listdir(entity_path)
1056
- for entity in entity_path_contents:
1057
- if entity != "metadata.json":
1058
- # Call publish_json with entity file, build entity, and publish
1059
- publish_json(entity, input_data_dict, entity_path, logger, "continual_publish")
1060
- pass
1061
- # This function should never return
1062
- return False
1063
-
1064
- def publish_json(entity: dict, input_data_dict: dict, entity_path: pathlib.Path,
1065
- logger: logging.Logger, reference: Optional[str] = ""):
1985
+ time_zone = input_data_dict["retrieve_hass_conf"]["time_zone"]
1986
+ timestamp_diff = freq.total_seconds() - (datetime.now(time_zone).timestamp() % 60)
1987
+ sleep_seconds = max(0.0, min(timestamp_diff, 60.0))
1988
+ await asyncio.sleep(sleep_seconds)
1989
+ # Delegate processing to helper function to reduce complexity
1990
+ freq = await _publish_and_update_freq(input_data_dict, entity_path, logger, freq)
1991
+ return False
1992
+
1993
+
1994
+ async def _publish_and_update_freq(input_data_dict, entity_path, logger, current_freq):
1995
+ """
1996
+ Helper to process entity publishing and frequency updates.
1997
+ Returns the (potentially updated) frequency.
1998
+ """
1999
+ # Guard clause: if path doesn't exist, do nothing and return current freq
2000
+ if not os.path.exists(entity_path):
2001
+ return current_freq
2002
+ entity_path_contents = os.listdir(entity_path)
2003
+ # Guard clause: if directory is empty, do nothing
2004
+ if not entity_path_contents:
2005
+ return current_freq
2006
+ # Loop through all saved entity files
2007
+ for entity in entity_path_contents:
2008
+ if entity != default_metadata_json:
2009
+ await publish_json(
2010
+ entity,
2011
+ input_data_dict,
2012
+ entity_path,
2013
+ logger,
2014
+ "continual_publish",
2015
+ )
2016
+ # Retrieve entity metadata from file
2017
+ metadata_file = entity_path / default_metadata_json
2018
+ if os.path.isfile(metadata_file):
2019
+ async with aiofiles.open(metadata_file) as file:
2020
+ content = await file.read()
2021
+ metadata = orjson.loads(content)
2022
+ # Check if freq should be shorter
2023
+ if metadata.get("lowest_time_step") is not None:
2024
+ return pd.to_timedelta(metadata["lowest_time_step"], "minutes")
2025
+ return current_freq
2026
+
2027
+
2028
+ async def publish_json(
2029
+ entity: dict,
2030
+ input_data_dict: dict,
2031
+ entity_path: pathlib.Path,
2032
+ logger: logging.Logger,
2033
+ reference: str | None = "",
2034
+ ):
1066
2035
  """
1067
2036
  Extract saved entity data from .json (in data_path/entities), build entity, post results to post_data
1068
2037
 
@@ -1074,30 +2043,35 @@ def publish_json(entity: dict, input_data_dict: dict, entity_path: pathlib.Path,
1074
2043
  :type entity_path: Path
1075
2044
  :param logger: The passed logger object
1076
2045
  :type logger: logging.Logger
1077
- :param reference: String for identifying who ran the function
2046
+ :param reference: String for identifying who ran the function
1078
2047
  :type reference: str, optional
1079
-
2048
+
1080
2049
  """
1081
2050
  # Retrieve entity metadata from file
1082
- if os.path.isfile(entity_path / "metadata.json"):
1083
- with open(entity_path / "metadata.json", "r") as file:
1084
- metadata = json.load(file)
1085
- if not metadata.get("lowest_freq",None) == None:
1086
- freq = pd.to_timedelta(metadata["lowest_freq"], "minutes")
2051
+ if os.path.isfile(entity_path / default_metadata_json):
2052
+ async with aiofiles.open(entity_path / default_metadata_json) as file:
2053
+ content = await file.read()
2054
+ metadata = orjson.loads(content)
1087
2055
  else:
1088
2056
  logger.error("unable to located metadata.json in:" + entity_path)
1089
- return False
2057
+ return False
1090
2058
  # Round current timecode (now)
1091
- now_precise = datetime.now(input_data_dict["retrieve_hass_conf"]["time_zone"]).replace(second=0, microsecond=0)
2059
+ now_precise = datetime.now(input_data_dict["retrieve_hass_conf"]["time_zone"]).replace(
2060
+ second=0, microsecond=0
2061
+ )
1092
2062
  # Retrieve entity data from file
1093
- entity_data = pd.read_json(entity_path / entity , orient='index')
2063
+ entity_data = pd.read_json(entity_path / entity, orient="index")
1094
2064
  # Remove ".json" from string for entity_id
1095
2065
  entity_id = entity.replace(".json", "")
1096
2066
  # Adjust Dataframe from received entity json file
1097
2067
  entity_data.columns = [metadata[entity_id]["name"]]
1098
2068
  entity_data.index.name = "timestamp"
1099
- entity_data.index = pd.to_datetime(entity_data.index).tz_convert(input_data_dict["retrieve_hass_conf"]["time_zone"])
1100
- entity_data.index.freq = pd.to_timedelta(int(metadata[entity_id]["freq"]), "minutes")
2069
+ entity_data.index = pd.to_datetime(entity_data.index).tz_convert(
2070
+ input_data_dict["retrieve_hass_conf"]["time_zone"]
2071
+ )
2072
+ entity_data.index.freq = pd.to_timedelta(
2073
+ int(metadata[entity_id]["optimization_time_step"]), "minutes"
2074
+ )
1101
2075
  # Calculate the current state value
1102
2076
  if input_data_dict["retrieve_hass_conf"]["method_ts_round"] == "nearest":
1103
2077
  idx_closest = entity_data.index.get_indexer([now_precise], method="nearest")[0]
@@ -1107,27 +2081,28 @@ def publish_json(entity: dict, input_data_dict: dict, entity_path: pathlib.Path,
1107
2081
  idx_closest = entity_data.index.get_indexer([now_precise], method="bfill")[0]
1108
2082
  if idx_closest == -1:
1109
2083
  idx_closest = entity_data.index.get_indexer([now_precise], method="nearest")[0]
1110
- # Call post data
2084
+ # Call post data
1111
2085
  if reference == "continual_publish":
1112
2086
  logger.debug("Auto Published sensor:")
1113
2087
  logger_levels = "DEBUG"
1114
- else:
2088
+ else:
1115
2089
  logger_levels = "INFO"
1116
2090
  # post/save entity
1117
- input_data_dict["rh"].post_data(
2091
+ await input_data_dict["rh"].post_data(
1118
2092
  data_df=entity_data[metadata[entity_id]["name"]],
1119
2093
  idx=idx_closest,
1120
2094
  entity_id=entity_id,
2095
+ device_class=dict.get(metadata[entity_id], "device_class"),
1121
2096
  unit_of_measurement=metadata[entity_id]["unit_of_measurement"],
1122
2097
  friendly_name=metadata[entity_id]["friendly_name"],
1123
- type_var=metadata[entity_id].get("type_var",""),
2098
+ type_var=metadata[entity_id].get("type_var", ""),
1124
2099
  save_entities=False,
1125
- logger_levels=logger_levels
2100
+ logger_levels=logger_levels,
1126
2101
  )
1127
2102
  return entity_data[metadata[entity_id]["name"]]
1128
2103
 
1129
2104
 
1130
- def main():
2105
+ async def main():
1131
2106
  r"""Define the main command line entry function.
1132
2107
 
1133
2108
  This function may take several arguments as inputs. You can type `emhass --help` to see the list of options:
@@ -1150,61 +2125,108 @@ def main():
1150
2125
  """
1151
2126
  # Parsing arguments
1152
2127
  parser = argparse.ArgumentParser()
1153
- parser.add_argument('--action', type=str, help='Set the desired action, options are: perfect-optim, dayahead-optim,\
1154
- naive-mpc-optim, publish-data, forecast-model-fit, forecast-model-predict, forecast-model-tune')
1155
- parser.add_argument('--config', type=str,
1156
- help='Define path to the config.yaml file')
1157
- parser.add_argument('--data', type=str,
1158
- help='Define path to the Data files (.csv & .pkl)')
1159
- parser.add_argument('--root', type=str, help='Define path emhass root')
1160
- parser.add_argument('--costfun', type=str, default='profit',
1161
- help='Define the type of cost function, options are: profit, cost, self-consumption')
1162
- parser.add_argument('--log2file', type=strtobool, default='False',
1163
- help='Define if we should log to a file or not')
1164
- parser.add_argument('--params', type=str, default=None,
1165
- help='Configuration parameters passed from data/options.json')
1166
- parser.add_argument('--runtimeparams', type=str, default=None,
1167
- help='Pass runtime optimization parameters as dictionnary')
1168
- parser.add_argument('--debug', type=strtobool,
1169
- default='False', help='Use True for testing purposes')
2128
+ parser.add_argument(
2129
+ "--action",
2130
+ type=str,
2131
+ help="Set the desired action, options are: perfect-optim, dayahead-optim,\
2132
+ naive-mpc-optim, publish-data, forecast-model-fit, forecast-model-predict, forecast-model-tune",
2133
+ )
2134
+ parser.add_argument(
2135
+ "--config", type=str, help="Define path to the config.json/defaults.json file"
2136
+ )
2137
+ parser.add_argument(
2138
+ "--params",
2139
+ type=str,
2140
+ default=None,
2141
+ help="String of configuration parameters passed",
2142
+ )
2143
+ parser.add_argument("--data", type=str, help="Define path to the Data files (.csv & .pkl)")
2144
+ parser.add_argument("--root", type=str, help="Define path emhass root")
2145
+ parser.add_argument(
2146
+ "--costfun",
2147
+ type=str,
2148
+ default="profit",
2149
+ help="Define the type of cost function, options are: profit, cost, self-consumption",
2150
+ )
2151
+ parser.add_argument(
2152
+ "--log2file",
2153
+ type=bool,
2154
+ default=False,
2155
+ help="Define if we should log to a file or not",
2156
+ )
2157
+ parser.add_argument(
2158
+ "--secrets",
2159
+ type=str,
2160
+ default=None,
2161
+ help="Define secret parameter file (secrets_emhass.yaml) path",
2162
+ )
2163
+ parser.add_argument(
2164
+ "--runtimeparams",
2165
+ type=str,
2166
+ default=None,
2167
+ help="Pass runtime optimization parameters as dictionnary",
2168
+ )
2169
+ parser.add_argument(
2170
+ "--debug",
2171
+ type=bool,
2172
+ default=False,
2173
+ help="Use True for testing purposes",
2174
+ )
1170
2175
  args = parser.parse_args()
2176
+
1171
2177
  # The path to the configuration files
1172
2178
  if args.config is not None:
1173
2179
  config_path = pathlib.Path(args.config)
1174
2180
  else:
1175
- config_path = pathlib.Path(
1176
- str(utils.get_root(__file__, num_parent=2) / 'config_emhass.yaml'))
2181
+ config_path = pathlib.Path(str(utils.get_root(__file__, num_parent=3) / "config.json"))
1177
2182
  if args.data is not None:
1178
2183
  data_path = pathlib.Path(args.data)
1179
2184
  else:
1180
- data_path = (config_path.parent / 'data/')
2185
+ data_path = config_path.parent / "data/"
1181
2186
  if args.root is not None:
1182
2187
  root_path = pathlib.Path(args.root)
1183
2188
  else:
1184
- root_path = config_path.parent
2189
+ root_path = utils.get_root(__file__, num_parent=1)
2190
+ if args.secrets is not None:
2191
+ secrets_path = pathlib.Path(args.secrets)
2192
+ else:
2193
+ secrets_path = pathlib.Path(config_path.parent / "secrets_emhass.yaml")
2194
+
2195
+ associations_path = root_path / "data/associations.csv"
2196
+ defaults_path = root_path / "data/config_defaults.json"
2197
+
1185
2198
  emhass_conf = {}
1186
- emhass_conf['config_path'] = config_path
1187
- emhass_conf['data_path'] = data_path
1188
- emhass_conf['root_path'] = root_path
2199
+ emhass_conf["config_path"] = config_path
2200
+ emhass_conf["data_path"] = data_path
2201
+ emhass_conf["root_path"] = root_path
2202
+ emhass_conf["associations_path"] = associations_path
2203
+ emhass_conf["defaults_path"] = defaults_path
1189
2204
  # create logger
1190
- logger, ch = utils.get_logger(
1191
- __name__, emhass_conf, save_to_file=bool(args.log2file))
2205
+ logger, ch = utils.get_logger(__name__, emhass_conf, save_to_file=bool(args.log2file))
2206
+
2207
+ # Check paths
1192
2208
  logger.debug("config path: " + str(config_path))
1193
2209
  logger.debug("data path: " + str(data_path))
1194
2210
  logger.debug("root path: " + str(root_path))
1195
- if not config_path.exists():
1196
- logger.error(
1197
- "Could not find config_emhass.yaml file in: " + str(config_path))
1198
- logger.error("Try setting config file path with --config")
2211
+ if not associations_path.exists():
2212
+ logger.error("Could not find associations.csv file in: " + str(associations_path))
2213
+ logger.error("Try setting config file path with --associations")
1199
2214
  return False
2215
+ if not config_path.exists():
2216
+ logger.warning("Could not find config.json file in: " + str(config_path))
2217
+ logger.warning("Try setting config file path with --config")
2218
+ if not secrets_path.exists():
2219
+ logger.warning("Could not find secrets file in: " + str(secrets_path))
2220
+ logger.warning("Try setting secrets file path with --secrets")
1200
2221
  if not os.path.isdir(data_path):
1201
- logger.error("Could not find data foulder in: " + str(data_path))
2222
+ logger.error("Could not find data folder in: " + str(data_path))
1202
2223
  logger.error("Try setting data path with --data")
1203
2224
  return False
1204
- if not os.path.isdir(root_path / 'src'):
1205
- logger.error("Could not find emhass/src foulder in: " + str(root_path))
2225
+ if not os.path.isdir(root_path):
2226
+ logger.error("Could not find emhass/src folder in: " + str(root_path))
1206
2227
  logger.error("Try setting emhass root path with --root")
1207
2228
  return False
2229
+
1208
2230
  # Additional argument
1209
2231
  try:
1210
2232
  parser.add_argument(
@@ -1217,53 +2239,116 @@ def main():
1217
2239
  logger.info(
1218
2240
  "Version not found for emhass package. Or importlib exited with PackageNotFoundError.",
1219
2241
  )
1220
- # Setup parameters
1221
- input_data_dict = set_input_data_dict(emhass_conf,
1222
- args.costfun, args.params, args.runtimeparams, args.action,
1223
- logger, args.debug)
2242
+
2243
+ # Setup config
2244
+ config = {}
2245
+ # Check if passed config file is yaml of json, build config accordingly
2246
+ if config_path.exists():
2247
+ # Safe: Use pathlib's suffix instead of regex to avoid ReDoS
2248
+ file_extension = config_path.suffix.lstrip(".").lower()
2249
+
2250
+ if file_extension:
2251
+ match file_extension:
2252
+ case "json":
2253
+ config = await utils.build_config(
2254
+ emhass_conf, logger, defaults_path, config_path
2255
+ )
2256
+ case "yaml" | "yml":
2257
+ config = await utils.build_config(
2258
+ emhass_conf, logger, defaults_path, config_path=config_path
2259
+ )
2260
+ case _:
2261
+ logger.warning(
2262
+ f"Unsupported config file format: .{file_extension}, building parameters with only defaults"
2263
+ )
2264
+ config = await utils.build_config(emhass_conf, logger, defaults_path)
2265
+ else:
2266
+ logger.warning("Config file has no extension, building parameters with only defaults")
2267
+ config = await utils.build_config(emhass_conf, logger, defaults_path)
2268
+ else:
2269
+ # If unable to find config file, use only defaults_config.json
2270
+ logger.warning("Unable to obtain config.json file, building parameters with only defaults")
2271
+ config = await utils.build_config(emhass_conf, logger, defaults_path)
2272
+ if type(config) is bool and not config:
2273
+ raise Exception("Failed to find default config")
2274
+
2275
+ # Obtain secrets from secrets_emhass.yaml?
2276
+ params_secrets = {}
2277
+ emhass_conf, built_secrets = await utils.build_secrets(
2278
+ emhass_conf, logger, secrets_path=secrets_path
2279
+ )
2280
+ params_secrets.update(built_secrets)
2281
+
2282
+ # Build params
2283
+ params = await utils.build_params(emhass_conf, params_secrets, config, logger)
2284
+ if type(params) is bool:
2285
+ raise Exception("A error has occurred while building parameters")
2286
+ # Add any passed params from args to params
2287
+ if args.params:
2288
+ params.update(orjson.loads(args.params))
2289
+
2290
+ input_data_dict = await set_input_data_dict(
2291
+ emhass_conf,
2292
+ args.costfun,
2293
+ orjson.dumps(params).decode("utf-8"),
2294
+ args.runtimeparams,
2295
+ args.action,
2296
+ logger,
2297
+ args.debug,
2298
+ )
2299
+ if type(input_data_dict) is bool:
2300
+ raise Exception("A error has occurred while creating action objects")
2301
+
1224
2302
  # Perform selected action
1225
2303
  if args.action == "perfect-optim":
1226
- opt_res = perfect_forecast_optim(
1227
- input_data_dict, logger, debug=args.debug)
2304
+ opt_res = await perfect_forecast_optim(input_data_dict, logger, debug=args.debug)
1228
2305
  elif args.action == "dayahead-optim":
1229
- opt_res = dayahead_forecast_optim(
1230
- input_data_dict, logger, debug=args.debug)
2306
+ opt_res = await dayahead_forecast_optim(input_data_dict, logger, debug=args.debug)
1231
2307
  elif args.action == "naive-mpc-optim":
1232
- opt_res = naive_mpc_optim(input_data_dict, logger, debug=args.debug)
2308
+ opt_res = await naive_mpc_optim(input_data_dict, logger, debug=args.debug)
1233
2309
  elif args.action == "forecast-model-fit":
1234
- df_fit_pred, df_fit_pred_backtest, mlf = forecast_model_fit(
2310
+ df_fit_pred, df_fit_pred_backtest, mlf = await forecast_model_fit(
1235
2311
  input_data_dict, logger, debug=args.debug
1236
2312
  )
1237
2313
  opt_res = None
1238
2314
  elif args.action == "forecast-model-predict":
1239
2315
  if args.debug:
1240
- _, _, mlf = forecast_model_fit(input_data_dict, logger, debug=args.debug)
2316
+ _, _, mlf = await forecast_model_fit(input_data_dict, logger, debug=args.debug)
1241
2317
  else:
1242
2318
  mlf = None
1243
- df_pred = forecast_model_predict(input_data_dict, logger, debug=args.debug, mlf=mlf)
2319
+ df_pred = await forecast_model_predict(input_data_dict, logger, debug=args.debug, mlf=mlf)
1244
2320
  opt_res = None
1245
2321
  elif args.action == "forecast-model-tune":
1246
2322
  if args.debug:
1247
- _, _, mlf = forecast_model_fit(input_data_dict, logger, debug=args.debug)
2323
+ _, _, mlf = await forecast_model_fit(input_data_dict, logger, debug=args.debug)
1248
2324
  else:
1249
2325
  mlf = None
1250
- df_pred_optim, mlf = forecast_model_tune(input_data_dict, logger, debug=args.debug, mlf=mlf)
2326
+ df_pred_optim, mlf = await forecast_model_tune(
2327
+ input_data_dict, logger, debug=args.debug, mlf=mlf
2328
+ )
1251
2329
  opt_res = None
1252
2330
  elif args.action == "regressor-model-fit":
1253
- mlr = regressor_model_fit(input_data_dict, logger, debug=args.debug)
2331
+ mlr = await regressor_model_fit(input_data_dict, logger, debug=args.debug)
1254
2332
  opt_res = None
1255
2333
  elif args.action == "regressor-model-predict":
1256
2334
  if args.debug:
1257
- mlr = regressor_model_fit(input_data_dict, logger, debug=args.debug)
2335
+ mlr = await regressor_model_fit(input_data_dict, logger, debug=args.debug)
1258
2336
  else:
1259
2337
  mlr = None
1260
- prediction = regressor_model_predict(input_data_dict, logger, debug=args.debug,mlr=mlr)
2338
+ prediction = await regressor_model_predict(
2339
+ input_data_dict, logger, debug=args.debug, mlr=mlr
2340
+ )
2341
+ opt_res = None
2342
+ elif args.action == "export-influxdb-to-csv":
2343
+ success = await export_influxdb_to_csv(input_data_dict, logger)
1261
2344
  opt_res = None
1262
2345
  elif args.action == "publish-data":
1263
- opt_res = publish_data(input_data_dict,logger)
2346
+ opt_res = await publish_data(input_data_dict, logger)
1264
2347
  else:
1265
2348
  logger.error("The passed action argument is not valid")
1266
- logger.error("Try setting --action: perfect-optim, dayahead-optim, naive-mpc-optim, forecast-model-fit, forecast-model-predict, forecast-model-tune or publish-data")
2349
+ logger.error(
2350
+ "Try setting --action: perfect-optim, dayahead-optim, naive-mpc-optim, forecast-model-fit, forecast-model-predict, forecast-model-tune, export-influxdb-to-csv or publish-data"
2351
+ )
1267
2352
  opt_res = None
1268
2353
  logger.info(opt_res)
1269
2354
  # Flush the logger
@@ -1284,11 +2369,18 @@ def main():
1284
2369
  return mlr
1285
2370
  elif args.action == "regressor-model-predict":
1286
2371
  return prediction
2372
+ elif args.action == "export-influxdb-to-csv":
2373
+ return success
1287
2374
  elif args.action == "forecast-model-tune":
1288
2375
  return df_pred_optim, mlf
1289
2376
  else:
1290
2377
  return opt_res
1291
2378
 
1292
2379
 
2380
+ def main_sync():
2381
+ """Sync wrapper for async main function - used as CLI entry point."""
2382
+ asyncio.run(main())
2383
+
2384
+
1293
2385
  if __name__ == "__main__":
1294
- main()
2386
+ main_sync()