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 +1827 -735
- emhass/connection_manager.py +108 -0
- emhass/data/associations.csv +98 -0
- emhass/data/cec_inverters.pbz2 +0 -0
- emhass/data/cec_modules.pbz2 +0 -0
- emhass/data/config_defaults.json +120 -0
- emhass/forecast.py +1482 -622
- emhass/img/emhass_icon.png +0 -0
- emhass/machine_learning_forecaster.py +565 -212
- emhass/machine_learning_regressor.py +162 -122
- emhass/optimization.py +1724 -590
- emhass/retrieve_hass.py +1104 -248
- emhass/static/advanced.html +9 -1
- emhass/static/basic.html +4 -2
- emhass/static/configuration_list.html +48 -0
- emhass/static/configuration_script.js +956 -0
- emhass/static/data/param_definitions.json +592 -0
- emhass/static/script.js +377 -322
- emhass/static/style.css +270 -13
- emhass/templates/configuration.html +77 -0
- emhass/templates/index.html +23 -14
- emhass/templates/template.html +4 -5
- emhass/utils.py +1797 -428
- emhass/web_server.py +850 -448
- emhass/websocket_client.py +224 -0
- emhass-0.15.5.dist-info/METADATA +164 -0
- emhass-0.15.5.dist-info/RECORD +34 -0
- {emhass-0.10.6.dist-info → emhass-0.15.5.dist-info}/WHEEL +1 -2
- emhass-0.15.5.dist-info/entry_points.txt +2 -0
- emhass-0.10.6.dist-info/METADATA +0 -622
- emhass-0.10.6.dist-info/RECORD +0 -26
- emhass-0.10.6.dist-info/entry_points.txt +0 -2
- emhass-0.10.6.dist-info/top_level.txt +0 -1
- {emhass-0.10.6.dist-info → emhass-0.15.5.dist-info/licenses}/LICENSE +0 -0
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
|
|
13
|
-
from
|
|
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
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
278
|
-
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 =
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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[
|
|
315
|
-
input_data_dict[
|
|
316
|
-
method=input_data_dict[
|
|
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[
|
|
321
|
-
df_input_data,
|
|
322
|
-
|
|
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[
|
|
326
|
-
df_input_data, input_data_dict[
|
|
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 =
|
|
863
|
+
filename = default_csv_filename
|
|
333
864
|
if not debug:
|
|
334
865
|
opt_res.to_csv(
|
|
335
|
-
input_data_dict[
|
|
336
|
-
|
|
337
|
-
|
|
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[
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
#
|
|
368
|
-
df_input_data_dayahead =
|
|
369
|
-
input_data_dict
|
|
370
|
-
|
|
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
|
-
|
|
989
|
+
opt_res_dayahead = input_data_dict["opt"].perform_dayahead_forecast_optim(
|
|
374
990
|
df_input_data_dayahead,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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(
|
|
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 =
|
|
999
|
+
filename = default_csv_filename
|
|
391
1000
|
if not debug:
|
|
392
1001
|
opt_res_dayahead.to_csv(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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[
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
#
|
|
427
|
-
df_input_data_dayahead =
|
|
428
|
-
input_data_dict
|
|
429
|
-
|
|
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"]["
|
|
444
|
-
|
|
445
|
-
|
|
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,
|
|
448
|
-
|
|
449
|
-
|
|
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(
|
|
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 =
|
|
1082
|
+
filename = default_csv_filename
|
|
458
1083
|
if not debug:
|
|
459
1084
|
opt_res_naive_mpc.to_csv(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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[
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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[
|
|
488
|
-
model_type = input_data_dict[
|
|
489
|
-
var_model = input_data_dict[
|
|
490
|
-
sklearn_model = input_data_dict[
|
|
491
|
-
num_lags = input_data_dict[
|
|
492
|
-
split_date_delta = input_data_dict[
|
|
493
|
-
perform_backtest = input_data_dict[
|
|
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(
|
|
496
|
-
|
|
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+
|
|
504
|
-
filename_path = input_data_dict[
|
|
505
|
-
with open(filename_path,
|
|
506
|
-
pickle.
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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[
|
|
535
|
-
filename = model_type+
|
|
536
|
-
filename_path = input_data_dict[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
555
|
-
]
|
|
556
|
-
|
|
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
|
-
|
|
570
|
-
)
|
|
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,
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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[
|
|
605
|
-
filename = model_type+
|
|
606
|
-
filename_path = input_data_dict[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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+
|
|
621
|
-
filename_path = input_data_dict[
|
|
622
|
-
with open(filename_path,
|
|
623
|
-
pickle.
|
|
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
|
-
|
|
627
|
-
|
|
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.
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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", "
|
|
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(
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
:
|
|
741
|
-
:
|
|
742
|
-
:
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
752
|
-
if
|
|
753
|
-
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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 =
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if
|
|
808
|
-
|
|
809
|
-
elif
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
829
|
-
save_entities=entity_save,
|
|
830
|
-
dont_post=dont_post
|
|
1660
|
+
**ctx.common_kwargs,
|
|
831
1661
|
)
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
857
|
-
save_entities=entity_save,
|
|
858
|
-
dont_post=dont_post
|
|
1687
|
+
**ctx.common_kwargs,
|
|
859
1688
|
)
|
|
860
|
-
|
|
861
|
-
#
|
|
862
|
-
if
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
872
|
-
save_entities=entity_save,
|
|
873
|
-
dont_post=dont_post
|
|
1701
|
+
**ctx.common_kwargs,
|
|
874
1702
|
)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
for
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
963
|
-
save_entities=entity_save,
|
|
964
|
-
dont_post=dont_post
|
|
1847
|
+
**ctx.common_kwargs,
|
|
965
1848
|
)
|
|
966
|
-
|
|
967
|
-
#
|
|
968
|
-
|
|
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
|
-
|
|
1853
|
+
await ctx.rh.post_data(
|
|
971
1854
|
opt_res_latest[col_cost_fun],
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
978
|
-
save_entities=entity_save,
|
|
979
|
-
dont_post=dont_post
|
|
1861
|
+
**ctx.common_kwargs,
|
|
980
1862
|
)
|
|
981
|
-
#
|
|
982
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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[
|
|
1049
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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 /
|
|
1083
|
-
with open(entity_path /
|
|
1084
|
-
|
|
1085
|
-
|
|
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(
|
|
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
|
|
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(
|
|
1100
|
-
|
|
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(
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
parser.add_argument(
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
parser.add_argument(
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
parser.add_argument(
|
|
1169
|
-
|
|
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 =
|
|
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 =
|
|
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[
|
|
1187
|
-
emhass_conf[
|
|
1188
|
-
emhass_conf[
|
|
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
|
-
|
|
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
|
|
1196
|
-
logger.error(
|
|
1197
|
-
|
|
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
|
|
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
|
|
1205
|
-
logger.error("Could not find emhass/src
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2386
|
+
main_sync()
|