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/optimization.py CHANGED
@@ -1,25 +1,24 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
-
4
- import logging
5
- import copy
6
- import pathlib
7
1
  import bz2
2
+ import copy
3
+ import logging
4
+ import os
8
5
  import pickle as cPickle
9
- from typing import Optional, Tuple
10
- import pandas as pd
6
+ from math import ceil
7
+
11
8
  import numpy as np
9
+ import pandas as pd
12
10
  import pulp as plp
13
- from pulp import PULP_CBC_CMD, COIN_CMD, GLPK_CMD
14
- from math import ceil
11
+ from pulp import COIN_CMD, GLPK_CMD, PULP_CBC_CMD, HiGHS
12
+
13
+ from emhass import utils
15
14
 
16
15
 
17
16
  class Optimization:
18
17
  r"""
19
- Optimize the deferrable load and battery energy dispatch problem using \
18
+ Optimize the deferrable load and battery energy dispatch problem using \
20
19
  the linear programming optimization technique. All equipement equations, \
21
20
  including the battery equations are hence transformed in a linear form.
22
-
21
+
23
22
  This class methods are:
24
23
 
25
24
  - perform_optimization
@@ -27,18 +26,26 @@ class Optimization:
27
26
  - perform_perfect_forecast_optim
28
27
 
29
28
  - perform_dayahead_forecast_optim
30
-
29
+
31
30
  - perform_naive_mpc_optim
32
-
31
+
33
32
  """
34
33
 
35
- def __init__(self, retrieve_hass_conf: dict, optim_conf: dict, plant_conf: dict,
36
- var_load_cost: str, var_prod_price: str,
37
- costfun: str, emhass_conf: dict, logger: logging.Logger,
38
- opt_time_delta: Optional[int] = 24) -> None:
34
+ def __init__(
35
+ self,
36
+ retrieve_hass_conf: dict,
37
+ optim_conf: dict,
38
+ plant_conf: dict,
39
+ var_load_cost: str,
40
+ var_prod_price: str,
41
+ costfun: str,
42
+ emhass_conf: dict,
43
+ logger: logging.Logger,
44
+ opt_time_delta: int | None = 24,
45
+ ) -> None:
39
46
  r"""
40
47
  Define constructor for Optimization class.
41
-
48
+
42
49
  :param retrieve_hass_conf: Configuration parameters used to retrieve data \
43
50
  from hass
44
51
  :type retrieve_hass_conf: dict
@@ -61,56 +68,154 @@ class Optimization:
61
68
  more than one day then the optimization will be peformed by chunks of \
62
69
  opt_time_delta periods, defaults to 24
63
70
  :type opt_time_delta: float, optional
64
-
71
+
65
72
  """
66
73
  self.retrieve_hass_conf = retrieve_hass_conf
67
74
  self.optim_conf = optim_conf
68
75
  self.plant_conf = plant_conf
69
- self.freq = self.retrieve_hass_conf['freq']
70
- self.time_zone = self.retrieve_hass_conf['time_zone']
71
- self.timeStep = self.freq.seconds/3600 # in hours
72
- self.time_delta = pd.to_timedelta(opt_time_delta, "hours") # The period of optimization
73
- self.var_PV = self.retrieve_hass_conf['var_PV']
74
- self.var_load = self.retrieve_hass_conf['var_load']
75
- self.var_load_new = self.var_load+'_positive'
76
+ self.freq = self.retrieve_hass_conf["optimization_time_step"]
77
+ self.time_zone = self.retrieve_hass_conf["time_zone"]
78
+ self.time_step = self.freq.seconds / 3600 # in hours
79
+ self.time_delta = pd.to_timedelta(opt_time_delta, "hours") # The period of optimization
80
+ self.var_pv = self.retrieve_hass_conf["sensor_power_photovoltaics"]
81
+ self.var_load = self.retrieve_hass_conf["sensor_power_load_no_var_loads"]
82
+ self.var_load_new = self.var_load + "_positive"
76
83
  self.costfun = costfun
77
- # self.emhass_conf = emhass_conf
84
+ self.emhass_conf = emhass_conf
78
85
  self.logger = logger
79
86
  self.var_load_cost = var_load_cost
80
87
  self.var_prod_price = var_prod_price
81
88
  self.optim_status = None
82
- if 'lp_solver' in optim_conf.keys():
83
- self.lp_solver = optim_conf['lp_solver']
89
+ if "num_threads" in optim_conf.keys():
90
+ if optim_conf["num_threads"] == 0:
91
+ self.num_threads = int(os.cpu_count())
92
+ else:
93
+ self.num_threads = int(optim_conf["num_threads"])
94
+ else:
95
+ self.num_threads = int(os.cpu_count())
96
+ if "lp_solver" in optim_conf.keys():
97
+ self.lp_solver = optim_conf["lp_solver"]
84
98
  else:
85
- self.lp_solver = 'default'
86
- if 'lp_solver_path' in optim_conf.keys():
87
- self.lp_solver_path = optim_conf['lp_solver_path']
99
+ self.lp_solver = "default"
100
+ if "lp_solver_path" in optim_conf.keys():
101
+ self.lp_solver_path = optim_conf["lp_solver_path"]
88
102
  else:
89
- self.lp_solver_path = 'empty'
90
- if self.lp_solver != 'COIN_CMD' and self.lp_solver_path != 'empty':
91
- self.logger.error("Use COIN_CMD solver name if you want to set a path for the LP solver")
92
- if self.lp_solver == 'COIN_CMD' and self.lp_solver_path == 'empty': #if COIN_CMD but lp_solver_path is empty
93
- self.logger.warning("lp_solver=COIN_CMD but lp_solver_path=empty, attempting to use lp_solver_path=/usr/bin/cbc")
94
- self.lp_solver_path = '/usr/bin/cbc'
95
-
96
- def perform_optimization(self, data_opt: pd.DataFrame, P_PV: np.array, P_load: np.array,
97
- unit_load_cost: np.array, unit_prod_price: np.array,
98
- soc_init: Optional[float] = None, soc_final: Optional[float] = None,
99
- def_total_hours: Optional[list] = None,
100
- def_start_timestep: Optional[list] = None,
101
- def_end_timestep: Optional[list] = None,
102
- debug: Optional[bool] = False) -> pd.DataFrame:
103
+ self.lp_solver_path = "empty"
104
+ if self.lp_solver != "COIN_CMD" and self.lp_solver_path != "empty":
105
+ self.logger.error(
106
+ "Use COIN_CMD solver name if you want to set a path for the LP solver"
107
+ )
108
+ if (
109
+ self.lp_solver == "COIN_CMD" and self.lp_solver_path == "empty"
110
+ ): # if COIN_CMD but lp_solver_path is empty
111
+ self.logger.warning(
112
+ "lp_solver=COIN_CMD but lp_solver_path=empty, attempting to use lp_solver_path=/usr/bin/cbc"
113
+ )
114
+ self.lp_solver_path = "/usr/bin/cbc"
115
+ self.logger.debug(f"Initialized Optimization with retrieve_hass_conf: {retrieve_hass_conf}")
116
+ self.logger.debug(f"Optimization configuration: {optim_conf}")
117
+ self.logger.debug(f"Plant configuration: {plant_conf}")
118
+ self.logger.debug(
119
+ f"Solver configuration: lp_solver={self.lp_solver}, lp_solver_path={self.lp_solver_path}"
120
+ )
121
+ self.logger.debug(f"Number of threads: {self.num_threads}")
122
+
123
+ def _setup_stress_cost(self, set_i, cost_conf_key, max_power, var_name_prefix):
124
+ """
125
+ Generic setup for a stress cost (battery or inverter).
126
+ """
127
+ stress_unit_cost = self.plant_conf.get(cost_conf_key, 0)
128
+ active = stress_unit_cost > 0 and max_power > 0
129
+
130
+ stress_cost_vars = None
131
+ if active:
132
+ self.logger.debug(
133
+ f"Stress cost enabled for {var_name_prefix}. "
134
+ f"Unit Cost: {stress_unit_cost}/kWh at full load {max_power}W."
135
+ )
136
+ stress_cost_vars = {
137
+ i: plp.LpVariable(
138
+ cat="Continuous",
139
+ lowBound=0,
140
+ name=f"{var_name_prefix}_stress_cost_{i}",
141
+ )
142
+ for i in set_i
143
+ }
144
+
145
+ return {
146
+ "active": active,
147
+ "vars": stress_cost_vars,
148
+ "unit_cost": stress_unit_cost,
149
+ "max_power": max_power,
150
+ # Defaults to 10 segments if not provided in config
151
+ "segments": self.plant_conf.get(f"{var_name_prefix}_stress_segments", 10),
152
+ }
153
+
154
+ def _build_stress_segments(self, max_power, stress_unit_cost, segments):
155
+ """
156
+ Generic builder for Piece-Wise Linear segments for a quadratic cost curve.
157
+ """
158
+ # Cost rate at nominal power (currency/hr)
159
+ max_cost_rate_hr = (max_power / 1000.0) * stress_unit_cost
160
+ max_cost_step = max_cost_rate_hr * self.time_step
161
+
162
+ x_points = np.linspace(0, max_power, segments + 1)
163
+ y_points = max_cost_step * (x_points / max_power) ** 2
164
+
165
+ seg_params = []
166
+ for k in range(segments):
167
+ x0, x1 = x_points[k], x_points[k + 1]
168
+ y0, y1 = y_points[k], y_points[k + 1]
169
+ slope = (y1 - y0) / (x1 - x0)
170
+ intercept = y0 - slope * x0
171
+ seg_params.append((k, slope, intercept))
172
+ return seg_params
173
+
174
+ def _add_stress_constraints(
175
+ self, constraints, set_i, power_var_func, stress_vars, seg_params, prefix
176
+ ):
177
+ """
178
+ Generic constraint adder.
179
+ :param power_var_func: A function(i) that returns the LpVariable or Expression
180
+ representing the power to be penalized at index i.
181
+ """
182
+ for k, slope, intercept in seg_params:
183
+ for sign, suffix in ((+1, "pos"), (-1, "neg")):
184
+ for i in set_i:
185
+ name = f"constraint_stress_pwl_{prefix}_{suffix}_{k}_{i}"
186
+ constraints[name] = plp.LpConstraint(
187
+ e=stress_vars[i] - (sign * slope * power_var_func(i) + intercept),
188
+ sense=plp.LpConstraintGE,
189
+ rhs=0,
190
+ )
191
+
192
+ def perform_optimization(
193
+ self,
194
+ data_opt: pd.DataFrame,
195
+ p_pv: np.array,
196
+ p_load: np.array,
197
+ unit_load_cost: np.array,
198
+ unit_prod_price: np.array,
199
+ soc_init: float | None = None,
200
+ soc_final: float | None = None,
201
+ def_total_hours: list | None = None,
202
+ def_total_timestep: list | None = None,
203
+ def_start_timestep: list | None = None,
204
+ def_end_timestep: list | None = None,
205
+ def_init_temp: list | None = None,
206
+ debug: bool | None = False,
207
+ ) -> pd.DataFrame:
103
208
  r"""
104
209
  Perform the actual optimization using linear programming (LP).
105
-
210
+
106
211
  :param data_opt: A DataFrame containing the input data. The results of the \
107
212
  optimization will be appended (decision variables, cost function values, etc)
108
213
  :type data_opt: pd.DataFrame
109
- :param P_PV: The photovoltaic power values. This can be real historical \
214
+ :param p_pv: The photovoltaic power values. This can be real historical \
110
215
  values or forecasted values.
111
- :type P_PV: numpy.array
112
- :param P_load: The load power consumption values
113
- :type P_load: np.array
216
+ :type p_pv: numpy.array
217
+ :param p_load: The load power consumption values
218
+ :type p_load: np.array
114
219
  :param unit_load_cost: The cost of power consumption for each unit of time. \
115
220
  This is the cost of the energy from the utility in a vector sampled \
116
221
  at the fixed freq value
@@ -124,10 +229,13 @@ class Optimization:
124
229
  :type soc_init: float
125
230
  :param soc_final: The final battery SOC for the optimization. This parameter \
126
231
  is optional, if not given soc_init = soc_final = soc_target from the configuration file.
127
- :type soc_final:
232
+ :type soc_final:
128
233
  :param def_total_hours: The functioning hours for this iteration for each deferrable load. \
129
234
  (For continuous deferrable loads: functioning hours at nominal power)
130
235
  :type def_total_hours: list
236
+ :param def_total_timestep: The functioning timesteps for this iteration for each deferrable load. \
237
+ (For continuous deferrable loads: functioning timesteps at nominal power)
238
+ :type def_total_timestep: list
131
239
  :param def_start_timestep: The timestep as from which each deferrable load is allowed to operate.
132
240
  :type def_start_timestep: list
133
241
  :param def_end_timestep: The timestep before which each deferrable load should operate.
@@ -138,521 +246,1377 @@ class Optimization:
138
246
 
139
247
  """
140
248
  # Prepare some data in the case of a battery
141
- if self.optim_conf['set_use_battery']:
249
+ if self.optim_conf["set_use_battery"]:
142
250
  if soc_init is None:
143
251
  if soc_final is not None:
144
252
  soc_init = soc_final
145
253
  else:
146
- soc_init = self.plant_conf['SOCtarget']
254
+ soc_init = self.plant_conf["battery_target_state_of_charge"]
147
255
  if soc_final is None:
148
256
  if soc_init is not None:
149
257
  soc_final = soc_init
150
258
  else:
151
- soc_final = self.plant_conf['SOCtarget']
152
- if def_total_hours is None:
153
- def_total_hours = self.optim_conf['def_total_hours']
259
+ soc_final = self.plant_conf["battery_target_state_of_charge"]
260
+ self.logger.debug(
261
+ f"Battery usage enabled. Initial SOC: {soc_init}, Final SOC: {soc_final}"
262
+ )
263
+
264
+ # If def_total_timestep os set, bypass def_total_hours
265
+ if def_total_timestep is not None:
266
+ if def_total_hours is None:
267
+ def_total_hours = self.optim_conf["operating_hours_of_each_deferrable_load"]
268
+ def_total_hours = [0 if x != 0 else x for x in def_total_hours]
269
+ elif def_total_hours is None:
270
+ def_total_hours = self.optim_conf["operating_hours_of_each_deferrable_load"]
271
+
154
272
  if def_start_timestep is None:
155
- def_start_timestep = self.optim_conf['def_start_timestep']
273
+ def_start_timestep = self.optim_conf["start_timesteps_of_each_deferrable_load"]
156
274
  if def_end_timestep is None:
157
- def_end_timestep = self.optim_conf['def_end_timestep']
158
- type_self_conso = 'bigm' # maxmin
275
+ def_end_timestep = self.optim_conf["end_timesteps_of_each_deferrable_load"]
276
+
277
+ # Initialize deferrable initial temperatures if not provided
278
+ if def_init_temp is None:
279
+ def_init_temp = [None] * self.optim_conf["number_of_deferrable_loads"]
280
+
281
+ type_self_conso = "bigm" # maxmin
282
+
283
+ num_deferrable_loads = self.optim_conf["number_of_deferrable_loads"]
284
+
285
+ # Retrieve the minimum power for each deferrable load, defaulting to 0 if not provided
286
+ min_power_of_deferrable_loads = self.optim_conf.get(
287
+ "minimum_power_of_deferrable_loads", [0] * num_deferrable_loads
288
+ )
289
+ min_power_of_deferrable_loads = min_power_of_deferrable_loads + [0] * (
290
+ num_deferrable_loads - len(min_power_of_deferrable_loads)
291
+ )
292
+
293
+ def_total_hours = def_total_hours + [0] * (num_deferrable_loads - len(def_total_hours))
294
+ def_start_timestep = def_start_timestep + [0] * (
295
+ num_deferrable_loads - len(def_start_timestep)
296
+ )
297
+ def_end_timestep = def_end_timestep + [0] * (num_deferrable_loads - len(def_end_timestep))
159
298
 
