emhass 0.13.2__py3-none-any.whl → 0.13.4__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 +6 -2
- emhass/data/associations.csv +6 -1
- emhass/data/config_defaults.json +5 -0
- emhass/forecast.py +52 -12
- emhass/machine_learning_forecaster.py +0 -2
- emhass/optimization.py +262 -126
- emhass/retrieve_hass.py +11 -9
- emhass/static/data/param_definitions.json +39 -2
- emhass/utils.py +61 -21
- emhass/web_server.py +10 -28
- {emhass-0.13.2.dist-info → emhass-0.13.4.dist-info}/METADATA +47 -23
- {emhass-0.13.2.dist-info → emhass-0.13.4.dist-info}/RECORD +15 -15
- {emhass-0.13.2.dist-info → emhass-0.13.4.dist-info}/WHEEL +0 -0
- {emhass-0.13.2.dist-info → emhass-0.13.4.dist-info}/entry_points.txt +0 -0
- {emhass-0.13.2.dist-info → emhass-0.13.4.dist-info}/licenses/LICENSE +0 -0
emhass/command_line.py
CHANGED
@@ -72,7 +72,9 @@ def retrieve_home_assistant_data(
|
|
72
72
|
if optim_conf.get("set_use_pv", True):
|
73
73
|
var_list.append(retrieve_hass_conf["sensor_power_photovoltaics"])
|
74
74
|
if optim_conf.get("set_use_adjusted_pv", True):
|
75
|
-
var_list.append(
|
75
|
+
var_list.append(
|
76
|
+
retrieve_hass_conf["sensor_power_photovoltaics_forecast"]
|
77
|
+
)
|
76
78
|
if not rh.get_data(
|
77
79
|
days_list, var_list, minimal_response=False, significant_changes_only=False
|
78
80
|
):
|
@@ -302,7 +304,8 @@ def set_input_data_dict(
|
|
302
304
|
else:
|
303
305
|
P_PV_forecast = pd.Series(0, index=fcst.forecast_dates)
|
304
306
|
P_load_forecast = fcst.get_load_forecast(
|
305
|
-
|
307
|
+
days_min_load_forecast=optim_conf["delta_forecast_daily"].days,
|
308
|
+
method=optim_conf["load_forecast_method"],
|
306
309
|
)
|
307
310
|
if isinstance(P_load_forecast, bool) and not P_load_forecast:
|
308
311
|
logger.error(
|
@@ -400,6 +403,7 @@ def set_input_data_dict(
|
|
400
403
|
else:
|
401
404
|
P_PV_forecast = pd.Series(0, index=fcst.forecast_dates)
|
402
405
|
P_load_forecast = fcst.get_load_forecast(
|
406
|
+
days_min_load_forecast=optim_conf["delta_forecast_daily"].days,
|
403
407
|
method=optim_conf["load_forecast_method"],
|
404
408
|
set_mix_forecast=set_mix_forecast,
|
405
409
|
df_now=df_input_data,
|
emhass/data/associations.csv
CHANGED
@@ -39,6 +39,7 @@ optim_conf,set_total_pv_sell,set_total_pv_sell
|
|
39
39
|
optim_conf,lp_solver,lp_solver
|
40
40
|
optim_conf,lp_solver_path,lp_solver_path
|
41
41
|
optim_conf,lp_solver_timeout,lp_solver_timeout
|
42
|
+
optim_conf,num_threads,num_threads
|
42
43
|
optim_conf,set_nocharge_from_grid,set_nocharge_from_grid
|
43
44
|
optim_conf,set_nodischarge_to_grid,set_nodischarge_to_grid
|
44
45
|
optim_conf,set_battery_dynamic,set_battery_dynamic
|
@@ -60,6 +61,10 @@ plant_conf,surface_azimuth,surface_azimuth,list_surface_azimuth
|
|
60
61
|
plant_conf,modules_per_string,modules_per_string,list_modules_per_string
|
61
62
|
plant_conf,strings_per_inverter,strings_per_inverter,list_strings_per_inverter
|
62
63
|
plant_conf,inverter_is_hybrid,inverter_is_hybrid
|
64
|
+
plant_conf,inverter_ac_output_max,inverter_ac_output_max
|
65
|
+
plant_conf,inverter_ac_input_max,inverter_ac_input_max
|
66
|
+
plant_conf,inverter_efficiency_dc_ac,inverter_efficiency_dc_ac
|
67
|
+
plant_conf,inverter_efficiency_ac_dc,inverter_efficiency_ac_dc
|
63
68
|
plant_conf,compute_curtailment,compute_curtailment
|
64
69
|
plant_conf,Pd_max,battery_discharge_power_max
|
65
70
|
plant_conf,Pc_max,battery_charge_power_max
|
@@ -68,4 +73,4 @@ plant_conf,eta_ch,battery_charge_efficiency
|
|
68
73
|
plant_conf,Enom,battery_nominal_energy_capacity
|
69
74
|
plant_conf,SOCmin,battery_minimum_state_of_charge
|
70
75
|
plant_conf,SOCmax,battery_maximum_state_of_charge
|
71
|
-
plant_conf,SOCtarget,battery_target_state_of_charge
|
76
|
+
plant_conf,SOCtarget,battery_target_state_of_charge
|
emhass/data/config_defaults.json
CHANGED
@@ -10,6 +10,7 @@
|
|
10
10
|
"lp_solver": "default",
|
11
11
|
"lp_solver_path": "empty",
|
12
12
|
"lp_solver_timeout": 45,
|
13
|
+
"num_threads": 0,
|
13
14
|
"set_nocharge_from_grid": false,
|
14
15
|
"set_nodischarge_to_grid": true,
|
15
16
|
"set_battery_dynamic": false,
|
@@ -115,6 +116,10 @@
|
|
115
116
|
1
|
116
117
|
],
|
117
118
|
"inverter_is_hybrid": false,
|
119
|
+
"inverter_ac_output_max": 1000,
|
120
|
+
"inverter_ac_input_max": 1000,
|
121
|
+
"inverter_efficiency_dc_ac": 1.0,
|
122
|
+
"inverter_efficiency_ac_dc": 1.0,
|
118
123
|
"compute_curtailment": false,
|
119
124
|
"set_use_battery": false,
|
120
125
|
"battery_discharge_power_max": 1000,
|
emhass/forecast.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
|
3
1
|
import bz2
|
4
2
|
import copy
|
5
3
|
import json
|
@@ -216,8 +214,7 @@ class Forecast:
|
|
216
214
|
]
|
217
215
|
|
218
216
|
def get_cached_open_meteo_forecast_json(
|
219
|
-
self,
|
220
|
-
max_age: int | None = 30,
|
217
|
+
self, max_age: int | None = 30, forecast_days: int = 3
|
221
218
|
) -> dict:
|
222
219
|
r"""
|
223
220
|
Get weather forecast json from Open-Meteo and cache it for re-use.
|
@@ -235,10 +232,30 @@ class Forecast:
|
|
235
232
|
before it is discarded and a new version fetched from Open-Meteo.
|
236
233
|
Defaults to 30 minutes.
|
237
234
|
:type max_age: int, optional
|
235
|
+
:param forecast_days: The number of days of forecast data required from Open-Meteo.
|
236
|
+
One additional day is always fetched from Open-Meteo so there is an extra data in the cache.
|
237
|
+
Defaults to 2 days (3 days fetched) to match the prior default.
|
238
|
+
:type forecast_days: int, optional
|
238
239
|
:return: The json containing the Open-Meteo forecast data
|
239
240
|
:rtype: dict
|
240
241
|
|
241
242
|
"""
|
243
|
+
|
244
|
+
# Ensure at least 3 weather forecast days (and 1 more than requested)
|
245
|
+
if forecast_days is None:
|
246
|
+
self.logger.warning(
|
247
|
+
"Open-Meteo forecast_days is missing so defaulting to 3 days"
|
248
|
+
)
|
249
|
+
forecast_days = 3
|
250
|
+
elif forecast_days < 3:
|
251
|
+
self.logger.warning(
|
252
|
+
"Open-Meteo forecast_days is too low (%s) so defaulting to 3 days",
|
253
|
+
forecast_days,
|
254
|
+
)
|
255
|
+
forecast_days = 3
|
256
|
+
else:
|
257
|
+
forecast_days = forecast_days + 1
|
258
|
+
|
242
259
|
json_path = os.path.abspath(
|
243
260
|
self.emhass_conf["data_path"] / "cached-open-meteo-forecast.json"
|
244
261
|
)
|
@@ -287,10 +304,13 @@ class Forecast:
|
|
287
304
|
+ "shortwave_radiation_instant,"
|
288
305
|
+ "diffuse_radiation_instant,"
|
289
306
|
+ "direct_normal_irradiance_instant"
|
307
|
+
+ "&forecast_days="
|
308
|
+
+ str(forecast_days)
|
290
309
|
+ "&timezone="
|
291
310
|
+ quote(str(self.time_zone), safe="")
|
292
311
|
)
|
293
312
|
try:
|
313
|
+
self.logger.debug("Fetching data from Open-Meteo using URL: %s", url)
|
294
314
|
response = get(url, headers=headers)
|
295
315
|
self.logger.debug("Returned HTTP status code: %s", response.status_code)
|
296
316
|
response.raise_for_status()
|
@@ -349,7 +369,8 @@ class Forecast:
|
|
349
369
|
): # The scrapper option is being left here for backward compatibility
|
350
370
|
if not os.path.isfile(w_forecast_cache_path):
|
351
371
|
data_raw = self.get_cached_open_meteo_forecast_json(
|
352
|
-
self.optim_conf["open_meteo_cache_max_age"]
|
372
|
+
self.optim_conf["open_meteo_cache_max_age"],
|
373
|
+
self.optim_conf["delta_forecast_daily"].days,
|
353
374
|
)
|
354
375
|
data_15min = pd.DataFrame.from_dict(data_raw["minutely_15"])
|
355
376
|
data_15min["time"] = pd.to_datetime(data_15min["time"])
|
@@ -671,6 +692,7 @@ class Forecast:
|
|
671
692
|
alpha: float,
|
672
693
|
beta: float,
|
673
694
|
col: str,
|
695
|
+
ignore_pv_feedback: bool = False,
|
674
696
|
) -> pd.DataFrame:
|
675
697
|
"""A simple correction method for forecasted data using the current real values of a variable.
|
676
698
|
|
@@ -684,9 +706,15 @@ class Forecast:
|
|
684
706
|
:type beta: float
|
685
707
|
:param col: The column variable name
|
686
708
|
:type col: str
|
709
|
+
:param ignore_pv_feedback: If True, bypass mixing and return original forecast (used during curtailment)
|
710
|
+
:type ignore_pv_feedback: bool
|
687
711
|
:return: The output DataFrame with the corrected values
|
688
712
|
:rtype: pd.DataFrame
|
689
713
|
"""
|
714
|
+
# If ignoring PV feedback (e.g., during curtailment), return original forecast
|
715
|
+
if ignore_pv_feedback:
|
716
|
+
return df_forecast
|
717
|
+
|
690
718
|
first_fcst = alpha * df_forecast.iloc[0] + beta * df_now[col].iloc[-1]
|
691
719
|
df_forecast.iloc[0] = int(round(first_fcst))
|
692
720
|
return df_forecast
|
@@ -787,12 +815,14 @@ class Forecast:
|
|
787
815
|
# Extracting results for AC power
|
788
816
|
P_PV_forecast = mc.results.ac
|
789
817
|
if set_mix_forecast:
|
818
|
+
ignore_pv_feedback = self.params["passed_data"].get("ignore_pv_feedback_during_curtailment", False)
|
790
819
|
P_PV_forecast = Forecast.get_mix_forecast(
|
791
820
|
df_now,
|
792
821
|
P_PV_forecast,
|
793
822
|
self.params["passed_data"]["alpha"],
|
794
823
|
self.params["passed_data"]["beta"],
|
795
824
|
self.var_PV,
|
825
|
+
ignore_pv_feedback,
|
796
826
|
)
|
797
827
|
P_PV_forecast[P_PV_forecast < 0] = 0 # replace any negative PV values with zero
|
798
828
|
self.logger.debug("get_power_from_weather returning:\n%s", P_PV_forecast)
|
@@ -1410,14 +1440,22 @@ class Forecast:
|
|
1410
1440
|
forecast_out.index.name = "ts"
|
1411
1441
|
forecast_out = forecast_out.rename(columns={"load": "yhat"})
|
1412
1442
|
elif method == "naive": # using a naive approach
|
1413
|
-
|
1414
|
-
|
1443
|
+
# Old code logic (shifted timestamp problem)
|
1444
|
+
# mask_forecast_out = (
|
1445
|
+
# df.index > days_list[-1] - self.optim_conf["delta_forecast_daily"]
|
1446
|
+
# )
|
1447
|
+
# forecast_out = df.copy().loc[mask_forecast_out]
|
1448
|
+
# forecast_out = forecast_out.rename(columns={self.var_load_new: "yhat"})
|
1449
|
+
# forecast_out = forecast_out.iloc[0 : len(self.forecast_dates)]
|
1450
|
+
# forecast_out.index = self.forecast_dates
|
1451
|
+
# New code logic
|
1452
|
+
forecast_horizon = len(self.forecast_dates)
|
1453
|
+
historical_values = df.iloc[-forecast_horizon:]
|
1454
|
+
forecast_out = pd.DataFrame(
|
1455
|
+
historical_values.values,
|
1456
|
+
index=self.forecast_dates,
|
1457
|
+
columns=["yhat"]
|
1415
1458
|
)
|
1416
|
-
forecast_out = df.copy().loc[mask_forecast_out]
|
1417
|
-
forecast_out = forecast_out.rename(columns={self.var_load_new: "yhat"})
|
1418
|
-
# Force forecast_out length to avoid mismatches
|
1419
|
-
forecast_out = forecast_out.iloc[0 : len(self.forecast_dates)]
|
1420
|
-
forecast_out.index = self.forecast_dates
|
1421
1459
|
elif (
|
1422
1460
|
method == "mlforecaster"
|
1423
1461
|
): # using a custom forecast model with machine learning
|
@@ -1506,12 +1544,14 @@ class Forecast:
|
|
1506
1544
|
return False
|
1507
1545
|
P_Load_forecast = copy.deepcopy(forecast_out["yhat"])
|
1508
1546
|
if set_mix_forecast:
|
1547
|
+
# Load forecasts don't need curtailment protection - always use feedback
|
1509
1548
|
P_Load_forecast = Forecast.get_mix_forecast(
|
1510
1549
|
df_now,
|
1511
1550
|
P_Load_forecast,
|
1512
1551
|
self.params["passed_data"]["alpha"],
|
1513
1552
|
self.params["passed_data"]["beta"],
|
1514
1553
|
self.var_load_new,
|
1554
|
+
False, # Never ignore feedback for load forecasts
|
1515
1555
|
)
|
1516
1556
|
self.logger.debug("get_load_forecast returning:\n%s", P_Load_forecast)
|
1517
1557
|
return P_Load_forecast
|