160
299
  #### The LP problem using Pulp ####
161
300
  opt_model = plp.LpProblem("LP_Model", plp.LpMaximize)
162
301
 
163
302
  n = len(data_opt.index)
164
- set_I = range(n)
165
- M = 10e10
303
+ set_i = range(n)
304
+ M = 100000
166
305
 
167
306
  ## Add decision variables
168
- P_grid_neg = {(i):plp.LpVariable(cat='Continuous',
169
- lowBound=-self.plant_conf['P_to_grid_max'], upBound=0,
170
- name="P_grid_neg{}".format(i)) for i in set_I}
171
- P_grid_pos = {(i):plp.LpVariable(cat='Continuous',
172
- lowBound=0, upBound=self.plant_conf['P_from_grid_max'],
173
- name="P_grid_pos{}".format(i)) for i in set_I}
174
- P_deferrable = []
175
- P_def_bin1 = []
176
- for k in range(self.optim_conf['num_def_loads']):
177
- if type(self.optim_conf['P_deferrable_nom'][k]) == list:
178
- upBound = np.max(self.optim_conf['P_deferrable_nom'][k])
307
+ p_grid_neg = {
308
+ (i): plp.LpVariable(
309
+ cat="Continuous",
310
+ lowBound=-self.plant_conf["maximum_power_to_grid"],
311
+ upBound=0,
312
+ name=f"P_grid_neg{i}",
313
+ )
314
+ for i in set_i
315
+ }
316
+ p_grid_pos = {
317
+ (i): plp.LpVariable(
318
+ cat="Continuous",
319
+ lowBound=0,
320
+ upBound=self.plant_conf["maximum_power_from_grid"],
321
+ name=f"P_grid_pos{i}",
322
+ )
323
+ for i in set_i
324
+ }
325
+ p_deferrable = []
326
+ p_def_bin1 = []
327
+ for k in range(num_deferrable_loads):
328
+ if isinstance(self.optim_conf["nominal_power_of_deferrable_loads"][k], list):
329
+ up_bound = np.max(self.optim_conf["nominal_power_of_deferrable_loads"][k])
179
330
  else:
180
- upBound = self.optim_conf['P_deferrable_nom'][k]
181
- if self.optim_conf['treat_def_as_semi_cont'][k]:
182
- P_deferrable.append({(i):plp.LpVariable(cat='Continuous',
183
- name="P_deferrable{}_{}".format(k, i)) for i in set_I})
331
+ up_bound = self.optim_conf["nominal_power_of_deferrable_loads"][k]
332
+ if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
333
+ p_deferrable.append(
334
+ {
335
+ (i): plp.LpVariable(cat="Continuous", name=f"P_deferrable{k}_{i}")
336
+ for i in set_i
337
+ }
338
+ )
184
339
  else:
185
- P_deferrable.append({(i):plp.LpVariable(cat='Continuous',
186
- lowBound=0, upBound=upBound,
187
- name="P_deferrable{}_{}".format(k, i)) for i in set_I})
188
- P_def_bin1.append({(i):plp.LpVariable(cat='Binary',
189
- name="P_def{}_bin1_{}".format(k, i)) for i in set_I})
190
- P_def_start = []
191
- P_def_bin2 = []
192
- for k in range(self.optim_conf['num_def_loads']):
193
- P_def_start.append({(i):plp.LpVariable(cat='Binary',
194
- name="P_def{}_start_{}".format(k, i)) for i in set_I})
195
- P_def_bin2.append({(i):plp.LpVariable(cat='Binary',
196
- name="P_def{}_bin2_{}".format(k, i)) for i in set_I})
197
- D = {(i):plp.LpVariable(cat='Binary',
198
- name="D_{}".format(i)) for i in set_I}
199
- E = {(i):plp.LpVariable(cat='Binary',
200
- name="E_{}".format(i)) for i in set_I}
201
- if self.optim_conf['set_use_battery']:
202
- P_sto_pos = {(i):plp.LpVariable(cat='Continuous',
203
- lowBound=0, upBound=self.plant_conf['Pd_max'],
204
- name="P_sto_pos_{0}".format(i)) for i in set_I}
205
- P_sto_neg = {(i):plp.LpVariable(cat='Continuous',
206
- lowBound=-self.plant_conf['Pc_max'], upBound=0,
207
- name="P_sto_neg_{0}".format(i)) for i in set_I}
340
+ p_deferrable.append(
341
+ {
342
+ (i): plp.LpVariable(
343
+ cat="Continuous",
344
+ lowBound=0,
345
+ upBound=up_bound,
346
+ name=f"P_deferrable{k}_{i}",
347
+ )
348
+ for i in set_i
349
+ }
350
+ )
351
+ p_def_bin1.append(
352
+ {(i): plp.LpVariable(cat="Binary", name=f"P_def{k}_bin1_{i}") for i in set_i}
353
+ )
354
+ p_def_start = []
355
+ p_def_bin2 = []
356
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
357
+ p_def_start.append(
358
+ {(i): plp.LpVariable(cat="Binary", name=f"P_def{k}_start_{i}") for i in set_i}
359
+ )
360
+ p_def_bin2.append(
361
+ {(i): plp.LpVariable(cat="Binary", name=f"P_def{k}_bin2_{i}") for i in set_i}
362
+ )
363
+ D = {(i): plp.LpVariable(cat="Binary", name=f"D_{i}") for i in set_i}
364
+ E = {(i): plp.LpVariable(cat="Binary", name=f"E_{i}") for i in set_i}
365
+
366
+ # Initialize stress configuration dictionaries
367
+ inv_stress_conf = None
368
+ batt_stress_conf = None
369
+
370
+ if self.optim_conf["set_use_battery"]:
371
+ if not self.plant_conf["inverter_is_hybrid"]:
372
+ self.logger.debug(
373
+ "Non-hybrid system detected. Using 'battery_charge_power_max' (%s W) "
374
+ "as the effective charge limit. "
375
+ "Any 'inverter_ac_input_max' setting is ignored to prevent default value conflicts.",
376
+ self.plant_conf["battery_charge_power_max"],
377
+ )
378
+ p_sto_pos = {
379
+ (i): plp.LpVariable(
380
+ cat="Continuous",
381
+ lowBound=0,
382
+ upBound=self.plant_conf["battery_discharge_power_max"],
383
+ name=f"P_sto_pos_{i}",
384
+ )
385
+ for i in set_i
386
+ }
387
+ p_sto_neg = {
388
+ (i): plp.LpVariable(
389
+ cat="Continuous",
390
+ lowBound=-np.abs(self.plant_conf["battery_charge_power_max"]),
391
+ upBound=0,
392
+ name=f"P_sto_neg_{i}",
393
+ )
394
+ for i in set_i
395
+ }
396
+
397
+ # Setup Battery Stress Cost
398
+ # We determine max power for the stress calculation as the max of charge/discharge limits
399
+ p_batt_max = max(
400
+ self.plant_conf.get("battery_discharge_power_max", 0),
401
+ self.plant_conf.get("battery_charge_power_max", 0),
402
+ )
403
+ batt_stress_conf = self._setup_stress_cost(
404
+ set_i, "battery_stress_cost", p_batt_max, "battery"
405
+ )
406
+
208
407
  else:
209
- P_sto_pos = {(i):i*0 for i in set_I}
210
- P_sto_neg = {(i):i*0 for i in set_I}
211
-
212
- if self.costfun == 'self-consumption':
213
- SC = {(i):plp.LpVariable(cat='Continuous',
214
- name="SC_{}".format(i)) for i in set_I}
215
- if self.plant_conf['inverter_is_hybrid']:
216
- P_hybrid_inverter = {(i):plp.LpVariable(cat='Continuous',
217
- name="P_hybrid_inverter{}".format(i)) for i in set_I}
218
- P_PV_curtailment = {(i):plp.LpVariable(cat='Continuous', lowBound=0,
219
- name="P_PV_curtailment{}".format(i)) for i in set_I}
220
-
408
+ p_sto_pos = {(i): i * 0 for i in set_i}
409
+ p_sto_neg = {(i): i * 0 for i in set_i}
410
+
411
+ if self.costfun == "self-consumption":
412
+ SC = {(i): plp.LpVariable(cat="Continuous", name=f"SC_{i}") for i in set_i}
413
+
414
+ if self.plant_conf["inverter_is_hybrid"]:
415
+ p_hybrid_inverter = {
416
+ (i): plp.LpVariable(cat="Continuous", name=f"P_hybrid_inverter{i}") for i in set_i
417
+ }
418
+ # Setup Inverter Stress Cost
419
+ P_nom_inverter_max = max(
420
+ self.plant_conf.get("inverter_ac_output_max", 0),
421
+ self.plant_conf.get("inverter_ac_input_max", 0),
422
+ )
423
+ inv_stress_conf = self._setup_stress_cost(
424
+ set_i, "inverter_stress_cost", P_nom_inverter_max, "inv"
425
+ )
426
+
427
+ p_pv_curtailment = {
428
+ (i): plp.LpVariable(cat="Continuous", lowBound=0, name=f"P_PV_curtailment{i}")
429
+ for i in set_i
430
+ }
431
+
221
432
  ## Define objective
222
- P_def_sum= []
223
- for i in set_I:
224
- P_def_sum.append(plp.lpSum(P_deferrable[k][i] for k in range(self.optim_conf['num_def_loads'])))
225
- if self.costfun == 'profit':
226
- if self.optim_conf['set_total_pv_sell']:
227
- objective = plp.lpSum(-0.001*self.timeStep*(unit_load_cost[i]*(P_load[i] + P_def_sum[i]) + \
228
- unit_prod_price[i]*P_grid_neg[i]) for i in set_I)
433
+ p_def_sum = []
434
+ for i in set_i:
435
+ p_def_sum.append(
436
+ plp.lpSum(
437
+ p_deferrable[k][i] for k in range(self.optim_conf["number_of_deferrable_loads"])
438
+ )
439
+ )
440
+ if self.costfun == "profit":
441
+ if self.optim_conf["set_total_pv_sell"]:
442
+ objective = plp.lpSum(
443
+ -0.001
444
+ * self.time_step
445
+ * (
446
+ unit_load_cost[i] * (p_load[i] + p_def_sum[i])
447
+ + unit_prod_price[i] * p_grid_neg[i]
448
+ )
449
+ for i in set_i
450
+ )
229
451
  else:
230
- objective = plp.lpSum(-0.001*self.timeStep*(unit_load_cost[i]*P_grid_pos[i] + \
231
- unit_prod_price[i]*P_grid_neg[i]) for i in set_I)
232
- elif self.costfun == 'cost':
233
- if self.optim_conf['set_total_pv_sell']:
234
- objective = plp.lpSum(-0.001*self.timeStep*unit_load_cost[i]*(P_load[i] + P_def_sum[i]) for i in set_I)
452
+ objective = plp.lpSum(
453
+ -0.001
454
+ * self.time_step
455
+ * (unit_load_cost[i] * p_grid_pos[i] + unit_prod_price[i] * p_grid_neg[i])
456
+ for i in set_i
457
+ )
458
+ elif self.costfun == "cost":
459
+ if self.optim_conf["set_total_pv_sell"]:
460
+ objective = plp.lpSum(
461
+ -0.001 * self.time_step * unit_load_cost[i] * (p_load[i] + p_def_sum[i])
462
+ for i in set_i
463
+ )
235
464
  else:
236
- objective = plp.lpSum(-0.001*self.timeStep*unit_load_cost[i]*P_grid_pos[i] for i in set_I)
237
- elif self.costfun == 'self-consumption':
238
- if type_self_conso == 'bigm':
465
+ objective = plp.lpSum(
466
+ -0.001 * self.time_step * unit_load_cost[i] * p_grid_pos[i] for i in set_i
467
+ )
468
+ elif self.costfun == "self-consumption":
469
+ if type_self_conso == "bigm":
239
470
  bigm = 1e3
240
- objective = plp.lpSum(-0.001*self.timeStep*(bigm*unit_load_cost[i]*P_grid_pos[i] + \
241
- unit_prod_price[i]*P_grid_neg[i]) for i in set_I)
242
- elif type_self_conso == 'maxmin':
243
- objective = plp.lpSum(0.001*self.timeStep*unit_load_cost[i]*SC[i] for i in set_I)
471
+ objective = plp.lpSum(
472
+ -0.001
473
+ * self.time_step
474
+ * (
475
+ bigm * unit_load_cost[i] * p_grid_pos[i]
476
+ + unit_prod_price[i] * p_grid_neg[i]
477
+ )
478
+ for i in set_i
479
+ )
480
+ elif type_self_conso == "maxmin":
481
+ objective = plp.lpSum(
482
+ 0.001 * self.time_step * unit_load_cost[i] * SC[i] for i in set_i
483
+ )
244
484
  else:
245
485
  self.logger.error("Not a valid option for type_self_conso parameter")
246
486
  else:
247
487
  self.logger.error("The cost function specified type is not valid")
248
488
  # Add more terms to the objective function in the case of battery use
249
- if self.optim_conf['set_use_battery']:
250
- objective = objective + plp.lpSum(-0.001*self.timeStep*(
251
- self.optim_conf['weight_battery_discharge']*P_sto_pos[i] + \
252
- self.optim_conf['weight_battery_charge']*P_sto_neg[i]) for i in set_I)
489
+ if self.optim_conf["set_use_battery"]:
490
+ objective = objective + plp.lpSum(
491
+ -0.001
492
+ * self.time_step
493
+ * (
494
+ self.optim_conf["weight_battery_discharge"] * p_sto_pos[i]
495
+ - self.optim_conf["weight_battery_charge"] * p_sto_neg[i]
496
+ )
497
+ for i in set_i
498
+ )
253
499
 
254
500
  # Add term penalizing each startup where configured
255
- if ("def_start_penalty" in self.optim_conf and self.optim_conf["def_start_penalty"]):
256
- for k in range(self.optim_conf["num_def_loads"]):
257
- if (len(self.optim_conf["def_start_penalty"]) > k and self.optim_conf["def_start_penalty"][k]):
501
+ if (
502
+ "set_deferrable_startup_penalty" in self.optim_conf
503
+ and self.optim_conf["set_deferrable_startup_penalty"]
504
+ ):
505
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
506
+ if (
507
+ len(self.optim_conf["set_deferrable_startup_penalty"]) > k
508
+ and self.optim_conf["set_deferrable_startup_penalty"][k]
509
+ ):
258
510
  objective = objective + plp.lpSum(
259
- -0.001 * self.timeStep * self.optim_conf["def_start_penalty"][k] * P_def_start[k][i] *\
260
- unit_load_cost[i] * self.optim_conf['P_deferrable_nom'][k]
261
- for i in set_I)
511
+ -0.001
512
+ * self.time_step
513
+ * self.optim_conf["set_deferrable_startup_penalty"][k]
514
+ * p_def_start[k][i]
515
+ * unit_load_cost[i]
516
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k]
517
+ for i in set_i
518
+ )
519
+
520
+ # Add the Hybrid Inverter Stress cost to objective
521
+ if inv_stress_conf and inv_stress_conf["active"]:
522
+ objective = objective - plp.lpSum(inv_stress_conf["vars"][i] for i in set_i)
523
+
524
+ # Add the Battery Stress cost to objective
525
+ if batt_stress_conf and batt_stress_conf["active"]:
526
+ # Sanity check logging
527
+ self.logger.debug("Adding battery stress cost to objective function")
528
+ objective = objective - plp.lpSum(batt_stress_conf["vars"][i] for i in set_i)
262
529
 
263
530
  opt_model.setObjective(objective)
264
531
 
265
532
  ## Setting constraints
266
533
  # The main constraint: power balance
267
- if self.plant_conf['inverter_is_hybrid']:
268
- constraints = {"constraint_main1_{}".format(i) :
269
- plp.LpConstraint(
270
- e = P_hybrid_inverter[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] ,
271
- sense = plp.LpConstraintEQ,
272
- rhs = 0)
273
- for i in set_I}
534
+ if self.plant_conf["inverter_is_hybrid"]:
535
+ constraints = {
536
+ f"constraint_main1_{i}": plp.LpConstraint(
537
+ e=p_hybrid_inverter[i]
538
+ - p_def_sum[i]
539
+ - p_load[i]
540
+ + p_grid_neg[i]
541
+ + p_grid_pos[i],
542
+ sense=plp.LpConstraintEQ,
543
+ rhs=0,
544
+ )
545
+ for i in set_i
546
+ }
274
547
  else:
275
- if self.plant_conf['compute_curtailment']:
276
- constraints = {"constraint_main2_{}".format(i) :
277
- plp.LpConstraint(
278
- e = P_PV[i] - P_PV_curtailment[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i],
279
- sense = plp.LpConstraintEQ,
280
- rhs = 0)
281
- for i in set_I}
548
+ if self.plant_conf["compute_curtailment"]:
549
+ constraints = {
550
+ f"constraint_main2_{i}": plp.LpConstraint(
551
+ e=p_pv[i]
552
+ - p_pv_curtailment[i]
553
+ - p_def_sum[i]
554
+ - p_load[i]
555
+ + p_grid_neg[i]
556
+ + p_grid_pos[i]
557
+ + p_sto_pos[i]
558
+ + p_sto_neg[i],
559
+ sense=plp.LpConstraintEQ,
560
+ rhs=0,
561
+ )
562
+ for i in set_i
563
+ }
282
564
  else:
283
- constraints = {"constraint_main3_{}".format(i) :
284
- plp.LpConstraint(
285
- e = P_PV[i] - P_def_sum[i] - P_load[i] + P_grid_neg[i] + P_grid_pos[i] + P_sto_pos[i] + P_sto_neg[i],
286
- sense = plp.LpConstraintEQ,
287
- rhs = 0)
288
- for i in set_I}
289
-
290
- # Constraint for hybrid inverter and curtailment cases
291
- if type(self.plant_conf['module_model']) == list:
292
- P_nom_inverter = 0.0
293
- for i in range(len(self.plant_conf['inverter_model'])):
294
- if type(self.plant_conf['inverter_model'][i]) == str:
295
- cec_inverters = bz2.BZ2File(pathlib.Path(__file__).parent / 'data/cec_inverters.pbz2', "rb")
296
- cec_inverters = cPickle.load(cec_inverters)
297
- inverter = cec_inverters[self.plant_conf['inverter_model'][i]]
298
- P_nom_inverter += inverter.Paco
565
+ constraints = {
566
+ f"constraint_main3_{i}": plp.LpConstraint(
567
+ e=p_pv[i]
568
+ - p_def_sum[i]
569
+ - p_load[i]
570
+ + p_grid_neg[i]
571
+ + p_grid_pos[i]
572
+ + p_sto_pos[i]
573
+ + p_sto_neg[i],
574
+ sense=plp.LpConstraintEQ,
575
+ rhs=0,
576
+ )
577
+ for i in set_i
578
+ }
579
+
580
+ # Implementation of the Quadratic Battery Stress Cost
581
+ # This applies regardless of whether the inverter is hybrid or not, as long as battery is used.
582
+ if batt_stress_conf and batt_stress_conf["active"]:
583
+ self.logger.debug("Applying battery stress constraints to LP model")
584
+ seg_params = self._build_stress_segments(
585
+ batt_stress_conf["max_power"],
586
+ batt_stress_conf["unit_cost"],
587
+ batt_stress_conf["segments"],
588
+ )
589
+ # For Battery, the power magnitude is p_sto_pos[i] - p_sto_neg[i]
590
+ # (Recall p_sto_neg is <= 0, so this sums the absolute values)
591
+ self._add_stress_constraints(
592
+ constraints,
593
+ set_i,
594
+ lambda i: p_sto_pos[i] - p_sto_neg[i],
595
+ batt_stress_conf["vars"],
596
+ seg_params,
597
+ "batt",
598
+ )
599
+
600
+ if self.plant_conf["inverter_is_hybrid"]:
601
+ p_nom_inverter_output = self.plant_conf.get("inverter_ac_output_max", None)
602
+ p_nom_inverter_input = self.plant_conf.get("inverter_ac_input_max", None)
603
+
604
+ # Fallback to legacy pv_inverter_model for output power if new setting is not provided
605
+ if p_nom_inverter_output is None:
606
+ if "pv_inverter_model" in self.plant_conf:
607
+ if isinstance(self.plant_conf["pv_inverter_model"], list):
608
+ p_nom_inverter_output = 0.0
609
+ for i in range(len(self.plant_conf["pv_inverter_model"])):
610
+ if isinstance(self.plant_conf["pv_inverter_model"][i], str):
611
+ cec_inverters = bz2.BZ2File(
612
+ self.emhass_conf["root_path"] / "data" / "cec_inverters.pbz2",
613
+ "rb",
614
+ )
615
+ cec_inverters = cPickle.load(cec_inverters)
616
+ inverter = cec_inverters[self.plant_conf["pv_inverter_model"][i]]
617
+ p_nom_inverter_output += inverter.Paco
618
+ else:
619
+ p_nom_inverter_output += self.plant_conf["pv_inverter_model"][i]
620
+ else:
621
+ if isinstance(self.plant_conf["pv_inverter_model"], str):
622
+ cec_inverters = bz2.BZ2File(
623
+ self.emhass_conf["root_path"] / "data" / "cec_inverters.pbz2",
624
+ "rb",
625
+ )
626
+ cec_inverters = cPickle.load(cec_inverters)
627
+ inverter = cec_inverters[self.plant_conf["pv_inverter_model"]]
628
+ p_nom_inverter_output = inverter.Paco
629
+ else:
630
+ p_nom_inverter_output = self.plant_conf["pv_inverter_model"]
631
+
632
+ if p_nom_inverter_input is None:
633
+ p_nom_inverter_input = p_nom_inverter_output
634
+
635
+ # Get efficiency parameters, defaulting to 100%
636
+ eff_dc_ac = self.plant_conf.get("inverter_efficiency_dc_ac", 1.0)
637
+ eff_ac_dc = self.plant_conf.get("inverter_efficiency_ac_dc", 1.0)
638
+
639
+ # Calculate the maximum allowed DC power flows based on AC limits and efficiency.
640
+ p_dc_ac_max = p_nom_inverter_output / eff_dc_ac
641
+ p_ac_dc_max = p_nom_inverter_input * eff_ac_dc
642
+
643
+ # Define unidirectional DC power flow variables with the tight, calculated bounds.
644
+ p_dc_ac = {
645
+ (i): plp.LpVariable(
646
+ cat="Continuous",
647
+ lowBound=0,
648
+ upBound=p_dc_ac_max,
649
+ name=f"P_dc_ac_{i}",
650
+ )
651
+ for i in set_i
652
+ }
653
+ p_ac_dc = {
654
+ (i): plp.LpVariable(
655
+ cat="Continuous",
656
+ lowBound=0,
657
+ upBound=p_ac_dc_max,
658
+ name=f"P_ac_dc_{i}",
659
+ )
660
+ for i in set_i
661
+ }
662
+ # Binary variable to enforce unidirectional flow
663
+ is_dc_sourcing = {
664
+ (i): plp.LpVariable(cat="Binary", name=f"is_dc_sourcing_{i}") for i in set_i
665
+ }
666
+
667
+ # Define the core energy balance equations for each timestep
668
+ for i in set_i:
669
+ # The net DC power from PV and battery must equal the net DC flow of the inverter
670
+ # Conditionally include curtailment variable to avoid energy leaks when feature is disabled
671
+ if self.plant_conf["compute_curtailment"]:
672
+ e_dc_balance = (p_pv[i] - p_pv_curtailment[i] + p_sto_pos[i] + p_sto_neg[i]) - (
673
+ p_dc_ac[i] - p_ac_dc[i]
674
+ )
299
675
  else:
300
- P_nom_inverter += self.plant_conf['inverter_model'][i]
301
- else:
302
- if type(self.plant_conf['inverter_model'][i]) == str:
303
- cec_inverters = bz2.BZ2File(pathlib.Path(__file__).parent / 'data/cec_inverters.pbz2', "rb")
304
- cec_inverters = cPickle.load(cec_inverters)
305
- inverter = cec_inverters[self.plant_conf['inverter_model']]
306
- P_nom_inverter = inverter.Paco
307
- else:
308
- P_nom_inverter = self.plant_conf['inverter_model']
309
- if self.plant_conf['inverter_is_hybrid']:
310
- constraints.update({"constraint_hybrid_inverter1_{}".format(i) :
311
- plp.LpConstraint(
312
- e = P_PV[i] - P_PV_curtailment[i] + P_sto_pos[i] + P_sto_neg[i] - P_nom_inverter,
313
- sense = plp.LpConstraintLE,
314
- rhs = 0)
315
- for i in set_I})
316
- constraints.update({"constraint_hybrid_inverter2_{}".format(i) :
317
- plp.LpConstraint(
318
- e = P_PV[i] - P_PV_curtailment[i] + P_sto_pos[i] + P_sto_neg[i] - P_hybrid_inverter[i],
319
- sense = plp.LpConstraintEQ,
320
- rhs = 0)
321
- for i in set_I})
322
- else:
323
- if self.plant_conf['compute_curtailment']:
324
- constraints.update({"constraint_curtailment_{}".format(i) :
325
- plp.LpConstraint(
326
- e = P_PV_curtailment[i] - max(P_PV[i],0),
327
- sense = plp.LpConstraintLE,
328
- rhs = 0)
329
- for i in set_I})
676
+ e_dc_balance = (p_pv[i] + p_sto_pos[i] + p_sto_neg[i]) - (
677
+ p_dc_ac[i] - p_ac_dc[i]
678
+ )
679
+
680
+ constraints.update(
681
+ {
682
+ f"constraint_dc_bus_balance_{i}": plp.LpConstraint(
683
+ e=e_dc_balance,
684
+ sense=plp.LpConstraintEQ,
685
+ rhs=0,
686
+ )
687
+ }
688
+ )
689
+
690
+ # The AC power is defined by the efficiency-adjusted DC flows
691
+ constraints.update(
692
+ {
693
+ f"constraint_ac_bus_balance_{i}": plp.LpConstraint(
694
+ e=p_hybrid_inverter[i]
695
+ - ((p_dc_ac[i] * eff_dc_ac) - (p_ac_dc[i] / eff_ac_dc)),
696
+ sense=plp.LpConstraintEQ,
697
+ rhs=0,
698
+ )
699
+ }
700
+ )
701
+
702
+ # Use the binary variable to ensure only one direction is active at a time
703
+ constraints.update(
704
+ {
705
+ # If is_dc_sourcing = 1 (DC->AC is active), then p_ac_dc must be 0.
706
+ f"constraint_enforce_ac_dc_zero_{i}": plp.LpConstraint(
707
+ e=p_ac_dc[i] - (1 - is_dc_sourcing[i]) * p_ac_dc_max,
708
+ sense=plp.LpConstraintLE,
709
+ rhs=0,
710
+ ),
711
+ # If is_dc_sourcing = 0 (AC->DC is active), then p_dc_ac must be 0.
712
+ f"constraint_enforce_dc_ac_zero_{i}": plp.LpConstraint(
713
+ e=p_dc_ac[i] - is_dc_sourcing[i] * p_dc_ac_max,
714
+ sense=plp.LpConstraintLE,
715
+ rhs=0,
716
+ ),
717
+ }
718
+ )
719
+
720
+ # Implementation of the Quadratic Inverter Stress Cost
721
+ if inv_stress_conf and inv_stress_conf["active"]:
722
+ seg_params = self._build_stress_segments(
723
+ inv_stress_conf["max_power"],
724
+ inv_stress_conf["unit_cost"],
725
+ inv_stress_conf["segments"],
726
+ )
727
+ # For Inverter, the variable is p_hybrid_inverter[i]
728
+ self._add_stress_constraints(
729
+ constraints,
730
+ set_i,
731
+ lambda i: p_hybrid_inverter[i],
732
+ inv_stress_conf["vars"],
733
+ seg_params,
734
+ "inv",
735
+ )
736
+
737
+ # Apply curtailment constraint if enabled, regardless of inverter type
738
+ if self.plant_conf["compute_curtailment"]:
739
+ constraints.update(
740
+ {
741
+ f"constraint_curtailment_{i}": plp.LpConstraint(
742
+ e=p_pv_curtailment[i] - max(p_pv[i], 0),
743
+ sense=plp.LpConstraintLE,
744
+ rhs=0,
745
+ )
746
+ for i in set_i
747
+ }
748
+ )
330
749
 
331
750
  # Two special constraints just for a self-consumption cost function
332
- if self.costfun == 'self-consumption':
333
- if type_self_conso == 'maxmin': # maxmin linear problem
334
- constraints.update({"constraint_selfcons_PV1_{}".format(i) :
335
- plp.LpConstraint(
336
- e = SC[i] - P_PV[i],
337
- sense = plp.LpConstraintLE,
338
- rhs = 0)
339
- for i in set_I})
340
- constraints.update({"constraint_selfcons_PV2_{}".format(i) :
341
- plp.LpConstraint(
342
- e = SC[i] - P_load[i] - P_def_sum[i],
343
- sense = plp.LpConstraintLE,
344
- rhs = 0)
345
- for i in set_I})
751
+ if (
752
+ self.costfun == "self-consumption" and type_self_conso == "maxmin"
753
+ ): # maxmin linear problem
754
+ constraints.update(
755
+ {
756
+ f"constraint_selfcons_PV1_{i}": plp.LpConstraint(
757
+ e=SC[i] - p_pv[i], sense=plp.LpConstraintLE, rhs=0
758
+ )
759
+ for i in set_i
760
+ }
761
+ )
762
+ constraints.update(
763
+ {
764
+ f"constraint_selfcons_PV2_{i}": plp.LpConstraint(
765
+ e=SC[i] - p_load[i] - p_def_sum[i],
766
+ sense=plp.LpConstraintLE,
767
+ rhs=0,
768
+ )
769
+ for i in set_i
770
+ }
771
+ )
346
772
 
347
773
  # Avoid injecting and consuming from grid at the same time
348
- constraints.update({"constraint_pgridpos_{}".format(i) :
349
- plp.LpConstraint(
350
- e = P_grid_pos[i] - self.plant_conf['P_from_grid_max']*D[i],
351
- sense = plp.LpConstraintLE,
352
- rhs = 0)
353
- for i in set_I})
354
- constraints.update({"constraint_pgridneg_{}".format(i) :
355
- plp.LpConstraint(
356
- e = -P_grid_neg[i] - self.plant_conf['P_to_grid_max']*(1-D[i]),
357
- sense = plp.LpConstraintLE,
358
- rhs = 0)
359
- for i in set_I})
774
+ constraints.update(
775
+ {
776
+ f"constraint_pgridpos_{i}": plp.LpConstraint(
777
+ e=p_grid_pos[i] - self.plant_conf["maximum_power_from_grid"] * D[i],
778
+ sense=plp.LpConstraintLE,
779
+ rhs=0,
780
+ )
781
+ for i in set_i
782
+ }
783
+ )
784
+ constraints.update(
785
+ {
786
+ f"constraint_pgridneg_{i}": plp.LpConstraint(
787
+ e=-p_grid_neg[i] - self.plant_conf["maximum_power_to_grid"] * (1 - D[i]),
788
+ sense=plp.LpConstraintLE,
789
+ rhs=0,
790
+ )
791
+ for i in set_i
792
+ }
793
+ )
360
794
 
361
795
  # Treat deferrable loads constraints
362
796
  predicted_temps = {}
363
- for k in range(self.optim_conf['num_def_loads']):
364
-
365
- if type(self.optim_conf['P_deferrable_nom'][k]) == list:
797
+ heating_demands = {} # Store heating demand for thermal loads
798
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
799
+ self.logger.debug(f"Processing deferrable load {k}")
800
+ if isinstance(self.optim_conf["nominal_power_of_deferrable_loads"][k], list):
801
+ self.logger.debug(
802
+ f"Load {k} is sequence-based. Sequence: {self.optim_conf['nominal_power_of_deferrable_loads'][k]}"
803
+ )
366
804
  # Constraint for sequence of deferrable
367
805
  # WARNING: This is experimental, formulation seems correct but feasibility problems.
368
806
  # Probably uncomptabile with other constraints
369
- power_sequence = self.optim_conf['P_deferrable_nom'][k]
807
+ power_sequence = self.optim_conf["nominal_power_of_deferrable_loads"][k]
370
808
  sequence_length = len(power_sequence)
809
+
371
810
  def create_matrix(input_list, n):
372
811
  matrix = []
373
812
  for i in range(n + 1):
374
813
  row = [0] * i + input_list + [0] * (n - i)
375
- matrix.append(row[:n*2])
814
+ matrix.append(row[: n * 2])
376
815
  return matrix
377
- matrix = create_matrix(power_sequence, n-sequence_length)
378
- y = plp.LpVariable.dicts(f"y{k}", (i for i in range(len(matrix))), cat='Binary')
379
- constraints.update({f"single_value_constraint_{k}" :
380
- plp.LpConstraint(
381
- e = plp.lpSum(y[i] for i in range(len(matrix))) - 1,
382
- sense = plp.LpConstraintEQ,
383
- rhs = 0)
384
- })
385
- constraints.update({f"pdef{k}_sumconstraint_{i}" :
386
- plp.LpConstraint(
387
- e = plp.lpSum(P_deferrable[k][i] for i in set_I) - np.sum(power_sequence),
388
- sense = plp.LpConstraintEQ,
389
- rhs = 0)
390
- })
391
- constraints.update({f"pdef{k}_positive_constraint_{i}" :
392
- plp.LpConstraint(
393
- e = P_deferrable[k][i],
394
- sense = plp.LpConstraintGE,
395
- rhs = 0)
396
- for i in set_I})
816
+
817
+ matrix = create_matrix(power_sequence, n - sequence_length)
818
+ y = plp.LpVariable.dicts(f"y{k}", range(len(matrix)), cat="Binary")
819
+ self.logger.debug(
820
+ f"Load {k}: Created binary variables for sequence placement: y = {list(y.keys())}"
821
+ )
822
+ constraints.update(
823
+ {
824
+ f"single_value_constraint_{k}": plp.LpConstraint(
825
+ e=plp.lpSum(y[i] for i in range(len(matrix))) - 1,
826
+ sense=plp.LpConstraintEQ,
827
+ rhs=0,
828
+ )
829
+ }
830
+ )
831
+ constraints.update(
832
+ {
833
+ f"pdef{k}_sumconstraint_{i}": plp.LpConstraint(
834
+ e=plp.lpSum(p_deferrable[k][i] for i in set_i) - np.sum(power_sequence),
835
+ sense=plp.LpConstraintEQ,
836
+ rhs=0,
837
+ )
838
+ }
839
+ )
840
+ constraints.update(
841
+ {
842
+ f"pdef{k}_positive_constraint_{i}": plp.LpConstraint(
843
+ e=p_deferrable[k][i], sense=plp.LpConstraintGE, rhs=0
844
+ )
845
+ for i in set_i
846
+ }
847
+ )
397
848
  for num, mat in enumerate(matrix):
398
- constraints.update({f"pdef{k}_value_constraint_{num}_{i}" :
399
- plp.LpConstraint(
400
- e = P_deferrable[k][i] - mat[i]*y[num],
401
- sense = plp.LpConstraintEQ,
402
- rhs = 0)
403
- for i in set_I})
404
-
405
- elif "def_load_config" in self.optim_conf.keys():
406
- if "thermal_config" in self.optim_conf["def_load_config"][k]:
407
- # Special case of a thermal deferrable load
408
- def_load_config = self.optim_conf['def_load_config'][k]
409
- if def_load_config and 'thermal_config' in def_load_config:
410
- hc = def_load_config["thermal_config"]
849
+ constraints.update(
850
+ {
851
+ f"pdef{k}_value_constraint_{num}_{i}": plp.LpConstraint(
852
+ e=p_deferrable[k][i] - mat[i] * y[num],
853
+ sense=plp.LpConstraintEQ,
854
+ rhs=0,
855
+ )
856
+ for i in set_i
857
+ }
858
+ )
859
+ self.logger.debug(f"Load {k}: Sequence-based constraints set.")
860
+
861
+ # Thermal deferrable load logic first
862
+ elif (
863
+ "def_load_config" in self.optim_conf.keys()
864
+ and len(self.optim_conf["def_load_config"]) > k
865
+ and "thermal_config" in self.optim_conf["def_load_config"][k]
866
+ ):
867
+ self.logger.debug(f"Load {k} is a thermal deferrable load.")
868
+ def_load_config = self.optim_conf["def_load_config"][k]
869
+ if def_load_config and "thermal_config" in def_load_config:
870
+ hc = def_load_config["thermal_config"]
871
+
872
+ # Use passed runtime value (def_init_temp) if available, else config
873
+ if def_init_temp[k] is not None:
874
+ start_temperature = def_init_temp[k]
875
+ else:
411
876
  start_temperature = hc["start_temperature"]
412
- cooling_constant = hc["cooling_constant"]
413
- heating_rate = hc["heating_rate"]
414
- overshoot_temperature = hc["overshoot_temperature"]
415
- outdoor_temperature_forecast = data_opt['outdoor_temperature_forecast']
416
- desired_temperatures = hc["desired_temperatures"]
417
- sense = hc.get('sense', 'heat')
418
- predicted_temp = [start_temperature]
419
- for I in set_I:
420
- if I == 0:
421
- continue
422
- predicted_temp.append(
423
- predicted_temp[I-1]
424
- + (P_deferrable[k][I-1] * (heating_rate * self.timeStep / self.optim_conf['P_deferrable_nom'][k]))
425
- - (cooling_constant * (predicted_temp[I-1] - outdoor_temperature_forecast[I-1])))
426
- if len(desired_temperatures) > I and desired_temperatures[I]:
427
- constraints.update({"constraint_defload{}_temperature_{}".format(k, I):
428
- plp.LpConstraint(
429
- e = predicted_temp[I],
430
- sense = plp.LpConstraintGE if sense == 'heat' else plp.LpConstraintLE,
431
- rhs = desired_temperatures[I],
877
+
878
+ cooling_constant = hc["cooling_constant"]
879
+ heating_rate = hc["heating_rate"]
880
+ overshoot_temperature = hc.get("overshoot_temperature", None)
881
+ outdoor_temperature_forecast = data_opt["outdoor_temperature_forecast"]
882
+ # Support for both single desired temperature (legacy) and min/max range (new)
883
+ desired_temperatures = hc.get("desired_temperatures", [])
884
+ min_temperatures = hc.get("min_temperatures", [])
885
+ max_temperatures = hc.get("max_temperatures", [])
886
+ sense = hc.get("sense", "heat")
887
+ sense_coeff = 1 if sense == "heat" else -1
888
+
889
+ self.logger.debug(
890
+ "Load %s: Thermal parameters: start_temperature=%s, cooling_constant=%s, heating_rate=%s",
891
+ k,
892
+ start_temperature,
893
+ cooling_constant,
894
+ heating_rate,
895
+ )
896
+
897
+ predicted_temp = [start_temperature]
898
+ for index in set_i:
899
+ if index == 0:
900
+ continue
901
+
902
+ predicted_temp.append(
903
+ predicted_temp[index - 1]
904
+ + (
905
+ p_deferrable[k][index - 1]
906
+ * (
907
+ heating_rate
908
+ * self.time_step
909
+ / self.optim_conf["nominal_power_of_deferrable_loads"][k]
910
+ )
911
+ )
912
+ - (
913
+ cooling_constant
914
+ * self.time_step
915
+ * (
916
+ predicted_temp[index - 1]
917
+ - outdoor_temperature_forecast.iloc[index - 1]
918
+ )
919
+ )
920
+ )
921
+
922
+ # Constraint Logic: Comfort Range (Min/Max)
923
+ # If min/max temps are provided, we enforce them.
924
+ # This avoids the "penalty" method and ensures feasibility within a range.
925
+ if len(min_temperatures) > index and min_temperatures[index] is not None:
926
+ constraints.update(
927
+ {
928
+ f"constraint_defload{k}_min_temp_{index}": plp.LpConstraint(
929
+ e=predicted_temp[index],
930
+ sense=plp.LpConstraintGE,
931
+ rhs=min_temperatures[index],
432
932
  )
433
- })
434
- constraints.update({"constraint_defload{}_overshoot_temp_{}".format(k, I):
435
- plp.LpConstraint(
436
- e = predicted_temp[I],
437
- sense = plp.LpConstraintLE if sense == 'heat' else plp.LpConstraintGE,
438
- rhs = overshoot_temperature,
933
+ }
439
934
  )
440
- for I in set_I})
441
- predicted_temps[k] = predicted_temp
442
-
443
- else:
444
-
445
- if def_total_hours[k] > 0:
446
- # Total time of deferrable load
447
- constraints.update({"constraint_defload{}_energy".format(k) :
448
- plp.LpConstraint(
449
- e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in set_I),
450
- sense = plp.LpConstraintEQ,
451
- rhs = def_total_hours[k]*self.optim_conf['P_deferrable_nom'][k])
452
- })
453
-
935
+
936
+ if len(max_temperatures) > index and max_temperatures[index] is not None:
937
+ constraints.update(
938
+ {
939
+ f"constraint_defload{k}_max_temp_{index}": plp.LpConstraint(
940
+ e=predicted_temp[index],
941
+ sense=plp.LpConstraintLE,
942
+ rhs=max_temperatures[index],
943
+ )
944
+ }
945
+ )
946
+
947
+ # Legacy "Overshoot" logic (Keep for backward compatibility)
948
+ # Only added if desired_temperatures is present AND overshoot_temperature is defined
949
+ if desired_temperatures and overshoot_temperature is not None:
950
+ is_overshoot = plp.LpVariable(f"defload_{k}_overshoot_{index}")
951
+ constraints.update(
952
+ {
953
+ f"constraint_defload{k}_overshoot_{index}_1": plp.LpConstraint(
954
+ e=predicted_temp[index]
955
+ - overshoot_temperature
956
+ - (100 * sense_coeff * is_overshoot),
957
+ sense=plp.LpConstraintLE
958
+ if sense == "heat"
959
+ else plp.LpConstraintGE,
960
+ rhs=0,
961
+ ),
962
+ f"constraint_defload{k}_overshoot_{index}_2": plp.LpConstraint(
963
+ e=predicted_temp[index]
964
+ - overshoot_temperature
965
+ + (100 * sense_coeff * (1 - is_overshoot)),
966
+ sense=plp.LpConstraintGE
967
+ if sense == "heat"
968
+ else plp.LpConstraintLE,
969
+ rhs=0,
970
+ ),
971
+ f"constraint_defload{k}_overshoot_temp_{index}": plp.LpConstraint(
972
+ e=is_overshoot + p_def_bin2[k][index - 1],
973
+ sense=plp.LpConstraintLE,
974
+ rhs=1,
975
+ ),
976
+ }
977
+ )
978
+
979
+ if len(desired_temperatures) > index and desired_temperatures[index]:
980
+ penalty_factor = hc.get("penalty_factor", 10)
981
+ if penalty_factor < 0:
982
+ raise ValueError(
983
+ "penalty_factor must be positive, otherwise the problem will become unsolvable"
984
+ )
985
+ penalty_value = (
986
+ (predicted_temp[index] - desired_temperatures[index])
987
+ * penalty_factor
988
+ * sense_coeff
989
+ )
990
+ penalty_var = plp.LpVariable(
991
+ f"defload_{k}_thermal_penalty_{index}",
992
+ cat="Continuous",
993
+ upBound=0,
994
+ )
995
+ constraints.update(
996
+ {
997
+ f"constraint_defload{k}_penalty_{index}": plp.LpConstraint(
998
+ e=penalty_var - penalty_value,
999
+ sense=plp.LpConstraintLE,
1000
+ rhs=0,
1001
+ )
1002
+ }
1003
+ )
1004
+ opt_model.setObjective(opt_model.objective + penalty_var)
1005
+
1006
+ # Only enforce semi-continuous if configured to do so
1007
+ if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
1008
+ constraints.update(
1009
+ {
1010
+ f"constraint_thermal_semicont_{k}_{i}": plp.LpConstraint(
1011
+ e=p_deferrable[k][i]
1012
+ - (
1013
+ p_def_bin2[k][i]
1014
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k]
1015
+ ),
1016
+ sense=plp.LpConstraintEQ,
1017
+ rhs=0,
1018
+ )
1019
+ for i in set_i
1020
+ }
1021
+ )
1022
+
1023
+ predicted_temps[k] = predicted_temp
1024
+ self.logger.debug(f"Load {k}: Thermal constraints set.")
1025
+
1026
+ # Thermal battery load logic
1027
+ elif (
1028
+ "def_load_config" in self.optim_conf.keys()
1029
+ and len(self.optim_conf["def_load_config"]) > k
1030
+ and "thermal_battery" in self.optim_conf["def_load_config"][k]
1031
+ ):
1032
+ self.logger.debug(f"Load {k} is a thermal battery load.")
1033
+ def_load_config = self.optim_conf["def_load_config"][k]
1034
+ if def_load_config and "thermal_battery" in def_load_config:
1035
+ hc = def_load_config["thermal_battery"]
1036
+
1037
+ start_temperature = hc["start_temperature"]
1038
+
1039
+ supply_temperature = hc["supply_temperature"] # heatpump supply temperature °C
1040
+ volume = hc["volume"] # volume of the thermal battery m3
1041
+ outdoor_temperature_forecast = data_opt["outdoor_temperature_forecast"]
1042
+
1043
+ min_temperatures = hc[
1044
+ "min_temperatures"
1045
+ ] # list of lower bounds per timestep °C
1046
+ max_temperatures = hc[
1047
+ "max_temperatures"
1048
+ ] # list of upper bounds per timestep °C
1049
+
1050
+ # Validate that temperature lists are not empty
1051
+ if not min_temperatures:
1052
+ raise ValueError(
1053
+ f"Load {k}: thermal_battery requires non-empty 'min_temperatures' list"
1054
+ )
1055
+ if not max_temperatures:
1056
+ raise ValueError(
1057
+ f"Load {k}: thermal_battery requires non-empty 'max_temperatures' list"
1058
+ )
1059
+
1060
+ p_concr = 2400 # Density of concrete kg/m3
1061
+ c_concr = 0.88 # Heat capacity of concrete kJ/kg*K
1062
+ loss = 0.045 # Temperature loss per time period (+/-) kW
1063
+ conversion = 3600 / (p_concr * c_concr * volume) # °C per kWh
1064
+
1065
+ self.logger.debug(
1066
+ "Load %s: Thermal battery parameters: start_temperature=%s, supply_temperature=%s, volume=%s, min_temperatures=%s, max_temperatures=%s",
1067
+ k,
1068
+ start_temperature,
1069
+ supply_temperature,
1070
+ volume,
1071
+ min_temperatures[0],
1072
+ max_temperatures[0],
1073
+ )
1074
+
1075
+ heatpump_cops = utils.calculate_cop_heatpump(
1076
+ supply_temperature=supply_temperature,
1077
+ carnot_efficiency=hc.get("carnot_efficiency", 0.4),
1078
+ outdoor_temperature_forecast=outdoor_temperature_forecast,
1079
+ )
1080
+ thermal_losses = utils.calculate_thermal_loss_signed(
1081
+ outdoor_temperature_forecast=outdoor_temperature_forecast,
1082
+ indoor_temperature=start_temperature,
1083
+ base_loss=loss,
1084
+ )
1085
+
1086
+ # Auto-detect heating demand calculation method
1087
+ # Use physics-based if core parameters are provided
1088
+ if all(
1089
+ key in hc
1090
+ for key in [
1091
+ "u_value",
1092
+ "envelope_area",
1093
+ "ventilation_rate",
1094
+ "heated_volume",
1095
+ ]
1096
+ ):
1097
+ # Physics-based method (more accurate)
1098
+ # Default indoor_target_temperature to the first min_temperature if not specified
1099
+ # This represents maintaining the lower comfort bound
1100
+ indoor_target_temp = hc.get(
1101
+ "indoor_target_temperature",
1102
+ min_temperatures[0] if min_temperatures else 20.0,
1103
+ )
1104
+
1105
+ # Extract optional solar gain parameters
1106
+ window_area = hc.get("window_area", None)
1107
+ shgc = hc.get("shgc", 0.6) # Default SHGC for modern double-glazed windows
1108
+
1109
+ # Check if GHI (Global Horizontal Irradiance) data is available
1110
+ solar_irradiance = None
1111
+ if "ghi" in data_opt.columns and window_area is not None:
1112
+ solar_irradiance = data_opt["ghi"]
1113
+
1114
+ heating_demand = utils.calculate_heating_demand_physics(
1115
+ u_value=hc["u_value"],
1116
+ envelope_area=hc["envelope_area"],
1117
+ ventilation_rate=hc["ventilation_rate"],
1118
+ heated_volume=hc["heated_volume"],
1119
+ indoor_target_temperature=indoor_target_temp,
1120
+ outdoor_temperature_forecast=outdoor_temperature_forecast,
1121
+ optimization_time_step=int(self.freq.total_seconds() / 60),
1122
+ solar_irradiance_forecast=solar_irradiance,
1123
+ window_area=window_area,
1124
+ shgc=shgc,
1125
+ )
1126
+
1127
+ # Log with solar gains info if applicable
1128
+ if solar_irradiance is not None:
1129
+ self.logger.debug(
1130
+ "Load %s: Using physics-based heating demand with solar gains "
1131
+ "(u_value=%.2f, envelope_area=%.1f, ventilation_rate=%.2f, heated_volume=%.1f, "
1132
+ "indoor_target_temp=%.1f%s, window_area=%.1f, shgc=%.2f)",
1133
+ k,
1134
+ hc["u_value"],
1135
+ hc["envelope_area"],
1136
+ hc["ventilation_rate"],
1137
+ hc["heated_volume"],
1138
+ indoor_target_temp,
1139
+ " (defaulted to min_temp)"
1140
+ if "indoor_target_temperature" not in hc
1141
+ else "",
1142
+ window_area,
1143
+ shgc,
1144
+ )
1145
+ else:
1146
+ self.logger.debug(
1147
+ "Load %s: Using physics-based heating demand calculation "
1148
+ "(u_value=%.2f, envelope_area=%.1f, ventilation_rate=%.2f, heated_volume=%.1f, "
1149
+ "indoor_target_temp=%.1f%s)",
1150
+ k,
1151
+ hc["u_value"],
1152
+ hc["envelope_area"],
1153
+ hc["ventilation_rate"],
1154
+ hc["heated_volume"],
1155
+ indoor_target_temp,
1156
+ " (defaulted to min_temp)"
1157
+ if "indoor_target_temperature" not in hc
1158
+ else "",
1159
+ )
1160
+ else:
1161
+ # HDD method (backward compatible) with configurable parameters
1162
+ base_temperature = hc.get("base_temperature", 18.0)
1163
+ annual_reference_hdd = hc.get("annual_reference_hdd", 3000.0)
1164
+ heating_demand = utils.calculate_heating_demand(
1165
+ specific_heating_demand=hc["specific_heating_demand"],
1166
+ floor_area=hc["area"],
1167
+ outdoor_temperature_forecast=outdoor_temperature_forecast,
1168
+ base_temperature=base_temperature,
1169
+ annual_reference_hdd=annual_reference_hdd,
1170
+ optimization_time_step=int(self.freq.total_seconds() / 60),
1171
+ )
1172
+ self.logger.debug(
1173
+ "Load %s: Using HDD heating demand calculation (specific_heating_demand=%.1f, area=%.1f, base_temperature=%.1f, annual_reference_hdd=%.1f)",
1174
+ k,
1175
+ hc["specific_heating_demand"],
1176
+ hc["area"],
1177
+ base_temperature,
1178
+ annual_reference_hdd,
1179
+ )
1180
+
1181
+ # Thermal battery state transition (Langer & Volling 2020, Eq B.11-B.15)
1182
+ # Store heating demand for this thermal battery load
1183
+ heating_demands[k] = heating_demand
1184
+
1185
+ predicted_temp_thermal = [start_temperature]
1186
+ for index in set_i:
1187
+ if index == 0:
1188
+ continue
1189
+
1190
+ # Equation B.11: T(h+1) = T(h) + conv * (COP*P - demand - loss)
1191
+ # where P is in kW, but emhass variables are in W, so divide by 1000
1192
+ predicted_temp_thermal.append(
1193
+ predicted_temp_thermal[index - 1]
1194
+ + conversion
1195
+ * (
1196
+ heatpump_cops[index - 1]
1197
+ * p_deferrable[k][index - 1]
1198
+ / 1000
1199
+ * self.time_step
1200
+ - heating_demand[index - 1]
1201
+ - thermal_losses[index - 1]
1202
+ )
1203
+ )
1204
+
1205
+ # Equation B.15: Comfort range constraints
1206
+ # Support per-timestep temperature bounds (aligned with thermal_config)
1207
+ if len(min_temperatures) > index and min_temperatures[index] is not None:
1208
+ constraints.update(
1209
+ {
1210
+ f"constraint_thermal_battery{k}_min_temp_{index}": plp.LpConstraint(
1211
+ e=predicted_temp_thermal[index],
1212
+ sense=plp.LpConstraintGE,
1213
+ rhs=min_temperatures[index],
1214
+ )
1215
+ }
1216
+ )
1217
+
1218
+ if len(max_temperatures) > index and max_temperatures[index] is not None:
1219
+ constraints.update(
1220
+ {
1221
+ f"constraint_thermal_battery{k}_max_temp_{index}": plp.LpConstraint(
1222
+ e=predicted_temp_thermal[index],
1223
+ sense=plp.LpConstraintLE,
1224
+ rhs=max_temperatures[index],
1225
+ )
1226
+ }
1227
+ )
1228
+
1229
+ predicted_temps[k] = predicted_temp_thermal
1230
+ self.logger.debug(f"Load {k}: Thermal battery constraints set.")
1231
+
1232
+ # Standard/non-thermal deferrable load logic comes after thermal
1233
+ elif (def_total_timestep and def_total_timestep[k] > 0) or (
1234
+ len(def_total_hours) > k and def_total_hours[k] > 0
1235
+ ):
1236
+ self.logger.debug(f"Load {k} is standard/non-thermal.")
1237
+ if def_total_timestep and def_total_timestep[k] > 0:
1238
+ self.logger.debug(
1239
+ f"Load {k}: Using total timesteps constraint: {def_total_timestep[k]}"
1240
+ )
1241
+ constraints.update(
1242
+ {
1243
+ f"constraint_defload{k}_energy": plp.LpConstraint(
1244
+ e=plp.lpSum(p_deferrable[k][i] * self.time_step for i in set_i),
1245
+ sense=plp.LpConstraintEQ,
1246
+ rhs=(self.time_step * def_total_timestep[k])
1247
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k],
1248
+ )
1249
+ }
1250
+ )
1251
+ else:
1252
+ self.logger.debug(
1253
+ f"Load {k}: Using total hours constraint: {def_total_hours[k]}"
1254
+ )
1255
+ constraints.update(
1256
+ {
1257
+ f"constraint_defload{k}_energy": plp.LpConstraint(
1258
+ e=plp.lpSum(p_deferrable[k][i] * self.time_step for i in set_i),
1259
+ sense=plp.LpConstraintEQ,
1260
+ rhs=def_total_hours[k]
1261
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k],
1262
+ )
1263
+ }
1264
+ )
1265
+ self.logger.debug(f"Load {k}: Standard load constraints set.")
1266
+
454
1267
  # Ensure deferrable loads consume energy between def_start_timestep & def_end_timestep
455
- self.logger.debug("Deferrable load {}: Proposed optimization window: {} --> {}".format(
456
- k, def_start_timestep[k], def_end_timestep[k]))
457
- def_start, def_end, warning = Optimization.validate_def_timewindow(
458
- def_start_timestep[k], def_end_timestep[k], ceil(def_total_hours[k]/self.timeStep), n)
459
- if warning is not None:
460
- self.logger.warning("Deferrable load {} : {}".format(k, warning))
461
- self.logger.debug("Deferrable load {}: Validated optimization window: {} --> {}".format(
462
- k, def_start, def_end))
463
- if def_start > 0:
464
- constraints.update({"constraint_defload{}_start_timestep".format(k) :
465
- plp.LpConstraint(
466
- e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in range(0, def_start)),
467
- sense = plp.LpConstraintEQ,
468
- rhs = 0)
469
- })
470
- if def_end > 0:
471
- constraints.update({"constraint_defload{}_end_timestep".format(k) :
472
- plp.LpConstraint(
473
- e = plp.lpSum(P_deferrable[k][i]*self.timeStep for i in range(def_end, n)),
474
- sense = plp.LpConstraintEQ,
475
- rhs = 0)
476
- })
477
-
1268
+ self.logger.debug(
1269
+ f"Deferrable load {k}: Proposed optimization window: {def_start_timestep[k]} --> {def_end_timestep[k]}"
1270
+ )
1271
+ if def_total_timestep and def_total_timestep[k] > 0:
1272
+ def_start, def_end, warning = Optimization.validate_def_timewindow(
1273
+ def_start_timestep[k],
1274
+ def_end_timestep[k],
1275
+ ceil(def_total_timestep[k]),
1276
+ n,
1277
+ )
1278
+ else:
1279
+ def_start, def_end, warning = Optimization.validate_def_timewindow(
1280
+ def_start_timestep[k],
1281
+ def_end_timestep[k],
1282
+ ceil(def_total_hours[k] / self.time_step),
1283
+ n,
1284
+ )
1285
+ if warning is not None:
1286
+ self.logger.warning(f"Deferrable load {k} : {warning}")
1287
+ self.logger.debug(
1288
+ f"Deferrable load {k}: Validated optimization window: {def_start} --> {def_end}"
1289
+ )
1290
+ if def_start > 0:
1291
+ constraints.update(
1292
+ {
1293
+ f"constraint_defload{k}_start_timestep": plp.LpConstraint(
1294
+ e=plp.lpSum(
1295
+ p_deferrable[k][i] * self.time_step for i in range(0, def_start)
1296
+ ),
1297
+ sense=plp.LpConstraintEQ,
1298
+ rhs=0,
1299
+ )
1300
+ }
1301
+ )
1302
+ if def_end > 0:
1303
+ constraints.update(
1304
+ {
1305
+ f"constraint_defload{k}_end_timestep": plp.LpConstraint(
1306
+ e=plp.lpSum(
1307
+ p_deferrable[k][i] * self.time_step for i in range(def_end, n)
1308
+ ),
1309
+ sense=plp.LpConstraintEQ,
1310
+ rhs=0,
1311
+ )
1312
+ }
1313
+ )
1314
+
1315
+ # Constraint for the minimum power of deferrable loads using the big-M method.
1316
+ # This enforces: p_deferrable = 0 OR p_deferrable >= min_power.
1317
+ if min_power_of_deferrable_loads[k] > 0:
1318
+ self.logger.debug(
1319
+ f"Applying minimum power constraint for deferrable load {k}: {min_power_of_deferrable_loads[k]} W"
1320
+ )
1321
+ constraints.update(
1322
+ {
1323
+ f"constraint_pdef{k}_min_power_{i}": plp.LpConstraint(
1324
+ e=p_deferrable[k][i]
1325
+ - (min_power_of_deferrable_loads[k] * p_def_bin2[k][i]),
1326
+ sense=plp.LpConstraintGE,
1327
+ rhs=0,
1328
+ )
1329
+ for i in set_i
1330
+ }
1331
+ )
1332
+
478
1333
  # Treat the number of starts for a deferrable load (new method considering current state)
479
1334
  current_state = 0
480
- if ("def_current_state" in self.optim_conf and len(self.optim_conf["def_current_state"]) > k):
1335
+ if (
1336
+ "def_current_state" in self.optim_conf
1337
+ and len(self.optim_conf["def_current_state"]) > k
1338
+ ):
481
1339
  current_state = 1 if self.optim_conf["def_current_state"][k] else 0
482
- # P_deferrable < P_def_bin2 * 1 million
483
- # P_deferrable must be zero if P_def_bin2 is zero
484
- constraints.update({"constraint_pdef{}_start1_{}".format(k, i):
485
- plp.LpConstraint(
486
- e=P_deferrable[k][i] - P_def_bin2[k][i] * M,
487
- sense=plp.LpConstraintLE,
488
- rhs=0)
489
- for i in set_I})
490
- # P_deferrable - P_def_bin2 <= 0
491
- # P_def_bin2 must be zero if P_deferrable is zero
492
- constraints.update({"constraint_pdef{}_start1a_{}".format(k, i):
493
- plp.LpConstraint(
494
- e=P_def_bin2[k][i] - P_deferrable[k][i],
495
- sense=plp.LpConstraintLE,
496
- rhs=0)
497
- for i in set_I})
498
- # P_def_start + P_def_bin2[i-1] >= P_def_bin2[i]
499
- # If load is on this cycle (P_def_bin2[i] is 1) then P_def_start must be 1 OR P_def_bin2[i-1] must be 1
1340
+ # p_deferrable < p_def_bin2 * 1 million
1341
+ # p_deferrable must be zero if p_def_bin2 is zero
1342
+ constraints.update(
1343
+ {
1344
+ f"constraint_pdef{k}_start1_{i}": plp.LpConstraint(
1345
+ e=p_deferrable[k][i] - p_def_bin2[k][i] * M,
1346
+ sense=plp.LpConstraintLE,
1347
+ rhs=0,
1348
+ )
1349
+ for i in set_i
1350
+ }
1351
+ )
1352
+ # p_deferrable - p_def_bin2 <= 0
1353
+ # p_def_bin2 must be zero if p_deferrable is zero
1354
+ constraints.update(
1355
+ {
1356
+ f"constraint_pdef{k}_start1a_{i}": plp.LpConstraint(
1357
+ e=p_def_bin2[k][i] - p_deferrable[k][i],
1358
+ sense=plp.LpConstraintLE,
1359
+ rhs=0,
1360
+ )
1361
+ for i in set_i
1362
+ }
1363
+ )
1364
+ # p_def_start + p_def_bin2[i-1] >= p_def_bin2[i]
1365
+ # If load is on this cycle (p_def_bin2[i] is 1) then p_def_start must be 1 OR p_def_bin2[i-1] must be 1
500
1366
  # For first timestep, use current state if provided by caller.
501
- constraints.update({"constraint_pdef{}_start2_{}".format(k, i):
502
- plp.LpConstraint(
503
- e=P_def_start[k][i]
504
- - P_def_bin2[k][i]
505
- + (P_def_bin2[k][i - 1] if i - 1 >= 0 else current_state),
506
- sense=plp.LpConstraintGE,
507
- rhs=0)
508
- for i in set_I})
509
- # P_def_bin2[i-1] + P_def_start <= 1
510
- # If load started this cycle (P_def_start[i] is 1) then P_def_bin2[i-1] must be 0
511
- constraints.update({"constraint_pdef{}_start3_{}".format(k, i):
512
- plp.LpConstraint(
513
- e=(P_def_bin2[k][i-1] if i-1 >= 0 else 0) + P_def_start[k][i],
514
- sense=plp.LpConstraintLE,
515
- rhs=1)
516
- for i in set_I})
517
-
518
- # Treat deferrable as a fixed value variable with just one startup
519
- if self.optim_conf['set_def_constant'][k]:
520
- # P_def_start[i] must be 1 for exactly 1 value of i
521
- constraints.update({"constraint_pdef{}_start4".format(k) :
522
- plp.LpConstraint(
523
- e = plp.lpSum(P_def_start[k][i] for i in set_I),
524
- sense = plp.LpConstraintEQ,
525
- rhs = 1)
526
- })
527
- # P_def_bin2 must be 1 for exactly the correct number of timesteps.
528
- constraints.update({"constraint_pdef{}_start5".format(k) :
529
- plp.LpConstraint(
530
- e = plp.lpSum(P_def_bin2[k][i] for i in set_I),
531
- sense = plp.LpConstraintEQ,
532
- rhs = def_total_hours[k]/self.timeStep)
533
- })
534
-
535
- # Treat deferrable load as a semi-continuous variable
536
- if self.optim_conf['treat_def_as_semi_cont'][k]:
537
- constraints.update({"constraint_pdef{}_semicont1_{}".format(k, i) :
538
- plp.LpConstraint(
539
- e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i],
1367
+ constraints.update(
1368
+ {
1369
+ f"constraint_pdef{k}_start2_{i}": plp.LpConstraint(
1370
+ e=p_def_start[k][i]
1371
+ - p_def_bin2[k][i]
1372
+ + (p_def_bin2[k][i - 1] if i - 1 >= 0 else current_state),
540
1373
  sense=plp.LpConstraintGE,
541
- rhs=0)
542
- for i in set_I})
543
- constraints.update({"constraint_pdef{}_semicont2_{}".format(k, i) :
544
- plp.LpConstraint(
545
- e=P_deferrable[k][i] - self.optim_conf['P_deferrable_nom'][k]*P_def_bin1[k][i],
1374
+ rhs=0,
1375
+ )
1376
+ for i in set_i
1377
+ }
1378
+ )
1379
+ # p_def_bin2[i-1] + p_def_start <= 1
1380
+ # If load started this cycle (p_def_start[i] is 1) then p_def_bin2[i-1] must be 0
1381
+ constraints.update(
1382
+ {
1383
+ f"constraint_pdef{k}_start3_{i}": plp.LpConstraint(
1384
+ e=(p_def_bin2[k][i - 1] if i - 1 >= 0 else 0) + p_def_start[k][i],
546
1385
  sense=plp.LpConstraintLE,
547
- rhs=0)
548
- for i in set_I})
549
-
550
-
551
- # Treat the number of starts for a deferrable load (old method, kept here just in case)
552
- # if self.optim_conf['set_def_constant'][k]:
553
- # constraints.update({"constraint_pdef{}_start1_{}".format(k, i) :
554
- # plp.LpConstraint(
555
- # e=P_deferrable[k][i] - P_def_bin2[k][i]*M,
556
- # sense=plp.LpConstraintLE,
557
- # rhs=0)
558
- # for i in set_I})
559
- # constraints.update({"constraint_pdef{}_start2_{}".format(k, i):
560
- # plp.LpConstraint(
561
- # e=P_def_start[k][i] - P_def_bin2[k][i] + (P_def_bin2[k][i-1] if i-1 >= 0 else 0),
562
- # sense=plp.LpConstraintGE,
563
- # rhs=0)
564
- # for i in set_I})
565
- # constraints.update({"constraint_pdef{}_start3".format(k) :
566
- # plp.LpConstraint(
567
- # e = plp.lpSum(P_def_start[k][i] for i in set_I),
568
- # sense = plp.LpConstraintEQ,
569
- # rhs = 1)
570
- # })
1386
+ rhs=1,
1387
+ )
1388
+ for i in set_i
1389
+ }
1390
+ )
1391
+
1392
+ # Treat deferrable as a fixed value variable with just one startup
1393
+ if self.optim_conf["set_deferrable_load_single_constant"][k]:
1394
+ # p_def_start[i] must be 1 for exactly 1 value of i
1395
+ constraints.update(
1396
+ {
1397
+ f"constraint_pdef{k}_start4": plp.LpConstraint(
1398
+ e=plp.lpSum(p_def_start[k][i] for i in set_i),
1399
+ sense=plp.LpConstraintEQ,
1400
+ rhs=1,
1401
+ )
1402
+ }
1403
+ )
1404
+ # p_def_bin2 must be 1 for exactly the correct number of timesteps.
1405
+ if def_total_timestep and def_total_timestep[k] > 0:
1406
+ constraints.update(
1407
+ {
1408
+ f"constraint_pdef{k}_start5": plp.LpConstraint(
1409
+ e=plp.lpSum(p_def_bin2[k][i] for i in set_i),
1410
+ sense=plp.LpConstraintEQ,
1411
+ rhs=def_total_timestep[k],
1412
+ )
1413
+ }
1414
+ )
1415
+ else:
1416
+ constraints.update(
1417
+ {
1418
+ f"constraint_pdef{k}_start5": plp.LpConstraint(
1419
+ e=plp.lpSum(p_def_bin2[k][i] for i in set_i),
1420
+ sense=plp.LpConstraintEQ,
1421
+ rhs=def_total_hours[k] / self.time_step,
1422
+ )
1423
+ }
1424
+ )
1425
+
1426
+ # Treat deferrable load as a semi-continuous variable
1427
+ if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
1428
+ constraints.update(
1429
+ {
1430
+ f"constraint_pdef{k}_semicont1_{i}": plp.LpConstraint(
1431
+ e=p_deferrable[k][i]
1432
+ - self.optim_conf["nominal_power_of_deferrable_loads"][k]
1433
+ * p_def_bin1[k][i],
1434
+ sense=plp.LpConstraintGE,
1435
+ rhs=0,
1436
+ )
1437
+ for i in set_i
1438
+ }
1439
+ )
1440
+ constraints.update(
1441
+ {
1442
+ f"constraint_pdef{k}_semicont2_{i}": plp.LpConstraint(
1443
+ e=p_deferrable[k][i]
1444
+ - self.optim_conf["nominal_power_of_deferrable_loads"][k]
1445
+ * p_def_bin1[k][i],
1446
+ sense=plp.LpConstraintLE,
1447
+ rhs=0,
1448
+ )
1449
+ for i in set_i
1450
+ }
1451
+ )
571
1452
 
572
1453
  # The battery constraints
573
- if self.optim_conf['set_use_battery']:
1454
+ if self.optim_conf["set_use_battery"]:
574
1455
  # Optional constraints to avoid charging the battery from the grid
575
- if self.optim_conf['set_nocharge_from_grid']:
576
- constraints.update({"constraint_nocharge_from_grid_{}".format(i) :
577
- plp.LpConstraint(
578
- e = P_sto_neg[i] + P_PV[i],
579
- sense = plp.LpConstraintGE,
580
- rhs = 0)
581
- for i in set_I})
1456
+ if self.optim_conf["set_nocharge_from_grid"]:
1457
+ constraints.update(
1458
+ {
1459
+ f"constraint_nocharge_from_grid_{i}": plp.LpConstraint(
1460
+ e=p_sto_neg[i] + p_pv[i], sense=plp.LpConstraintGE, rhs=0
1461
+ )
1462
+ for i in set_i
1463
+ }
1464
+ )
582
1465
  # Optional constraints to avoid discharging the battery to the grid
583
- if self.optim_conf['set_nodischarge_to_grid']:
584
- constraints.update({"constraint_nodischarge_to_grid_{}".format(i) :
585
- plp.LpConstraint(
586
- e = P_grid_neg[i] + P_PV[i],
587
- sense = plp.LpConstraintGE,
588
- rhs = 0)
589
- for i in set_I})
1466
+ if self.optim_conf["set_nodischarge_to_grid"]:
1467
+ constraints.update(
1468
+ {
1469
+ f"constraint_nodischarge_to_grid_{i}": plp.LpConstraint(
1470
+ e=p_grid_neg[i] + p_pv[i], sense=plp.LpConstraintGE, rhs=0
1471
+ )
1472
+ for i in set_i
1473
+ }
1474
+ )
590
1475
  # Limitation of power dynamics in power per unit of time
591
- if self.optim_conf['set_battery_dynamic']:
592
- constraints.update({"constraint_pos_batt_dynamic_max_{}".format(i) :
593
- plp.LpConstraint(e = P_sto_pos[i+1] - P_sto_pos[i],
594
- sense = plp.LpConstraintLE,
595
- rhs = self.timeStep*self.optim_conf['battery_dynamic_max']*self.plant_conf['Pd_max'])
596
- for i in range(n-1)})
597
- constraints.update({"constraint_pos_batt_dynamic_min_{}".format(i) :
598
- plp.LpConstraint(e = P_sto_pos[i+1] - P_sto_pos[i],
599
- sense = plp.LpConstraintGE,
600
- rhs = self.timeStep*self.optim_conf['battery_dynamic_min']*self.plant_conf['Pd_max'])
601
- for i in range(n-1)})
602
- constraints.update({"constraint_neg_batt_dynamic_max_{}".format(i) :
603
- plp.LpConstraint(e = P_sto_neg[i+1] - P_sto_neg[i],
604
- sense = plp.LpConstraintLE,
605
- rhs = self.timeStep*self.optim_conf['battery_dynamic_max']*self.plant_conf['Pc_max'])
606
- for i in range(n-1)})
607
- constraints.update({"constraint_neg_batt_dynamic_min_{}".format(i) :
608
- plp.LpConstraint(e = P_sto_neg[i+1] - P_sto_neg[i],
609
- sense = plp.LpConstraintGE,
610
- rhs = self.timeStep*self.optim_conf['battery_dynamic_min']*self.plant_conf['Pc_max'])
611
- for i in range(n-1)})
1476
+ if self.optim_conf["set_battery_dynamic"]:
1477
+ constraints.update(
1478
+ {
1479
+ f"constraint_pos_batt_dynamic_max_{i}": plp.LpConstraint(
1480
+ e=p_sto_pos[i + 1] - p_sto_pos[i],
1481
+ sense=plp.LpConstraintLE,
1482
+ rhs=self.time_step
1483
+ * self.optim_conf["battery_dynamic_max"]
1484
+ * self.plant_conf["battery_discharge_power_max"],
1485
+ )
1486
+ for i in range(n - 1)
1487
+ }
1488
+ )
1489
+ constraints.update(
1490
+ {
1491
+ f"constraint_pos_batt_dynamic_min_{i}": plp.LpConstraint(
1492
+ e=p_sto_pos[i + 1] - p_sto_pos[i],
1493
+ sense=plp.LpConstraintGE,
1494
+ rhs=self.time_step
1495
+ * self.optim_conf["battery_dynamic_min"]
1496
+ * self.plant_conf["battery_discharge_power_max"],
1497
+ )
1498
+ for i in range(n - 1)
1499
+ }
1500
+ )
1501
+ constraints.update(
1502
+ {
1503
+ f"constraint_neg_batt_dynamic_max_{i}": plp.LpConstraint(
1504
+ e=p_sto_neg[i + 1] - p_sto_neg[i],
1505
+ sense=plp.LpConstraintLE,
1506
+ rhs=self.time_step
1507
+ * self.optim_conf["battery_dynamic_max"]
1508
+ * self.plant_conf["battery_charge_power_max"],
1509
+ )
1510
+ for i in range(n - 1)
1511
+ }
1512
+ )
1513
+ constraints.update(
1514
+ {
1515
+ f"constraint_neg_batt_dynamic_min_{i}": plp.LpConstraint(
1516
+ e=p_sto_neg[i + 1] - p_sto_neg[i],
1517
+ sense=plp.LpConstraintGE,
1518
+ rhs=self.time_step
1519
+ * self.optim_conf["battery_dynamic_min"]
1520
+ * self.plant_conf["battery_charge_power_max"],
1521
+ )
1522
+ for i in range(n - 1)
1523
+ }
1524
+ )
612
1525
  # Then the classic battery constraints
613
- constraints.update({"constraint_pstopos_{}".format(i) :
614
- plp.LpConstraint(
615
- e=P_sto_pos[i] - self.plant_conf['eta_disch']*self.plant_conf['Pd_max']*E[i],
616
- sense=plp.LpConstraintLE,
617
- rhs=0)
618
- for i in set_I})
619
- constraints.update({"constraint_pstoneg_{}".format(i) :
620
- plp.LpConstraint(
621
- e=-P_sto_neg[i] - (1/self.plant_conf['eta_ch'])*self.plant_conf['Pc_max']*(1-E[i]),
622
- sense=plp.LpConstraintLE,
623
- rhs=0)
624
- for i in set_I})
625
- constraints.update({"constraint_socmax_{}".format(i) :
626
- plp.LpConstraint(
627
- e=-plp.lpSum(P_sto_pos[j]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[j] for j in range(i)),
628
- sense=plp.LpConstraintLE,
629
- rhs=(self.plant_conf['Enom']/self.timeStep)*(self.plant_conf['SOCmax'] - soc_init))
630
- for i in set_I})
631
- constraints.update({"constraint_socmin_{}".format(i) :
632
- plp.LpConstraint(
633
- e=plp.lpSum(P_sto_pos[j]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[j] for j in range(i)),
634
- sense=plp.LpConstraintLE,
635
- rhs=(self.plant_conf['Enom']/self.timeStep)*(soc_init - self.plant_conf['SOCmin']))
636
- for i in set_I})
637
- constraints.update({"constraint_socfinal_{}".format(0) :
638
- plp.LpConstraint(
639
- e=plp.lpSum(P_sto_pos[i]*(1/self.plant_conf['eta_disch']) + self.plant_conf['eta_ch']*P_sto_neg[i] for i in set_I),
640
- sense=plp.LpConstraintEQ,
641
- rhs=(soc_init - soc_final)*self.plant_conf['Enom']/self.timeStep)
642
- })
1526
+ constraints.update(
1527
+ {
1528
+ f"constraint_pstopos_{i}": plp.LpConstraint(
1529
+ e=p_sto_pos[i]
1530
+ - self.plant_conf["battery_discharge_efficiency"]
1531
+ * self.plant_conf["battery_discharge_power_max"]
1532
+ * E[i],
1533
+ sense=plp.LpConstraintLE,
1534
+ rhs=0,
1535
+ )
1536
+ for i in set_i
1537
+ }
1538
+ )
1539
+ constraints.update(
1540
+ {
1541
+ f"constraint_pstoneg_{i}": plp.LpConstraint(
1542
+ e=-p_sto_neg[i]
1543
+ - (1 / self.plant_conf["battery_charge_efficiency"])
1544
+ * self.plant_conf["battery_charge_power_max"]
1545
+ * (1 - E[i]),
1546
+ sense=plp.LpConstraintLE,
1547
+ rhs=0,
1548
+ )
1549
+ for i in set_i
1550
+ }
1551
+ )
1552
+ constraints.update(
1553
+ {
1554
+ f"constraint_socmax_{i}": plp.LpConstraint(
1555
+ e=-plp.lpSum(
1556
+ p_sto_pos[j] * (1 / self.plant_conf["battery_discharge_efficiency"])
1557
+ + self.plant_conf["battery_charge_efficiency"] * p_sto_neg[j]
1558
+ for j in range(i)
1559
+ ),
1560
+ sense=plp.LpConstraintLE,
1561
+ rhs=(self.plant_conf["battery_nominal_energy_capacity"] / self.time_step)
1562
+ * (self.plant_conf["battery_maximum_state_of_charge"] - soc_init),
1563
+ )
1564
+ for i in set_i
1565
+ }
1566
+ )
1567
+ constraints.update(
1568
+ {
1569
+ f"constraint_socmin_{i}": plp.LpConstraint(
1570
+ e=plp.lpSum(
1571
+ p_sto_pos[j] * (1 / self.plant_conf["battery_discharge_efficiency"])
1572
+ + self.plant_conf["battery_charge_efficiency"] * p_sto_neg[j]
1573
+ for j in range(i)
1574
+ ),
1575
+ sense=plp.LpConstraintLE,
1576
+ rhs=(self.plant_conf["battery_nominal_energy_capacity"] / self.time_step)
1577
+ * (soc_init - self.plant_conf["battery_minimum_state_of_charge"]),
1578
+ )
1579
+ for i in set_i
1580
+ }
1581
+ )
1582
+ constraints.update(
1583
+ {
1584
+ f"constraint_socfinal_{0}": plp.LpConstraint(
1585
+ e=plp.lpSum(
1586
+ p_sto_pos[i] * (1 / self.plant_conf["battery_discharge_efficiency"])
1587
+ + self.plant_conf["battery_charge_efficiency"] * p_sto_neg[i]
1588
+ for i in set_i
1589
+ ),
1590
+ sense=plp.LpConstraintEQ,
1591
+ rhs=(soc_init - soc_final)
1592
+ * self.plant_conf["battery_nominal_energy_capacity"]
1593
+ / self.time_step,
1594
+ )
1595
+ }
1596
+ )
643
1597
  opt_model.constraints = constraints
644
1598
 
645
1599
  ## Finally, we call the solver to solve our optimization model:
1600
+ timeout = self.optim_conf["lp_solver_timeout"]
646
1601
  # solving with default solver CBC
647
- if self.lp_solver == 'PULP_CBC_CMD':
648
- opt_model.solve(PULP_CBC_CMD(msg=0))
649
- elif self.lp_solver == 'GLPK_CMD':
650
- opt_model.solve(GLPK_CMD(msg=0))
651
- elif self.lp_solver == 'COIN_CMD':
652
- opt_model.solve(COIN_CMD(msg=0, path=self.lp_solver_path))
1602
+ if self.lp_solver == "PULP_CBC_CMD":
1603
+ opt_model.solve(PULP_CBC_CMD(msg=0, timeLimit=timeout, threads=self.num_threads))
1604
+ elif self.lp_solver == "GLPK_CMD":
1605
+ opt_model.solve(GLPK_CMD(msg=0, timeLimit=timeout))
1606
+ elif self.lp_solver == "HiGHS":
1607
+ opt_model.solve(HiGHS(msg=0, timeLimit=timeout))
1608
+ elif self.lp_solver == "COIN_CMD":
1609
+ opt_model.solve(
1610
+ COIN_CMD(
1611
+ msg=0,
1612
+ path=self.lp_solver_path,
1613
+ timeLimit=timeout,
1614
+ threads=self.num_threads,
1615
+ )
1616
+ )
653
1617
  else:
654
1618
  self.logger.warning("Solver %s unknown, using default", self.lp_solver)
655
- opt_model.solve()
1619
+ opt_model.solve(PULP_CBC_CMD(msg=0, timeLimit=timeout, threads=self.num_threads))
656
1620
 
657
1621
  # The status of the solution is printed to the screen
658
1622
  self.optim_status = plp.LpStatus[opt_model.status]
@@ -661,65 +1625,130 @@ class Optimization:
661
1625
  self.logger.warning("Cost function cannot be evaluated")
662
1626
  return
663
1627
  else:
664
- self.logger.info("Total value of the Cost function = %.02f", plp.value(opt_model.objective))
1628
+ self.logger.info(
1629
+ "Total value of the Cost function = %.02f",
1630
+ plp.value(opt_model.objective),
1631
+ )
665
1632
 
666
1633
  # Build results Dataframe
667
1634
  opt_tp = pd.DataFrame()
668
- opt_tp["P_PV"] = [P_PV[i] for i in set_I]
669
- opt_tp["P_Load"] = [P_load[i] for i in set_I]
670
- for k in range(self.optim_conf['num_def_loads']):
671
- opt_tp["P_deferrable{}".format(k)] = [P_deferrable[k][i].varValue for i in set_I]
672
- opt_tp["P_grid_pos"] = [P_grid_pos[i].varValue for i in set_I]
673
- opt_tp["P_grid_neg"] = [P_grid_neg[i].varValue for i in set_I]
674
- opt_tp["P_grid"] = [P_grid_pos[i].varValue + P_grid_neg[i].varValue for i in set_I]
675
- if self.optim_conf['set_use_battery']:
676
- opt_tp["P_batt"] = [P_sto_pos[i].varValue + P_sto_neg[i].varValue for i in set_I]
677
- SOC_opt_delta = [(P_sto_pos[i].varValue*(1/self.plant_conf['eta_disch']) + \
678
- self.plant_conf['eta_ch']*P_sto_neg[i].varValue)*(
679
- self.timeStep/(self.plant_conf['Enom'])) for i in set_I]
680
- SOCinit = copy.copy(soc_init)
681
- SOC_opt = []
682
- for i in set_I:
683
- SOC_opt.append(SOCinit - SOC_opt_delta[i])
684
- SOCinit = SOC_opt[i]
685
- opt_tp["SOC_opt"] = SOC_opt
686
- if self.plant_conf['inverter_is_hybrid']:
687
- opt_tp["P_hybrid_inverter"] = [P_hybrid_inverter[i].varValue for i in set_I]
688
- if self.plant_conf['compute_curtailment']:
689
- opt_tp["P_PV_curtailment"] = [P_PV_curtailment[i].varValue for i in set_I]
1635
+ opt_tp["P_PV"] = [p_pv[i] for i in set_i]
1636
+ opt_tp["P_Load"] = [p_load[i] for i in set_i]
1637
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
1638
+ opt_tp[f"P_deferrable{k}"] = [p_deferrable[k][i].varValue for i in set_i]
1639
+ opt_tp["P_grid_pos"] = [p_grid_pos[i].varValue for i in set_i]
1640
+ opt_tp["P_grid_neg"] = [p_grid_neg[i].varValue for i in set_i]
1641
+ opt_tp["P_grid"] = [p_grid_pos[i].varValue + p_grid_neg[i].varValue for i in set_i]
1642
+ if self.optim_conf["set_use_battery"]:
1643
+ opt_tp["P_batt"] = [p_sto_pos[i].varValue + p_sto_neg[i].varValue for i in set_i]
1644
+ soc_opt_delta = [
1645
+ (
1646
+ p_sto_pos[i].varValue * (1 / self.plant_conf["battery_discharge_efficiency"])
1647
+ + self.plant_conf["battery_charge_efficiency"] * p_sto_neg[i].varValue
1648
+ )
1649
+ * (self.time_step / (self.plant_conf["battery_nominal_energy_capacity"]))
1650
+ for i in set_i
1651
+ ]
1652
+ soc_init = copy.copy(soc_init)
1653
+ soc_opt = []
1654
+ for i in set_i:
1655
+ soc_opt.append(soc_init - soc_opt_delta[i])
1656
+ soc_init = soc_opt[i]
1657
+ opt_tp["SOC_opt"] = soc_opt
1658
+
1659
+ # Record Battery Stress Cost if active
1660
+ if batt_stress_conf and batt_stress_conf["active"]:
1661
+ opt_tp["batt_stress_cost"] = [batt_stress_conf["vars"][i].varValue for i in set_i]
1662
+
1663
+ if self.plant_conf["inverter_is_hybrid"]:
1664
+ opt_tp["P_hybrid_inverter"] = [p_hybrid_inverter[i].varValue for i in set_i]
1665
+ # Record Inverter Stress Cost if active
1666
+ if inv_stress_conf and inv_stress_conf["active"]:
1667
+ opt_tp["inv_stress_cost"] = [inv_stress_conf["vars"][i].varValue for i in set_i]
1668
+
1669
+ if self.plant_conf["compute_curtailment"]:
1670
+ opt_tp["P_PV_curtailment"] = [p_pv_curtailment[i].varValue for i in set_i]
690
1671
  opt_tp.index = data_opt.index
691
1672
 
692
1673
  # Lets compute the optimal cost function
693
- P_def_sum_tp = []
694
- for i in set_I:
695
- P_def_sum_tp.append(sum(P_deferrable[k][i].varValue for k in range(self.optim_conf['num_def_loads'])))
696
- opt_tp["unit_load_cost"] = [unit_load_cost[i] for i in set_I]
697
- opt_tp["unit_prod_price"] = [unit_prod_price[i] for i in set_I]
698
- if self.optim_conf['set_total_pv_sell']:
699
- opt_tp["cost_profit"] = [-0.001*self.timeStep*(unit_load_cost[i]*(P_load[i] + P_def_sum_tp[i]) + \
700
- unit_prod_price[i]*P_grid_neg[i].varValue) for i in set_I]
1674
+ p_def_sum_tp = []
1675
+ for i in set_i:
1676
+ p_def_sum_tp.append(
1677
+ sum(
1678
+ p_deferrable[k][i].varValue
1679
+ for k in range(self.optim_conf["number_of_deferrable_loads"])
1680
+ )
1681
+ )
1682
+ opt_tp["unit_load_cost"] = [unit_load_cost[i] for i in set_i]
1683
+ opt_tp["unit_prod_price"] = [unit_prod_price[i] for i in set_i]
1684
+ if self.optim_conf["set_total_pv_sell"]:
1685
+ opt_tp["cost_profit"] = [
1686
+ -0.001
1687
+ * self.time_step
1688
+ * (
1689
+ unit_load_cost[i] * (p_load[i] + p_def_sum_tp[i])
1690
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1691
+ )
1692
+ for i in set_i
1693
+ ]
701
1694
  else:
702
- opt_tp["cost_profit"] = [-0.001*self.timeStep*(unit_load_cost[i]*P_grid_pos[i].varValue + \
703
- unit_prod_price[i]*P_grid_neg[i].varValue) for i in set_I]
1695
+ opt_tp["cost_profit"] = [
1696
+ -0.001
1697
+ * self.time_step
1698
+ * (
1699
+ unit_load_cost[i] * p_grid_pos[i].varValue
1700
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1701
+ )
1702
+ for i in set_i
1703
+ ]
704
1704
 
705
- if self.costfun == 'profit':
706
- if self.optim_conf['set_total_pv_sell']:
707
- opt_tp["cost_fun_profit"] = [-0.001*self.timeStep*(unit_load_cost[i]*(P_load[i] + P_def_sum_tp[i]) + \
708
- unit_prod_price[i]*P_grid_neg[i].varValue) for i in set_I]
1705
+ if self.costfun == "profit":
1706
+ if self.optim_conf["set_total_pv_sell"]:
1707
+ opt_tp["cost_fun_profit"] = [
1708
+ -0.001
1709
+ * self.time_step
1710
+ * (
1711
+ unit_load_cost[i] * (p_load[i] + p_def_sum_tp[i])
1712
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1713
+ )
1714
+ for i in set_i
1715
+ ]
709
1716
  else:
710
- opt_tp["cost_fun_profit"] = [-0.001*self.timeStep*(unit_load_cost[i]*P_grid_pos[i].varValue + \
711
- unit_prod_price[i]*P_grid_neg[i].varValue) for i in set_I]
712
- elif self.costfun == 'cost':
713
- if self.optim_conf['set_total_pv_sell']:
714
- opt_tp["cost_fun_cost"] = [-0.001*self.timeStep*unit_load_cost[i]*(P_load[i] + P_def_sum_tp[i]) for i in set_I]
1717
+ opt_tp["cost_fun_profit"] = [
1718
+ -0.001
1719
+ * self.time_step
1720
+ * (
1721
+ unit_load_cost[i] * p_grid_pos[i].varValue
1722
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1723
+ )
1724
+ for i in set_i
1725
+ ]
1726
+ elif self.costfun == "cost":
1727
+ if self.optim_conf["set_total_pv_sell"]:
1728
+ opt_tp["cost_fun_cost"] = [
1729
+ -0.001 * self.time_step * unit_load_cost[i] * (p_load[i] + p_def_sum_tp[i])
1730
+ for i in set_i
1731
+ ]
715
1732
  else:
716
- opt_tp["cost_fun_cost"] = [-0.001*self.timeStep*unit_load_cost[i]*P_grid_pos[i].varValue for i in set_I]
717
- elif self.costfun == 'self-consumption':
718
- if type_self_conso == 'maxmin':
719
- opt_tp["cost_fun_selfcons"] = [-0.001*self.timeStep*unit_load_cost[i]*SC[i].varValue for i in set_I]
720
- elif type_self_conso == 'bigm':
721
- opt_tp["cost_fun_selfcons"] = [-0.001*self.timeStep*(unit_load_cost[i]*P_grid_pos[i].varValue + \
722
- unit_prod_price[i]*P_grid_neg[i].varValue) for i in set_I]
1733
+ opt_tp["cost_fun_cost"] = [
1734
+ -0.001 * self.time_step * unit_load_cost[i] * p_grid_pos[i].varValue
1735
+ for i in set_i
1736
+ ]
1737
+ elif self.costfun == "self-consumption":
1738
+ if type_self_conso == "maxmin":
1739
+ opt_tp["cost_fun_selfcons"] = [
1740
+ -0.001 * self.time_step * unit_load_cost[i] * SC[i].varValue for i in set_i
1741
+ ]
1742
+ elif type_self_conso == "bigm":
1743
+ opt_tp["cost_fun_selfcons"] = [
1744
+ -0.001
1745
+ * self.time_step
1746
+ * (
1747
+ unit_load_cost[i] * p_grid_pos[i].varValue
1748
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1749
+ )
1750
+ for i in set_i
1751
+ ]
723
1752
  else:
724
1753
  self.logger.error("The cost function specified type is not valid")
725
1754
 
@@ -728,19 +1757,67 @@ class Optimization:
728
1757
 
729
1758
  # Debug variables
730
1759
  if debug:
731
- for k in range(self.optim_conf["num_def_loads"]):
732
- opt_tp[f"P_def_start_{k}"] = [P_def_start[k][i].varValue for i in set_I]
733
- opt_tp[f"P_def_bin2_{k}"] = [P_def_bin2[k][i].varValue for i in set_I]
1760
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
1761
+ opt_tp[f"P_def_start_{k}"] = [p_def_start[k][i].varValue for i in set_i]
1762
+ opt_tp[f"P_def_bin2_{k}"] = [p_def_bin2[k][i].varValue for i in set_i]
1763
+
734
1764
  for i, predicted_temp in predicted_temps.items():
735
- opt_tp[f"predicted_temp_heater{i}"] = pd.Series([round(pt.value(), 2) if isinstance(pt, plp.LpAffineExpression) else pt for pt in predicted_temp], index=opt_tp.index)
736
- opt_tp[f"target_temp_heater{i}"] = pd.Series(self.optim_conf["def_load_config"][i]['thermal_config']["desired_temperatures"], index=opt_tp.index)
1765
+ opt_tp[f"predicted_temp_heater{i}"] = pd.Series(
1766
+ [
1767
+ round(pt.value(), 2) if isinstance(pt, plp.LpAffineExpression) else pt
1768
+ for pt in predicted_temp
1769
+ ],
1770
+ index=opt_tp.index,
1771
+ )
1772
+ # Try desired, then min, then max, else empty list
1773
+ if "thermal_config" in self.optim_conf["def_load_config"][i]:
1774
+ thermal_config = self.optim_conf["def_load_config"][i]["thermal_config"]
1775
+ target_temps = thermal_config.get("desired_temperatures")
1776
+ # If desired_temperatures is missing, try to fallback to min or max for visualization
1777
+ if not target_temps:
1778
+ target_temps = thermal_config.get("min_temperatures")
1779
+ if not target_temps:
1780
+ target_temps = thermal_config.get("max_temperatures")
1781
+ # If we found something, add it to the DF
1782
+ if target_temps:
1783
+ opt_tp[f"target_temp_heater{i}"] = pd.Series(
1784
+ target_temps,
1785
+ index=opt_tp.index,
1786
+ )
737
1787
 
1788
+ # Add heating demands for thermal loads (both thermal_config and thermal_battery)
1789
+ for i, heating_demand in heating_demands.items():
1790
+ opt_tp[f"heating_demand_heater{i}"] = pd.Series(
1791
+ heating_demand,
1792
+ index=opt_tp.index,
1793
+ )
1794
+
1795
+ # Battery initialization logging
1796
+ if self.optim_conf["set_use_battery"]:
1797
+ self.logger.debug(
1798
+ f"Battery usage enabled. Initial SOC: {soc_init}, Final SOC: {soc_final}"
1799
+ )
1800
+
1801
+ # Deferrable load initialization logging
1802
+ self.logger.debug(f"Deferrable load operating hours: {def_total_hours}")
1803
+ self.logger.debug(f"Deferrable load timesteps: {def_total_timestep}")
1804
+ self.logger.debug(f"Deferrable load start timesteps: {def_start_timestep}")
1805
+ self.logger.debug(f"Deferrable load end timesteps: {def_end_timestep}")
1806
+
1807
+ # Objective function logging
1808
+ self.logger.debug(f"Selected cost function type: {self.costfun}")
1809
+
1810
+ # Solver execution logging
1811
+ self.logger.debug(f"Solver selected: {self.lp_solver}")
1812
+ self.logger.info(f"Optimization status: {self.optim_status}")
738
1813
  return opt_tp
739
1814
 
740
- def perform_perfect_forecast_optim(self, df_input_data: pd.DataFrame, days_list: pd.date_range) -> pd.DataFrame:
1815
+ def perform_perfect_forecast_optim(
1816
+ self, df_input_data: pd.DataFrame, days_list: pd.date_range
1817
+ ) -> pd.DataFrame:
741
1818
  r"""
742
1819
  Perform an optimization on historical data (perfectly known PV production).
743
-
1820
+
744
1821
  :param df_input_data: A DataFrame containing all the input data used for \
745
1822
  the optimization, notably photovoltaics and load consumption powers.
746
1823
  :type df_input_data: pandas.DataFrame
@@ -753,21 +1830,47 @@ class Optimization:
753
1830
 
754
1831
  """
755
1832
  self.logger.info("Perform optimization for perfect forecast scenario")
756
- self.days_list_tz = days_list.tz_convert(self.time_zone).round(self.freq)[:-1] # Converted to tz and without the current day (today)
1833
+ self.days_list_tz = days_list.tz_convert(self.time_zone).round(self.freq)[
1834
+ :-1
1835
+ ] # Converted to tz and without the current day (today)
757
1836
  self.opt_res = pd.DataFrame()
758
1837
  for day in self.days_list_tz:
759
- self.logger.info("Solving for day: "+str(day.day)+"-"+str(day.month)+"-"+str(day.year))
1838
+ self.logger.info(
1839
+ "Solving for day: " + str(day.day) + "-" + str(day.month) + "-" + str(day.year)
1840
+ )
760
1841
  # Prepare data
761
- day_start = day.isoformat()
762
- day_end = (day+self.time_delta-self.freq).isoformat()
763
- data_tp = df_input_data.copy().loc[pd.date_range(start=day_start, end=day_end, freq=self.freq)]
764
- P_PV = data_tp[self.var_PV].values
765
- P_load = data_tp[self.var_load_new].values
766
- unit_load_cost = data_tp[self.var_load_cost].values # €/kWh
767
- unit_prod_price = data_tp[self.var_prod_price].values # €/kWh
1842
+ if day.tzinfo is None:
1843
+ day = day.replace(tzinfo=self.time_zone) # Assign timezone if naive
1844
+ else:
1845
+ day = day.astimezone(self.time_zone)
1846
+ day_start = day
1847
+ day_end = day + self.time_delta - self.freq
1848
+ if day_start.tzinfo != day_end.tzinfo:
1849
+ self.logger.warning(
1850
+ f"Skipping day {day} as days have ddifferent timezone, probably because of DST."
1851
+ )
1852
+ continue # Skip this day and move to the next iteration
1853
+ else:
1854
+ day_start = day_start.astimezone(self.time_zone).isoformat()
1855
+ day_end = day_end.astimezone(self.time_zone).isoformat()
1856
+ # Generate the date range for the current day
1857
+ day_range = pd.date_range(start=day_start, end=day_end, freq=self.freq)
1858
+ # Check if all timestamps in the range exist in the DataFrame index
1859
+ if not day_range.isin(df_input_data.index).all():
1860
+ self.logger.warning(
1861
+ f"Skipping day {day} as some timestamps are missing in the data."
1862
+ )
1863
+ continue # Skip this day and move to the next iteration
1864
+ # If all timestamps exist, proceed with the data preparation
1865
+ data_tp = df_input_data.copy().loc[day_range]
1866
+ p_pv = data_tp[self.var_pv].values
1867
+ p_load = data_tp[self.var_load_new].values
1868
+ unit_load_cost = data_tp[self.var_load_cost].values # €/kWh
1869
+ unit_prod_price = data_tp[self.var_prod_price].values # €/kWh
768
1870
  # Call optimization function
769
- opt_tp = self.perform_optimization(data_tp, P_PV, P_load,
770
- unit_load_cost, unit_prod_price)
1871
+ opt_tp = self.perform_optimization(
1872
+ data_tp, p_pv, p_load, unit_load_cost, unit_prod_price
1873
+ )
771
1874
  if len(self.opt_res) == 0:
772
1875
  self.opt_res = opt_tp
773
1876
  else:
@@ -775,52 +1878,65 @@ class Optimization:
775
1878
 
776
1879
  return self.opt_res
777
1880
 
778
- def perform_dayahead_forecast_optim(self, df_input_data: pd.DataFrame,
779
- P_PV: pd.Series, P_load: pd.Series) -> pd.DataFrame:
1881
+ def perform_dayahead_forecast_optim(
1882
+ self, df_input_data: pd.DataFrame, p_pv: pd.Series, p_load: pd.Series
1883
+ ) -> pd.DataFrame:
780
1884
  r"""
781
1885
  Perform a day-ahead optimization task using real forecast data. \
782
1886
  This type of optimization is intented to be launched once a day.
783
-
1887
+
784
1888
  :param df_input_data: A DataFrame containing all the input data used for \
785
1889
  the optimization, notably the unit load cost for power consumption.
786
1890
  :type df_input_data: pandas.DataFrame
787
- :param P_PV: The forecasted PV power production.
788
- :type P_PV: pandas.DataFrame
789
- :param P_load: The forecasted Load power consumption. This power should \
1891
+ :param p_pv: The forecasted PV power production.
1892
+ :type p_pv: pandas.DataFrame
1893
+ :param p_load: The forecasted Load power consumption. This power should \
790
1894
  not include the power from the deferrable load that we want to find.
791
- :type P_load: pandas.DataFrame
1895
+ :type p_load: pandas.DataFrame
792
1896
  :return: opt_res: A DataFrame containing the optimization results
793
1897
  :rtype: pandas.DataFrame
794
1898
 
795
1899
  """
796
1900
  self.logger.info("Perform optimization for the day-ahead")
797
- unit_load_cost = df_input_data[self.var_load_cost].values # €/kWh
798
- unit_prod_price = df_input_data[self.var_prod_price].values # €/kWh
1901
+ unit_load_cost = df_input_data[self.var_load_cost].values # €/kWh
1902
+ unit_prod_price = df_input_data[self.var_prod_price].values # €/kWh
799
1903
  # Call optimization function
800
- self.opt_res = self.perform_optimization(df_input_data, P_PV.values.ravel(),
801
- P_load.values.ravel(),
802
- unit_load_cost, unit_prod_price)
1904
+ self.opt_res = self.perform_optimization(
1905
+ df_input_data,
1906
+ p_pv.values.ravel(),
1907
+ p_load.values.ravel(),
1908
+ unit_load_cost,
1909
+ unit_prod_price,
1910
+ )
803
1911
  return self.opt_res
804
1912
 
805
- def perform_naive_mpc_optim(self, df_input_data: pd.DataFrame, P_PV: pd.Series, P_load: pd.Series,
806
- prediction_horizon: int, soc_init: Optional[float] = None, soc_final: Optional[float] = None,
807
- def_total_hours: Optional[list] = None,
808
- def_start_timestep: Optional[list] = None,
809
- def_end_timestep: Optional[list] = None) -> pd.DataFrame:
1913
+ def perform_naive_mpc_optim(
1914
+ self,
1915
+ df_input_data: pd.DataFrame,
1916
+ p_pv: pd.Series,
1917
+ p_load: pd.Series,
1918
+ prediction_horizon: int,
1919
+ soc_init: float | None = None,
1920
+ soc_final: float | None = None,
1921
+ def_total_hours: list | None = None,
1922
+ def_total_timestep: list | None = None,
1923
+ def_start_timestep: list | None = None,
1924
+ def_end_timestep: list | None = None,
1925
+ ) -> pd.DataFrame:
810
1926
  r"""
811
1927
  Perform a naive approach to a Model Predictive Control (MPC). \
812
1928
  This implementaion is naive because we are not using the formal formulation \
813
1929
  of a MPC. Only the sense of a receiding horizon is considered here. \
814
1930
  This optimization is more suitable for higher optimization frequency, ex: 5min.
815
-
1931
+
816
1932
  :param df_input_data: A DataFrame containing all the input data used for \
817
1933
  the optimization, notably the unit load cost for power consumption.
818
1934
  :type df_input_data: pandas.DataFrame
819
- :param P_PV: The forecasted PV power production.
820
- :type P_PV: pandas.DataFrame
821
- :param P_load: The forecasted Load power consumption. This power should \
1935
+ :param p_pv: The forecasted PV power production.
1936
+ :type p_pv: pandas.DataFrame
1937
+ :param p_load: The forecasted Load power consumption. This power should \
822
1938
  not include the power from the deferrable load that we want to find.
823
- :type P_load: pandas.DataFrame
1939
+ :type p_load: pandas.DataFrame
824
1940
  :param prediction_horizon: The prediction horizon of the MPC controller in number \
825
1941
  of optimization time steps.
826
1942
  :type prediction_horizon: int
@@ -829,7 +1945,10 @@ class Optimization:
829
1945
  :type soc_init: float
830
1946
  :param soc_final: The final battery SOC for the optimization. This parameter \
831
1947
  is optional, if not given soc_init = soc_final = soc_target from the configuration file.
832
- :type soc_final:
1948
+ :type soc_final:
1949
+ :param def_total_timestep: The functioning timesteps for this iteration for each deferrable load. \
1950
+ (For continuous deferrable loads: functioning timesteps at nominal power)
1951
+ :type def_total_timestep: list
833
1952
  :param def_total_hours: The functioning hours for this iteration for each deferrable load. \
834
1953
  (For continuous deferrable loads: functioning hours at nominal power)
835
1954
  :type def_total_hours: list
@@ -843,24 +1962,39 @@ class Optimization:
843
1962
  """
844
1963
  self.logger.info("Perform an iteration of a naive MPC controller")
845
1964
  if prediction_horizon < 5:
846
- self.logger.error("Set the MPC prediction horizon to at least 5 times the optimization time step")
1965
+ self.logger.error(
1966
+ "Set the MPC prediction horizon to at least 5 times the optimization time step"
1967
+ )
847
1968
  return pd.DataFrame()
848
1969
  else:
849
- df_input_data = copy.deepcopy(df_input_data)[df_input_data.index[0]:df_input_data.index[prediction_horizon-1]]
850
- unit_load_cost = df_input_data[self.var_load_cost].values # €/kWh
851
- unit_prod_price = df_input_data[self.var_prod_price].values # €/kWh
1970
+ df_input_data = copy.deepcopy(df_input_data)[
1971
+ df_input_data.index[0] : df_input_data.index[prediction_horizon - 1]
1972
+ ]
1973
+ unit_load_cost = df_input_data[self.var_load_cost].values # €/kWh
1974
+ unit_prod_price = df_input_data[self.var_prod_price].values # €/kWh
852
1975
  # Call optimization function
853
- self.opt_res = self.perform_optimization(df_input_data, P_PV.values.ravel(), P_load.values.ravel(),
854
- unit_load_cost, unit_prod_price, soc_init=soc_init,
855
- soc_final=soc_final, def_total_hours=def_total_hours,
856
- def_start_timestep=def_start_timestep, def_end_timestep=def_end_timestep)
1976
+ self.opt_res = self.perform_optimization(
1977
+ df_input_data,
1978
+ p_pv.values.ravel(),
1979
+ p_load.values.ravel(),
1980
+ unit_load_cost,
1981
+ unit_prod_price,
1982
+ soc_init=soc_init,
1983
+ soc_final=soc_final,
1984
+ def_total_hours=def_total_hours,
1985
+ def_total_timestep=def_total_timestep,
1986
+ def_start_timestep=def_start_timestep,
1987
+ def_end_timestep=def_end_timestep,
1988
+ )
857
1989
  return self.opt_res
858
1990
 
859
1991
  @staticmethod
860
- def validate_def_timewindow(start: int, end: int, min_steps: int, window: int) -> Tuple[int,int,str]:
1992
+ def validate_def_timewindow(
1993
+ start: int, end: int, min_steps: int, window: int
1994
+ ) -> tuple[int, int, str]:
861
1995
  r"""
862
1996
  Helper function to validate (and if necessary: correct) the defined optimization window of a deferrable load.
863
-
1997
+
864
1998
  :param start: Start timestep of the optimization window of the deferrable load
865
1999
  :type start: int
866
2000
  :param end: End timestep of the optimization window of the deferrable load
@@ -887,7 +2021,7 @@ class Optimization:
887
2021
  end_validated = max(0, min(window, end))
888
2022
  if end_validated > 0:
889
2023
  # If the available timeframe is shorter than the number of timesteps needed to meet the hours to operate (def_total_hours), issue a warning.
890
- if (end_validated-start_validated) < min_steps:
2024
+ if (end_validated - start_validated) < min_steps:
891
2025
  warning = "Available timeframe is shorter than the specified number of hours to operate. Optimization will fail."
892
2026
  else:
893
2027
  warning = "Invalid timeframe for deferrable load (start timestep is not <= end timestep). Continuing optimization without timewindow constraint."