emhass 0.11.4__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,26 +1,24 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
-
4
1
  import bz2
5
2
  import copy
6
3
  import logging
7
- import pathlib
4
+ import os
8
5
  import pickle as cPickle
9
6
  from math import ceil
10
- from typing import Optional, Tuple
11
7
 
12
8
  import numpy as np
13
9
  import pandas as pd
14
10
  import pulp as plp
15
- from pulp import COIN_CMD, GLPK_CMD, PULP_CBC_CMD
11
+ from pulp import COIN_CMD, GLPK_CMD, PULP_CBC_CMD, HiGHS
12
+
13
+ from emhass import utils
16
14
 
17
15
 
18
16
  class Optimization:
19
17
  r"""
20
- Optimize the deferrable load and battery energy dispatch problem using \
18
+ Optimize the deferrable load and battery energy dispatch problem using \
21
19
  the linear programming optimization technique. All equipement equations, \
22
20
  including the battery equations are hence transformed in a linear form.
23
-
21
+
24
22
  This class methods are:
25
23
 
26
24
  - perform_optimization
@@ -28,9 +26,9 @@ class Optimization:
28
26
  - perform_perfect_forecast_optim
29
27
 
30
28
  - perform_dayahead_forecast_optim
31
-
29
+
32
30
  - perform_naive_mpc_optim
33
-
31
+
34
32
  """
35
33
 
36
34
  def __init__(
@@ -43,11 +41,11 @@ class Optimization:
43
41
  costfun: str,
44
42
  emhass_conf: dict,
45
43
  logger: logging.Logger,
46
- opt_time_delta: Optional[int] = 24,
44
+ opt_time_delta: int | None = 24,
47
45
  ) -> None:
48
46
  r"""
49
47
  Define constructor for Optimization class.
50
-
48
+
51
49
  :param retrieve_hass_conf: Configuration parameters used to retrieve data \
52
50
  from hass
53
51
  :type retrieve_hass_conf: dict
@@ -70,26 +68,31 @@ class Optimization:
70
68
  more than one day then the optimization will be peformed by chunks of \
71
69
  opt_time_delta periods, defaults to 24
72
70
  :type opt_time_delta: float, optional
73
-
71
+
74
72
  """
75
73
  self.retrieve_hass_conf = retrieve_hass_conf
76
74
  self.optim_conf = optim_conf
77
75
  self.plant_conf = plant_conf
78
76
  self.freq = self.retrieve_hass_conf["optimization_time_step"]
79
77
  self.time_zone = self.retrieve_hass_conf["time_zone"]
80
- self.timeStep = self.freq.seconds / 3600 # in hours
81
- self.time_delta = pd.to_timedelta(
82
- opt_time_delta, "hours"
83
- ) # The period of optimization
84
- self.var_PV = self.retrieve_hass_conf["sensor_power_photovoltaics"]
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"]
85
81
  self.var_load = self.retrieve_hass_conf["sensor_power_load_no_var_loads"]
86
82
  self.var_load_new = self.var_load + "_positive"
87
83
  self.costfun = costfun
88
- # self.emhass_conf = emhass_conf
84
+ self.emhass_conf = emhass_conf
89
85
  self.logger = logger
90
86
  self.var_load_cost = var_load_cost
91
87
  self.var_prod_price = var_prod_price
92
88
  self.optim_status = None
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())
93
96
  if "lp_solver" in optim_conf.keys():
94
97
  self.lp_solver = optim_conf["lp_solver"]
95
98
  else:
@@ -109,32 +112,110 @@ class Optimization:
109
112
  "lp_solver=COIN_CMD but lp_solver_path=empty, attempting to use lp_solver_path=/usr/bin/cbc"
110
113
  )
111
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
+ )
112
191
 
113
192
  def perform_optimization(
114
193
  self,
115
194
  data_opt: pd.DataFrame,
116
- P_PV: np.array,
117
- P_load: np.array,
195
+ p_pv: np.array,
196
+ p_load: np.array,
118
197
  unit_load_cost: np.array,
119
198
  unit_prod_price: np.array,
120
- soc_init: Optional[float] = None,
121
- soc_final: Optional[float] = None,
122
- def_total_hours: Optional[list] = None,
123
- def_start_timestep: Optional[list] = None,
124
- def_end_timestep: Optional[list] = None,
125
- debug: Optional[bool] = False,
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,
126
207
  ) -> pd.DataFrame:
127
208
  r"""
128
209
  Perform the actual optimization using linear programming (LP).
129
-
210
+
130
211
  :param data_opt: A DataFrame containing the input data. The results of the \
131
212
  optimization will be appended (decision variables, cost function values, etc)
132
213
  :type data_opt: pd.DataFrame
133
- :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 \
134
215
  values or forecasted values.
135
- :type P_PV: numpy.array
136
- :param P_load: The load power consumption values
137
- :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
138
219
  :param unit_load_cost: The cost of power consumption for each unit of time. \
139
220
  This is the cost of the energy from the utility in a vector sampled \
140
221
  at the fixed freq value
@@ -148,10 +229,13 @@ class Optimization:
148
229
  :type soc_init: float
149
230
  :param soc_final: The final battery SOC for the optimization. This parameter \
150
231
  is optional, if not given soc_init = soc_final = soc_target from the configuration file.
151
- :type soc_final:
232
+ :type soc_final:
152
233
  :param def_total_hours: The functioning hours for this iteration for each deferrable load. \
153
234
  (For continuous deferrable loads: functioning hours at nominal power)
154
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
155
239
  :param def_start_timestep: The timestep as from which each deferrable load is allowed to operate.
156
240
  :type def_start_timestep: list
157
241
  :param def_end_timestep: The timestep before which each deferrable load should operate.
@@ -173,202 +257,229 @@ class Optimization:
173
257
  soc_final = soc_init
174
258
  else:
175
259
  soc_final = self.plant_conf["battery_target_state_of_charge"]
176
- if def_total_hours is None:
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:
177
270
  def_total_hours = self.optim_conf["operating_hours_of_each_deferrable_load"]
271
+
178
272
  if def_start_timestep is None:
179
- def_start_timestep = self.optim_conf[
180
- "start_timesteps_of_each_deferrable_load"
181
- ]
273
+ def_start_timestep = self.optim_conf["start_timesteps_of_each_deferrable_load"]
182
274
  if def_end_timestep is None:
183
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
+
184
281
  type_self_conso = "bigm" # maxmin
185
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))
298
+
186
299
  #### The LP problem using Pulp ####
187
300
  opt_model = plp.LpProblem("LP_Model", plp.LpMaximize)
188
301
 
189
302
  n = len(data_opt.index)
190
- set_I = range(n)
191
- M = 10e10
303
+ set_i = range(n)
304
+ M = 100000
192
305
 
193
306
  ## Add decision variables
194
- P_grid_neg = {
307
+ p_grid_neg = {
195
308
  (i): plp.LpVariable(
196
309
  cat="Continuous",
197
310
  lowBound=-self.plant_conf["maximum_power_to_grid"],
198
311
  upBound=0,
199
- name="P_grid_neg{}".format(i),
312
+ name=f"P_grid_neg{i}",
200
313
  )
201
- for i in set_I
314
+ for i in set_i
202
315
  }
203
- P_grid_pos = {
316
+ p_grid_pos = {
204
317
  (i): plp.LpVariable(
205
318
  cat="Continuous",
206
319
  lowBound=0,
207
320
  upBound=self.plant_conf["maximum_power_from_grid"],
208
- name="P_grid_pos{}".format(i),
321
+ name=f"P_grid_pos{i}",
209
322
  )
210
- for i in set_I
323
+ for i in set_i
211
324
  }
212
- P_deferrable = []
213
- P_def_bin1 = []
214
- for k in range(self.optim_conf["number_of_deferrable_loads"]):
215
- if type(self.optim_conf["nominal_power_of_deferrable_loads"][k]) == list:
216
- upBound = np.max(
217
- self.optim_conf["nominal_power_of_deferrable_loads"][k]
218
- )
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])
219
330
  else:
220
- upBound = self.optim_conf["nominal_power_of_deferrable_loads"][k]
331
+ up_bound = self.optim_conf["nominal_power_of_deferrable_loads"][k]
221
332
  if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
222
- P_deferrable.append(
333
+ p_deferrable.append(
223
334
  {
224
- (i): plp.LpVariable(
225
- cat="Continuous", name="P_deferrable{}_{}".format(k, i)
226
- )
227
- for i in set_I
335
+ (i): plp.LpVariable(cat="Continuous", name=f"P_deferrable{k}_{i}")
336
+ for i in set_i
228
337
  }
229
338
  )
230
339
  else:
231
- P_deferrable.append(
340
+ p_deferrable.append(
232
341
  {
233
342
  (i): plp.LpVariable(
234
343
  cat="Continuous",
235
344
  lowBound=0,
236
- upBound=upBound,
237
- name="P_deferrable{}_{}".format(k, i),
345
+ upBound=up_bound,
346
+ name=f"P_deferrable{k}_{i}",
238
347
  )
239
- for i in set_I
348
+ for i in set_i
240
349
  }
241
350
  )
242
- P_def_bin1.append(
243
- {
244
- (i): plp.LpVariable(
245
- cat="Binary", name="P_def{}_bin1_{}".format(k, i)
246
- )
247
- for i in set_I
248
- }
351
+ p_def_bin1.append(
352
+ {(i): plp.LpVariable(cat="Binary", name=f"P_def{k}_bin1_{i}") for i in set_i}
249
353
  )
250
- P_def_start = []
251
- P_def_bin2 = []
354
+ p_def_start = []
355
+ p_def_bin2 = []
252
356
  for k in range(self.optim_conf["number_of_deferrable_loads"]):
253
- P_def_start.append(
254
- {
255
- (i): plp.LpVariable(
256
- cat="Binary", name="P_def{}_start_{}".format(k, i)
257
- )
258
- for i in set_I
259
- }
357
+ p_def_start.append(
358
+ {(i): plp.LpVariable(cat="Binary", name=f"P_def{k}_start_{i}") for i in set_i}
260
359
  )
261
- P_def_bin2.append(
262
- {
263
- (i): plp.LpVariable(
264
- cat="Binary", name="P_def{}_bin2_{}".format(k, i)
265
- )
266
- for i in set_I
267
- }
360
+ p_def_bin2.append(
361
+ {(i): plp.LpVariable(cat="Binary", name=f"P_def{k}_bin2_{i}") for i in set_i}
268
362
  )
269
- D = {(i): plp.LpVariable(cat="Binary", name="D_{}".format(i)) for i in set_I}
270
- E = {(i): plp.LpVariable(cat="Binary", name="E_{}".format(i)) for i in set_I}
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
+
271
370
  if self.optim_conf["set_use_battery"]:
272
- P_sto_pos = {
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 = {
273
379
  (i): plp.LpVariable(
274
380
  cat="Continuous",
275
381
  lowBound=0,
276
382
  upBound=self.plant_conf["battery_discharge_power_max"],
277
- name="P_sto_pos_{0}".format(i),
383
+ name=f"P_sto_pos_{i}",
278
384
  )
279
- for i in set_I
385
+ for i in set_i
280
386
  }
281
- P_sto_neg = {
387
+ p_sto_neg = {
282
388
  (i): plp.LpVariable(
283
389
  cat="Continuous",
284
- lowBound=-self.plant_conf["battery_charge_power_max"],
390
+ lowBound=-np.abs(self.plant_conf["battery_charge_power_max"]),
285
391
  upBound=0,
286
- name="P_sto_neg_{0}".format(i),
392
+ name=f"P_sto_neg_{i}",
287
393
  )
288
- for i in set_I
394
+ for i in set_i
289
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
+
290
407
  else:
291
- P_sto_pos = {(i): i * 0 for i in set_I}
292
- P_sto_neg = {(i): i * 0 for i in set_I}
408
+ p_sto_pos = {(i): i * 0 for i in set_i}
409
+ p_sto_neg = {(i): i * 0 for i in set_i}
293
410
 
294
411
  if self.costfun == "self-consumption":
295
- SC = {
296
- (i): plp.LpVariable(cat="Continuous", name="SC_{}".format(i))
297
- for i in set_I
298
- }
412
+ SC = {(i): plp.LpVariable(cat="Continuous", name=f"SC_{i}") for i in set_i}
413
+
299
414
  if self.plant_conf["inverter_is_hybrid"]:
300
- P_hybrid_inverter = {
301
- (i): plp.LpVariable(
302
- cat="Continuous", name="P_hybrid_inverter{}".format(i)
303
- )
304
- for i in set_I
415
+ p_hybrid_inverter = {
416
+ (i): plp.LpVariable(cat="Continuous", name=f"P_hybrid_inverter{i}") for i in set_i
305
417
  }
306
- P_PV_curtailment = {
307
- (i): plp.LpVariable(
308
- cat="Continuous", lowBound=0, name="P_PV_curtailment{}".format(i)
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),
309
422
  )
310
- for i in set_I
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
311
430
  }
312
431
 
313
432
  ## Define objective
314
- P_def_sum = []
315
- for i in set_I:
316
- P_def_sum.append(
433
+ p_def_sum = []
434
+ for i in set_i:
435
+ p_def_sum.append(
317
436
  plp.lpSum(
318
- P_deferrable[k][i]
319
- for k in range(self.optim_conf["number_of_deferrable_loads"])
437
+ p_deferrable[k][i] for k in range(self.optim_conf["number_of_deferrable_loads"])
320
438
  )
321
439
  )
322
440
  if self.costfun == "profit":
323
441
  if self.optim_conf["set_total_pv_sell"]:
324
442
  objective = plp.lpSum(
325
443
  -0.001
326
- * self.timeStep
444
+ * self.time_step
327
445
  * (
328
- unit_load_cost[i] * (P_load[i] + P_def_sum[i])
329
- + unit_prod_price[i] * P_grid_neg[i]
446
+ unit_load_cost[i] * (p_load[i] + p_def_sum[i])
447
+ + unit_prod_price[i] * p_grid_neg[i]
330
448
  )
331
- for i in set_I
449
+ for i in set_i
332
450
  )
333
451
  else:
334
452
  objective = plp.lpSum(
335
453
  -0.001
336
- * self.timeStep
337
- * (
338
- unit_load_cost[i] * P_grid_pos[i]
339
- + unit_prod_price[i] * P_grid_neg[i]
340
- )
341
- for i in set_I
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
342
457
  )
343
458
  elif self.costfun == "cost":
344
459
  if self.optim_conf["set_total_pv_sell"]:
345
460
  objective = plp.lpSum(
346
- -0.001
347
- * self.timeStep
348
- * unit_load_cost[i]
349
- * (P_load[i] + P_def_sum[i])
350
- for i in set_I
461
+ -0.001 * self.time_step * unit_load_cost[i] * (p_load[i] + p_def_sum[i])
462
+ for i in set_i
351
463
  )
352
464
  else:
353
465
  objective = plp.lpSum(
354
- -0.001 * self.timeStep * unit_load_cost[i] * P_grid_pos[i]
355
- for i in set_I
466
+ -0.001 * self.time_step * unit_load_cost[i] * p_grid_pos[i] for i in set_i
356
467
  )
357
468
  elif self.costfun == "self-consumption":
358
469
  if type_self_conso == "bigm":
359
470
  bigm = 1e3
360
471
  objective = plp.lpSum(
361
472
  -0.001
362
- * self.timeStep
473
+ * self.time_step
363
474
  * (
364
- bigm * unit_load_cost[i] * P_grid_pos[i]
365
- + unit_prod_price[i] * P_grid_neg[i]
475
+ bigm * unit_load_cost[i] * p_grid_pos[i]
476
+ + unit_prod_price[i] * p_grid_neg[i]
366
477
  )
367
- for i in set_I
478
+ for i in set_i
368
479
  )
369
480
  elif type_self_conso == "maxmin":
370
481
  objective = plp.lpSum(
371
- 0.001 * self.timeStep * unit_load_cost[i] * SC[i] for i in set_I
482
+ 0.001 * self.time_step * unit_load_cost[i] * SC[i] for i in set_i
372
483
  )
373
484
  else:
374
485
  self.logger.error("Not a valid option for type_self_conso parameter")
@@ -378,12 +489,12 @@ class Optimization:
378
489
  if self.optim_conf["set_use_battery"]:
379
490
  objective = objective + plp.lpSum(
380
491
  -0.001
381
- * self.timeStep
492
+ * self.time_step
382
493
  * (
383
- self.optim_conf["weight_battery_discharge"] * P_sto_pos[i]
384
- + self.optim_conf["weight_battery_charge"] * P_sto_neg[i]
494
+ self.optim_conf["weight_battery_discharge"] * p_sto_pos[i]
495
+ - self.optim_conf["weight_battery_charge"] * p_sto_neg[i]
385
496
  )
386
- for i in set_I
497
+ for i in set_i
387
498
  )
388
499
 
389
500
  # Add term penalizing each startup where configured
@@ -398,178 +509,298 @@ class Optimization:
398
509
  ):
399
510
  objective = objective + plp.lpSum(
400
511
  -0.001
401
- * self.timeStep
512
+ * self.time_step
402
513
  * self.optim_conf["set_deferrable_startup_penalty"][k]
403
- * P_def_start[k][i]
514
+ * p_def_start[k][i]
404
515
  * unit_load_cost[i]
405
516
  * self.optim_conf["nominal_power_of_deferrable_loads"][k]
406
- for i in set_I
517
+ for i in set_i
407
518
  )
408
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)
529
+
409
530
  opt_model.setObjective(objective)
410
531
 
411
532
  ## Setting constraints
412
533
  # The main constraint: power balance
413
534
  if self.plant_conf["inverter_is_hybrid"]:
414
535
  constraints = {
415
- "constraint_main1_{}".format(i): plp.LpConstraint(
416
- e=P_hybrid_inverter[i]
417
- - P_def_sum[i]
418
- - P_load[i]
419
- + P_grid_neg[i]
420
- + P_grid_pos[i],
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],
421
542
  sense=plp.LpConstraintEQ,
422
543
  rhs=0,
423
544
  )
424
- for i in set_I
545
+ for i in set_i
425
546
  }
426
547
  else:
427
548
  if self.plant_conf["compute_curtailment"]:
428
549
  constraints = {
429
- "constraint_main2_{}".format(i): plp.LpConstraint(
430
- e=P_PV[i]
431
- - P_PV_curtailment[i]
432
- - P_def_sum[i]
433
- - P_load[i]
434
- + P_grid_neg[i]
435
- + P_grid_pos[i]
436
- + P_sto_pos[i]
437
- + P_sto_neg[i],
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],
438
559
  sense=plp.LpConstraintEQ,
439
560
  rhs=0,
440
561
  )
441
- for i in set_I
562
+ for i in set_i
442
563
  }
443
564
  else:
444
565
  constraints = {
445
- "constraint_main3_{}".format(i): plp.LpConstraint(
446
- e=P_PV[i]
447
- - P_def_sum[i]
448
- - P_load[i]
449
- + P_grid_neg[i]
450
- + P_grid_pos[i]
451
- + P_sto_pos[i]
452
- + P_sto_neg[i],
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],
453
574
  sense=plp.LpConstraintEQ,
454
575
  rhs=0,
455
576
  )
456
- for i in set_I
577
+ for i in set_i
457
578
  }
458
579
 
459
- # Constraint for hybrid inverter and curtailment cases
460
- if type(self.plant_conf["pv_module_model"]) == list:
461
- P_nom_inverter = 0.0
462
- for i in range(len(self.plant_conf["pv_inverter_model"])):
463
- if type(self.plant_conf["pv_inverter_model"][i]) == str:
464
- cec_inverters = bz2.BZ2File(
465
- pathlib.Path(__file__).parent / "data/cec_inverters.pbz2", "rb"
466
- )
467
- cec_inverters = cPickle.load(cec_inverters)
468
- inverter = cec_inverters[self.plant_conf["pv_inverter_model"][i]]
469
- P_nom_inverter += inverter.Paco
470
- else:
471
- P_nom_inverter += self.plant_conf["pv_inverter_model"][i]
472
- else:
473
- if type(self.plant_conf["pv_inverter_model"][i]) == str:
474
- cec_inverters = bz2.BZ2File(
475
- pathlib.Path(__file__).parent / "data/cec_inverters.pbz2", "rb"
476
- )
477
- cec_inverters = cPickle.load(cec_inverters)
478
- inverter = cec_inverters[self.plant_conf["pv_inverter_model"]]
479
- P_nom_inverter = inverter.Paco
480
- else:
481
- P_nom_inverter = self.plant_conf["pv_inverter_model"]
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
+
482
600
  if self.plant_conf["inverter_is_hybrid"]:
483
- constraints.update(
484
- {
485
- "constraint_hybrid_inverter1_{}".format(i): plp.LpConstraint(
486
- e=P_PV[i]
487
- - P_PV_curtailment[i]
488
- + P_sto_pos[i]
489
- + P_sto_neg[i]
490
- - P_nom_inverter,
491
- sense=plp.LpConstraintLE,
492
- rhs=0,
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]
493
674
  )
494
- for i in set_I
495
- }
496
- )
497
- constraints.update(
498
- {
499
- "constraint_hybrid_inverter2_{}".format(i): plp.LpConstraint(
500
- e=P_PV[i]
501
- - P_PV_curtailment[i]
502
- + P_sto_pos[i]
503
- + P_sto_neg[i]
504
- - P_hybrid_inverter[i],
505
- sense=plp.LpConstraintEQ,
506
- rhs=0,
675
+ else:
676
+ e_dc_balance = (p_pv[i] + p_sto_pos[i] + p_sto_neg[i]) - (
677
+ p_dc_ac[i] - p_ac_dc[i]
507
678
  )
508
- for i in set_I
509
- }
510
- )
511
- else:
512
- if self.plant_conf["compute_curtailment"]:
679
+
513
680
  constraints.update(
514
681
  {
515
- "constraint_curtailment_{}".format(i): plp.LpConstraint(
516
- e=P_PV_curtailment[i] - max(P_PV[i], 0),
517
- sense=plp.LpConstraintLE,
682
+ f"constraint_dc_bus_balance_{i}": plp.LpConstraint(
683
+ e=e_dc_balance,
684
+ sense=plp.LpConstraintEQ,
518
685
  rhs=0,
519
686
  )
520
- for i in set_I
521
687
  }
522
688
  )
523
689
 
524
- # Two special constraints just for a self-consumption cost function
525
- if self.costfun == "self-consumption":
526
- if type_self_conso == "maxmin": # maxmin linear problem
690
+ # The AC power is defined by the efficiency-adjusted DC flows
527
691
  constraints.update(
528
692
  {
529
- "constraint_selfcons_PV1_{}".format(i): plp.LpConstraint(
530
- e=SC[i] - P_PV[i], sense=plp.LpConstraintLE, rhs=0
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,
531
698
  )
532
- for i in set_I
533
699
  }
534
700
  )
701
+
702
+ # Use the binary variable to ensure only one direction is active at a time
535
703
  constraints.update(
536
704
  {
537
- "constraint_selfcons_PV2_{}".format(i): plp.LpConstraint(
538
- e=SC[i] - P_load[i] - P_def_sum[i],
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,
539
708
  sense=plp.LpConstraintLE,
540
709
  rhs=0,
541
- )
542
- for i in set_I
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
+ ),
543
717
  }
544
718
  )
545
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
+ )
749
+
750
+ # Two special constraints just for a self-consumption cost function
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
+ )
772
+
546
773
  # Avoid injecting and consuming from grid at the same time
547
774
  constraints.update(
548
775
  {
549
- "constraint_pgridpos_{}".format(i): plp.LpConstraint(
550
- e=P_grid_pos[i] - self.plant_conf["maximum_power_from_grid"] * D[i],
776
+ f"constraint_pgridpos_{i}": plp.LpConstraint(
777
+ e=p_grid_pos[i] - self.plant_conf["maximum_power_from_grid"] * D[i],
551
778
  sense=plp.LpConstraintLE,
552
779
  rhs=0,
553
780
  )
554
- for i in set_I
781
+ for i in set_i
555
782
  }
556
783
  )
557
784
  constraints.update(
558
785
  {
559
- "constraint_pgridneg_{}".format(i): plp.LpConstraint(
560
- e=-P_grid_neg[i]
561
- - self.plant_conf["maximum_power_to_grid"] * (1 - D[i]),
786
+ f"constraint_pgridneg_{i}": plp.LpConstraint(
787
+ e=-p_grid_neg[i] - self.plant_conf["maximum_power_to_grid"] * (1 - D[i]),
562
788
  sense=plp.LpConstraintLE,
563
789
  rhs=0,
564
790
  )
565
- for i in set_I
791
+ for i in set_i
566
792
  }
567
793
  )
568
794
 
569
795
  # Treat deferrable loads constraints
570
796
  predicted_temps = {}
797
+ heating_demands = {} # Store heating demand for thermal loads
571
798
  for k in range(self.optim_conf["number_of_deferrable_loads"]):
572
- if type(self.optim_conf["nominal_power_of_deferrable_loads"][k]) == list:
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
+ )
573
804
  # Constraint for sequence of deferrable
574
805
  # WARNING: This is experimental, formulation seems correct but feasibility problems.
575
806
  # Probably uncomptabile with other constraints
@@ -584,8 +815,9 @@ class Optimization:
584
815
  return matrix
585
816
 
586
817
  matrix = create_matrix(power_sequence, n - sequence_length)
587
- y = plp.LpVariable.dicts(
588
- f"y{k}", (i for i in range(len(matrix))), cat="Binary"
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())}"
589
821
  )
590
822
  constraints.update(
591
823
  {
@@ -599,8 +831,7 @@ class Optimization:
599
831
  constraints.update(
600
832
  {
601
833
  f"pdef{k}_sumconstraint_{i}": plp.LpConstraint(
602
- e=plp.lpSum(P_deferrable[k][i] for i in set_I)
603
- - np.sum(power_sequence),
834
+ e=plp.lpSum(p_deferrable[k][i] for i in set_i) - np.sum(power_sequence),
604
835
  sense=plp.LpConstraintEQ,
605
836
  rhs=0,
606
837
  )
@@ -609,141 +840,459 @@ class Optimization:
609
840
  constraints.update(
610
841
  {
611
842
  f"pdef{k}_positive_constraint_{i}": plp.LpConstraint(
612
- e=P_deferrable[k][i], sense=plp.LpConstraintGE, rhs=0
843
+ e=p_deferrable[k][i], sense=plp.LpConstraintGE, rhs=0
613
844
  )
614
- for i in set_I
845
+ for i in set_i
615
846
  }
616
847
  )
617
848
  for num, mat in enumerate(matrix):
618
849
  constraints.update(
619
850
  {
620
851
  f"pdef{k}_value_constraint_{num}_{i}": plp.LpConstraint(
621
- e=P_deferrable[k][i] - mat[i] * y[num],
852
+ e=p_deferrable[k][i] - mat[i] * y[num],
622
853
  sense=plp.LpConstraintEQ,
623
854
  rhs=0,
624
855
  )
625
- for i in set_I
856
+ for i in set_i
626
857
  }
627
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"]
628
871
 
629
- elif "def_load_config" in self.optim_conf.keys():
630
- if "thermal_config" in self.optim_conf["def_load_config"][k]:
631
- # Special case of a thermal deferrable load
632
- def_load_config = self.optim_conf["def_load_config"][k]
633
- if def_load_config and "thermal_config" in def_load_config:
634
- hc = def_load_config["thermal_config"]
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:
635
876
  start_temperature = hc["start_temperature"]
636
- cooling_constant = hc["cooling_constant"]
637
- heating_rate = hc["heating_rate"]
638
- overshoot_temperature = hc["overshoot_temperature"]
639
- outdoor_temperature_forecast = data_opt[
640
- "outdoor_temperature_forecast"
641
- ]
642
- desired_temperatures = hc["desired_temperatures"]
643
- sense = hc.get("sense", "heat")
644
- predicted_temp = [start_temperature]
645
- for I in set_I:
646
- if I == 0:
647
- continue
648
- predicted_temp.append(
649
- predicted_temp[I - 1]
650
- + (
651
- P_deferrable[k][I - 1]
652
- * (
653
- heating_rate
654
- * self.timeStep
655
- / self.optim_conf[
656
- "nominal_power_of_deferrable_loads"
657
- ][k]
658
- )
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]
659
910
  )
660
- - (
661
- cooling_constant
662
- * (
663
- predicted_temp[I - 1]
664
- - outdoor_temperature_forecast[I - 1]
665
- )
911
+ )
912
+ - (
913
+ cooling_constant
914
+ * self.time_step
915
+ * (
916
+ predicted_temp[index - 1]
917
+ - outdoor_temperature_forecast.iloc[index - 1]
666
918
  )
667
919
  )
668
- if (
669
- len(desired_temperatures) > I
670
- and desired_temperatures[I]
671
- ):
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],
932
+ )
933
+ }
934
+ )
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
+ )
672
995
  constraints.update(
673
996
  {
674
- "constraint_defload{}_temperature_{}".format(
675
- k, I
676
- ): plp.LpConstraint(
677
- e=predicted_temp[I],
678
- sense=plp.LpConstraintGE
679
- if sense == "heat"
680
- else plp.LpConstraintLE,
681
- rhs=desired_temperatures[I],
997
+ f"constraint_defload{k}_penalty_{index}": plp.LpConstraint(
998
+ e=penalty_var - penalty_value,
999
+ sense=plp.LpConstraintLE,
1000
+ rhs=0,
682
1001
  )
683
1002
  }
684
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]:
685
1008
  constraints.update(
686
1009
  {
687
- "constraint_defload{}_overshoot_temp_{}".format(
688
- k, I
689
- ): plp.LpConstraint(
690
- e=predicted_temp[I],
691
- sense=plp.LpConstraintLE
692
- if sense == "heat"
693
- else plp.LpConstraintGE,
694
- rhs=overshoot_temperature,
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,
695
1018
  )
696
- for I in set_I
1019
+ for i in set_i
697
1020
  }
698
1021
  )
699
- predicted_temps[k] = predicted_temp
700
1022
 
701
- else:
702
- if def_total_hours[k] > 0:
703
- # Total time of deferrable load
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
+ )
704
1255
  constraints.update(
705
1256
  {
706
- "constraint_defload{}_energy".format(k): plp.LpConstraint(
707
- e=plp.lpSum(
708
- P_deferrable[k][i] * self.timeStep for i in set_I
709
- ),
1257
+ f"constraint_defload{k}_energy": plp.LpConstraint(
1258
+ e=plp.lpSum(p_deferrable[k][i] * self.time_step for i in set_i),
710
1259
  sense=plp.LpConstraintEQ,
711
1260
  rhs=def_total_hours[k]
712
- * self.optim_conf["nominal_power_of_deferrable_loads"][
713
- k
714
- ],
1261
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k],
715
1262
  )
716
1263
  }
717
1264
  )
1265
+ self.logger.debug(f"Load {k}: Standard load constraints set.")
718
1266
 
719
1267
  # Ensure deferrable loads consume energy between def_start_timestep & def_end_timestep
720
1268
  self.logger.debug(
721
- "Deferrable load {}: Proposed optimization window: {} --> {}".format(
722
- k, def_start_timestep[k], def_end_timestep[k]
723
- )
724
- )
725
- def_start, def_end, warning = Optimization.validate_def_timewindow(
726
- def_start_timestep[k],
727
- def_end_timestep[k],
728
- ceil(def_total_hours[k] / self.timeStep),
729
- n,
1269
+ f"Deferrable load {k}: Proposed optimization window: {def_start_timestep[k]} --> {def_end_timestep[k]}"
730
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
+ )
731
1285
  if warning is not None:
732
- self.logger.warning("Deferrable load {} : {}".format(k, warning))
1286
+ self.logger.warning(f"Deferrable load {k} : {warning}")
733
1287
  self.logger.debug(
734
- "Deferrable load {}: Validated optimization window: {} --> {}".format(
735
- k, def_start, def_end
736
- )
1288
+ f"Deferrable load {k}: Validated optimization window: {def_start} --> {def_end}"
737
1289
  )
738
1290
  if def_start > 0:
739
1291
  constraints.update(
740
1292
  {
741
- "constraint_defload{}_start_timestep".format(
742
- k
743
- ): plp.LpConstraint(
1293
+ f"constraint_defload{k}_start_timestep": plp.LpConstraint(
744
1294
  e=plp.lpSum(
745
- P_deferrable[k][i] * self.timeStep
746
- for i in range(0, def_start)
1295
+ p_deferrable[k][i] * self.time_step for i in range(0, def_start)
747
1296
  ),
748
1297
  sense=plp.LpConstraintEQ,
749
1298
  rhs=0,
@@ -753,10 +1302,9 @@ class Optimization:
753
1302
  if def_end > 0:
754
1303
  constraints.update(
755
1304
  {
756
- "constraint_defload{}_end_timestep".format(k): plp.LpConstraint(
1305
+ f"constraint_defload{k}_end_timestep": plp.LpConstraint(
757
1306
  e=plp.lpSum(
758
- P_deferrable[k][i] * self.timeStep
759
- for i in range(def_end, n)
1307
+ p_deferrable[k][i] * self.time_step for i in range(def_end, n)
760
1308
  ),
761
1309
  sense=plp.LpConstraintEQ,
762
1310
  rhs=0,
@@ -764,6 +1312,24 @@ class Optimization:
764
1312
  }
765
1313
  )
766
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
+
767
1333
  # Treat the number of starts for a deferrable load (new method considering current state)
768
1334
  current_state = 0
769
1335
  if (
@@ -771,162 +1337,149 @@ class Optimization:
771
1337
  and len(self.optim_conf["def_current_state"]) > k
772
1338
  ):
773
1339
  current_state = 1 if self.optim_conf["def_current_state"][k] else 0
774
- # P_deferrable < P_def_bin2 * 1 million
775
- # P_deferrable must be zero if P_def_bin2 is zero
1340
+ # p_deferrable < p_def_bin2 * 1 million
1341
+ # p_deferrable must be zero if p_def_bin2 is zero
776
1342
  constraints.update(
777
1343
  {
778
- "constraint_pdef{}_start1_{}".format(k, i): plp.LpConstraint(
779
- e=P_deferrable[k][i] - P_def_bin2[k][i] * M,
1344
+ f"constraint_pdef{k}_start1_{i}": plp.LpConstraint(
1345
+ e=p_deferrable[k][i] - p_def_bin2[k][i] * M,
780
1346
  sense=plp.LpConstraintLE,
781
1347
  rhs=0,
782
1348
  )
783
- for i in set_I
1349
+ for i in set_i
784
1350
  }
785
1351
  )
786
- # P_deferrable - P_def_bin2 <= 0
787
- # P_def_bin2 must be zero if P_deferrable is zero
1352
+ # p_deferrable - p_def_bin2 <= 0
1353
+ # p_def_bin2 must be zero if p_deferrable is zero
788
1354
  constraints.update(
789
1355
  {
790
- "constraint_pdef{}_start1a_{}".format(k, i): plp.LpConstraint(
791
- e=P_def_bin2[k][i] - P_deferrable[k][i],
1356
+ f"constraint_pdef{k}_start1a_{i}": plp.LpConstraint(
1357
+ e=p_def_bin2[k][i] - p_deferrable[k][i],
792
1358
  sense=plp.LpConstraintLE,
793
1359
  rhs=0,
794
1360
  )
795
- for i in set_I
1361
+ for i in set_i
796
1362
  }
797
1363
  )
798
- # P_def_start + P_def_bin2[i-1] >= P_def_bin2[i]
799
- # 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
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
800
1366
  # For first timestep, use current state if provided by caller.
801
1367
  constraints.update(
802
1368
  {
803
- "constraint_pdef{}_start2_{}".format(k, i): plp.LpConstraint(
804
- e=P_def_start[k][i]
805
- - P_def_bin2[k][i]
806
- + (P_def_bin2[k][i - 1] if i - 1 >= 0 else current_state),
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),
807
1373
  sense=plp.LpConstraintGE,
808
1374
  rhs=0,
809
1375
  )
810
- for i in set_I
1376
+ for i in set_i
811
1377
  }
812
1378
  )
813
- # P_def_bin2[i-1] + P_def_start <= 1
814
- # If load started this cycle (P_def_start[i] is 1) then P_def_bin2[i-1] must be 0
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
815
1381
  constraints.update(
816
1382
  {
817
- "constraint_pdef{}_start3_{}".format(k, i): plp.LpConstraint(
818
- e=(P_def_bin2[k][i - 1] if i - 1 >= 0 else 0)
819
- + P_def_start[k][i],
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],
820
1385
  sense=plp.LpConstraintLE,
821
1386
  rhs=1,
822
1387
  )
823
- for i in set_I
1388
+ for i in set_i
824
1389
  }
825
1390
  )
826
1391
 
827
1392
  # Treat deferrable as a fixed value variable with just one startup
828
1393
  if self.optim_conf["set_deferrable_load_single_constant"][k]:
829
- # P_def_start[i] must be 1 for exactly 1 value of i
1394
+ # p_def_start[i] must be 1 for exactly 1 value of i
830
1395
  constraints.update(
831
1396
  {
832
- "constraint_pdef{}_start4".format(k): plp.LpConstraint(
833
- e=plp.lpSum(P_def_start[k][i] for i in set_I),
1397
+ f"constraint_pdef{k}_start4": plp.LpConstraint(
1398
+ e=plp.lpSum(p_def_start[k][i] for i in set_i),
834
1399
  sense=plp.LpConstraintEQ,
835
1400
  rhs=1,
836
1401
  )
837
1402
  }
838
1403
  )
839
- # P_def_bin2 must be 1 for exactly the correct number of timesteps.
840
- constraints.update(
841
- {
842
- "constraint_pdef{}_start5".format(k): plp.LpConstraint(
843
- e=plp.lpSum(P_def_bin2[k][i] for i in set_I),
844
- sense=plp.LpConstraintEQ,
845
- rhs=def_total_hours[k] / self.timeStep,
846
- )
847
- }
848
- )
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
+ )
849
1425
 
850
1426
  # Treat deferrable load as a semi-continuous variable
851
1427
  if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
852
1428
  constraints.update(
853
1429
  {
854
- "constraint_pdef{}_semicont1_{}".format(k, i): plp.LpConstraint(
855
- e=P_deferrable[k][i]
1430
+ f"constraint_pdef{k}_semicont1_{i}": plp.LpConstraint(
1431
+ e=p_deferrable[k][i]
856
1432
  - self.optim_conf["nominal_power_of_deferrable_loads"][k]
857
- * P_def_bin1[k][i],
1433
+ * p_def_bin1[k][i],
858
1434
  sense=plp.LpConstraintGE,
859
1435
  rhs=0,
860
1436
  )
861
- for i in set_I
1437
+ for i in set_i
862
1438
  }
863
1439
  )
864
1440
  constraints.update(
865
1441
  {
866
- "constraint_pdef{}_semicont2_{}".format(k, i): plp.LpConstraint(
867
- e=P_deferrable[k][i]
1442
+ f"constraint_pdef{k}_semicont2_{i}": plp.LpConstraint(
1443
+ e=p_deferrable[k][i]
868
1444
  - self.optim_conf["nominal_power_of_deferrable_loads"][k]
869
- * P_def_bin1[k][i],
1445
+ * p_def_bin1[k][i],
870
1446
  sense=plp.LpConstraintLE,
871
1447
  rhs=0,
872
1448
  )
873
- for i in set_I
1449
+ for i in set_i
874
1450
  }
875
1451
  )
876
1452
 
877
- # Treat the number of starts for a deferrable load (old method, kept here just in case)
878
- # if self.optim_conf['set_deferrable_load_single_constant'][k]:
879
- # constraints.update({"constraint_pdef{}_start1_{}".format(k, i) :
880
- # plp.LpConstraint(
881
- # e=P_deferrable[k][i] - P_def_bin2[k][i]*M,
882
- # sense=plp.LpConstraintLE,
883
- # rhs=0)
884
- # for i in set_I})
885
- # constraints.update({"constraint_pdef{}_start2_{}".format(k, i):
886
- # plp.LpConstraint(
887
- # e=P_def_start[k][i] - P_def_bin2[k][i] + (P_def_bin2[k][i-1] if i-1 >= 0 else 0),
888
- # sense=plp.LpConstraintGE,
889
- # rhs=0)
890
- # for i in set_I})
891
- # constraints.update({"constraint_pdef{}_start3".format(k) :
892
- # plp.LpConstraint(
893
- # e = plp.lpSum(P_def_start[k][i] for i in set_I),
894
- # sense = plp.LpConstraintEQ,
895
- # rhs = 1)
896
- # })
897
-
898
1453
  # The battery constraints
899
1454
  if self.optim_conf["set_use_battery"]:
900
1455
  # Optional constraints to avoid charging the battery from the grid
901
1456
  if self.optim_conf["set_nocharge_from_grid"]:
902
1457
  constraints.update(
903
1458
  {
904
- "constraint_nocharge_from_grid_{}".format(i): plp.LpConstraint(
905
- e=P_sto_neg[i] + P_PV[i], sense=plp.LpConstraintGE, rhs=0
1459
+ f"constraint_nocharge_from_grid_{i}": plp.LpConstraint(
1460
+ e=p_sto_neg[i] + p_pv[i], sense=plp.LpConstraintGE, rhs=0
906
1461
  )
907
- for i in set_I
1462
+ for i in set_i
908
1463
  }
909
1464
  )
910
1465
  # Optional constraints to avoid discharging the battery to the grid
911
1466
  if self.optim_conf["set_nodischarge_to_grid"]:
912
1467
  constraints.update(
913
1468
  {
914
- "constraint_nodischarge_to_grid_{}".format(i): plp.LpConstraint(
915
- e=P_grid_neg[i] + P_PV[i], sense=plp.LpConstraintGE, rhs=0
1469
+ f"constraint_nodischarge_to_grid_{i}": plp.LpConstraint(
1470
+ e=p_grid_neg[i] + p_pv[i], sense=plp.LpConstraintGE, rhs=0
916
1471
  )
917
- for i in set_I
1472
+ for i in set_i
918
1473
  }
919
1474
  )
920
1475
  # Limitation of power dynamics in power per unit of time
921
1476
  if self.optim_conf["set_battery_dynamic"]:
922
1477
  constraints.update(
923
1478
  {
924
- "constraint_pos_batt_dynamic_max_{}".format(
925
- i
926
- ): plp.LpConstraint(
927
- e=P_sto_pos[i + 1] - P_sto_pos[i],
1479
+ f"constraint_pos_batt_dynamic_max_{i}": plp.LpConstraint(
1480
+ e=p_sto_pos[i + 1] - p_sto_pos[i],
928
1481
  sense=plp.LpConstraintLE,
929
- rhs=self.timeStep
1482
+ rhs=self.time_step
930
1483
  * self.optim_conf["battery_dynamic_max"]
931
1484
  * self.plant_conf["battery_discharge_power_max"],
932
1485
  )
@@ -935,12 +1488,10 @@ class Optimization:
935
1488
  )
936
1489
  constraints.update(
937
1490
  {
938
- "constraint_pos_batt_dynamic_min_{}".format(
939
- i
940
- ): plp.LpConstraint(
941
- e=P_sto_pos[i + 1] - P_sto_pos[i],
1491
+ f"constraint_pos_batt_dynamic_min_{i}": plp.LpConstraint(
1492
+ e=p_sto_pos[i + 1] - p_sto_pos[i],
942
1493
  sense=plp.LpConstraintGE,
943
- rhs=self.timeStep
1494
+ rhs=self.time_step
944
1495
  * self.optim_conf["battery_dynamic_min"]
945
1496
  * self.plant_conf["battery_discharge_power_max"],
946
1497
  )
@@ -949,12 +1500,10 @@ class Optimization:
949
1500
  )
950
1501
  constraints.update(
951
1502
  {
952
- "constraint_neg_batt_dynamic_max_{}".format(
953
- i
954
- ): plp.LpConstraint(
955
- e=P_sto_neg[i + 1] - P_sto_neg[i],
1503
+ f"constraint_neg_batt_dynamic_max_{i}": plp.LpConstraint(
1504
+ e=p_sto_neg[i + 1] - p_sto_neg[i],
956
1505
  sense=plp.LpConstraintLE,
957
- rhs=self.timeStep
1506
+ rhs=self.time_step
958
1507
  * self.optim_conf["battery_dynamic_max"]
959
1508
  * self.plant_conf["battery_charge_power_max"],
960
1509
  )
@@ -963,12 +1512,10 @@ class Optimization:
963
1512
  )
964
1513
  constraints.update(
965
1514
  {
966
- "constraint_neg_batt_dynamic_min_{}".format(
967
- i
968
- ): plp.LpConstraint(
969
- e=P_sto_neg[i + 1] - P_sto_neg[i],
1515
+ f"constraint_neg_batt_dynamic_min_{i}": plp.LpConstraint(
1516
+ e=p_sto_neg[i + 1] - p_sto_neg[i],
970
1517
  sense=plp.LpConstraintGE,
971
- rhs=self.timeStep
1518
+ rhs=self.time_step
972
1519
  * self.optim_conf["battery_dynamic_min"]
973
1520
  * self.plant_conf["battery_charge_power_max"],
974
1521
  )
@@ -978,106 +1525,98 @@ class Optimization:
978
1525
  # Then the classic battery constraints
979
1526
  constraints.update(
980
1527
  {
981
- "constraint_pstopos_{}".format(i): plp.LpConstraint(
982
- e=P_sto_pos[i]
1528
+ f"constraint_pstopos_{i}": plp.LpConstraint(
1529
+ e=p_sto_pos[i]
983
1530
  - self.plant_conf["battery_discharge_efficiency"]
984
1531
  * self.plant_conf["battery_discharge_power_max"]
985
1532
  * E[i],
986
1533
  sense=plp.LpConstraintLE,
987
1534
  rhs=0,
988
1535
  )
989
- for i in set_I
1536
+ for i in set_i
990
1537
  }
991
1538
  )
992
1539
  constraints.update(
993
1540
  {
994
- "constraint_pstoneg_{}".format(i): plp.LpConstraint(
995
- e=-P_sto_neg[i]
1541
+ f"constraint_pstoneg_{i}": plp.LpConstraint(
1542
+ e=-p_sto_neg[i]
996
1543
  - (1 / self.plant_conf["battery_charge_efficiency"])
997
1544
  * self.plant_conf["battery_charge_power_max"]
998
1545
  * (1 - E[i]),
999
1546
  sense=plp.LpConstraintLE,
1000
1547
  rhs=0,
1001
1548
  )
1002
- for i in set_I
1549
+ for i in set_i
1003
1550
  }
1004
1551
  )
1005
1552
  constraints.update(
1006
1553
  {
1007
- "constraint_socmax_{}".format(i): plp.LpConstraint(
1554
+ f"constraint_socmax_{i}": plp.LpConstraint(
1008
1555
  e=-plp.lpSum(
1009
- P_sto_pos[j]
1010
- * (1 / self.plant_conf["battery_discharge_efficiency"])
1011
- + self.plant_conf["battery_charge_efficiency"]
1012
- * P_sto_neg[j]
1556
+ p_sto_pos[j] * (1 / self.plant_conf["battery_discharge_efficiency"])
1557
+ + self.plant_conf["battery_charge_efficiency"] * p_sto_neg[j]
1013
1558
  for j in range(i)
1014
1559
  ),
1015
1560
  sense=plp.LpConstraintLE,
1016
- rhs=(
1017
- self.plant_conf["battery_nominal_energy_capacity"]
1018
- / self.timeStep
1019
- )
1020
- * (
1021
- self.plant_conf["battery_maximum_state_of_charge"]
1022
- - soc_init
1023
- ),
1561
+ rhs=(self.plant_conf["battery_nominal_energy_capacity"] / self.time_step)
1562
+ * (self.plant_conf["battery_maximum_state_of_charge"] - soc_init),
1024
1563
  )
1025
- for i in set_I
1564
+ for i in set_i
1026
1565
  }
1027
1566
  )
1028
1567
  constraints.update(
1029
1568
  {
1030
- "constraint_socmin_{}".format(i): plp.LpConstraint(
1569
+ f"constraint_socmin_{i}": plp.LpConstraint(
1031
1570
  e=plp.lpSum(
1032
- P_sto_pos[j]
1033
- * (1 / self.plant_conf["battery_discharge_efficiency"])
1034
- + self.plant_conf["battery_charge_efficiency"]
1035
- * P_sto_neg[j]
1571
+ p_sto_pos[j] * (1 / self.plant_conf["battery_discharge_efficiency"])
1572
+ + self.plant_conf["battery_charge_efficiency"] * p_sto_neg[j]
1036
1573
  for j in range(i)
1037
1574
  ),
1038
1575
  sense=plp.LpConstraintLE,
1039
- rhs=(
1040
- self.plant_conf["battery_nominal_energy_capacity"]
1041
- / self.timeStep
1042
- )
1043
- * (
1044
- soc_init
1045
- - self.plant_conf["battery_minimum_state_of_charge"]
1046
- ),
1576
+ rhs=(self.plant_conf["battery_nominal_energy_capacity"] / self.time_step)
1577
+ * (soc_init - self.plant_conf["battery_minimum_state_of_charge"]),
1047
1578
  )
1048
- for i in set_I
1579
+ for i in set_i
1049
1580
  }
1050
1581
  )
1051
1582
  constraints.update(
1052
1583
  {
1053
- "constraint_socfinal_{}".format(0): plp.LpConstraint(
1584
+ f"constraint_socfinal_{0}": plp.LpConstraint(
1054
1585
  e=plp.lpSum(
1055
- P_sto_pos[i]
1056
- * (1 / self.plant_conf["battery_discharge_efficiency"])
1057
- + self.plant_conf["battery_charge_efficiency"]
1058
- * P_sto_neg[i]
1059
- for i in set_I
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
1060
1589
  ),
1061
1590
  sense=plp.LpConstraintEQ,
1062
1591
  rhs=(soc_init - soc_final)
1063
1592
  * self.plant_conf["battery_nominal_energy_capacity"]
1064
- / self.timeStep,
1593
+ / self.time_step,
1065
1594
  )
1066
1595
  }
1067
1596
  )
1068
1597
  opt_model.constraints = constraints
1069
1598
 
1070
1599
  ## Finally, we call the solver to solve our optimization model:
1600
+ timeout = self.optim_conf["lp_solver_timeout"]
1071
1601
  # solving with default solver CBC
1072
1602
  if self.lp_solver == "PULP_CBC_CMD":
1073
- opt_model.solve(PULP_CBC_CMD(msg=0))
1603
+ opt_model.solve(PULP_CBC_CMD(msg=0, timeLimit=timeout, threads=self.num_threads))
1074
1604
  elif self.lp_solver == "GLPK_CMD":
1075
- opt_model.solve(GLPK_CMD(msg=0))
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))
1076
1608
  elif self.lp_solver == "COIN_CMD":
1077
- opt_model.solve(COIN_CMD(msg=0, path=self.lp_solver_path))
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
+ )
1078
1617
  else:
1079
1618
  self.logger.warning("Solver %s unknown, using default", self.lp_solver)
1080
- opt_model.solve()
1619
+ opt_model.solve(PULP_CBC_CMD(msg=0, timeLimit=timeout, threads=self.num_threads))
1081
1620
 
1082
1621
  # The status of the solution is printed to the screen
1083
1622
  self.optim_status = plp.LpStatus[opt_model.status]
@@ -1093,125 +1632,122 @@ class Optimization:
1093
1632
 
1094
1633
  # Build results Dataframe
1095
1634
  opt_tp = pd.DataFrame()
1096
- opt_tp["P_PV"] = [P_PV[i] for i in set_I]
1097
- opt_tp["P_Load"] = [P_load[i] 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]
1098
1637
  for k in range(self.optim_conf["number_of_deferrable_loads"]):
1099
- opt_tp["P_deferrable{}".format(k)] = [
1100
- P_deferrable[k][i].varValue for i in set_I
1101
- ]
1102
- opt_tp["P_grid_pos"] = [P_grid_pos[i].varValue for i in set_I]
1103
- opt_tp["P_grid_neg"] = [P_grid_neg[i].varValue for i in set_I]
1104
- opt_tp["P_grid"] = [
1105
- P_grid_pos[i].varValue + P_grid_neg[i].varValue for i in set_I
1106
- ]
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]
1107
1642
  if self.optim_conf["set_use_battery"]:
1108
- opt_tp["P_batt"] = [
1109
- P_sto_pos[i].varValue + P_sto_neg[i].varValue for i in set_I
1110
- ]
1111
- SOC_opt_delta = [
1643
+ opt_tp["P_batt"] = [p_sto_pos[i].varValue + p_sto_neg[i].varValue for i in set_i]
1644
+ soc_opt_delta = [
1112
1645
  (
1113
- P_sto_pos[i].varValue
1114
- * (1 / self.plant_conf["battery_discharge_efficiency"])
1115
- + self.plant_conf["battery_charge_efficiency"]
1116
- * P_sto_neg[i].varValue
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
1117
1648
  )
1118
- * (self.timeStep / (self.plant_conf["battery_nominal_energy_capacity"]))
1119
- for i in set_I
1649
+ * (self.time_step / (self.plant_conf["battery_nominal_energy_capacity"]))
1650
+ for i in set_i
1120
1651
  ]
1121
- SOCinit = copy.copy(soc_init)
1122
- SOC_opt = []
1123
- for i in set_I:
1124
- SOC_opt.append(SOCinit - SOC_opt_delta[i])
1125
- SOCinit = SOC_opt[i]
1126
- opt_tp["SOC_opt"] = SOC_opt
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
+
1127
1663
  if self.plant_conf["inverter_is_hybrid"]:
1128
- opt_tp["P_hybrid_inverter"] = [P_hybrid_inverter[i].varValue for i in set_I]
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
+
1129
1669
  if self.plant_conf["compute_curtailment"]:
1130
- opt_tp["P_PV_curtailment"] = [P_PV_curtailment[i].varValue for i in set_I]
1670
+ opt_tp["P_PV_curtailment"] = [p_pv_curtailment[i].varValue for i in set_i]
1131
1671
  opt_tp.index = data_opt.index
1132
1672
 
1133
1673
  # Lets compute the optimal cost function
1134
- P_def_sum_tp = []
1135
- for i in set_I:
1136
- P_def_sum_tp.append(
1674
+ p_def_sum_tp = []
1675
+ for i in set_i:
1676
+ p_def_sum_tp.append(
1137
1677
  sum(
1138
- P_deferrable[k][i].varValue
1678
+ p_deferrable[k][i].varValue
1139
1679
  for k in range(self.optim_conf["number_of_deferrable_loads"])
1140
1680
  )
1141
1681
  )
1142
- opt_tp["unit_load_cost"] = [unit_load_cost[i] for i in set_I]
1143
- opt_tp["unit_prod_price"] = [unit_prod_price[i] for i in set_I]
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]
1144
1684
  if self.optim_conf["set_total_pv_sell"]:
1145
1685
  opt_tp["cost_profit"] = [
1146
1686
  -0.001
1147
- * self.timeStep
1687
+ * self.time_step
1148
1688
  * (
1149
- unit_load_cost[i] * (P_load[i] + P_def_sum_tp[i])
1150
- + unit_prod_price[i] * P_grid_neg[i].varValue
1689
+ unit_load_cost[i] * (p_load[i] + p_def_sum_tp[i])
1690
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1151
1691
  )
1152
- for i in set_I
1692
+ for i in set_i
1153
1693
  ]
1154
1694
  else:
1155
1695
  opt_tp["cost_profit"] = [
1156
1696
  -0.001
1157
- * self.timeStep
1697
+ * self.time_step
1158
1698
  * (
1159
- unit_load_cost[i] * P_grid_pos[i].varValue
1160
- + unit_prod_price[i] * P_grid_neg[i].varValue
1699
+ unit_load_cost[i] * p_grid_pos[i].varValue
1700
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1161
1701
  )
1162
- for i in set_I
1702
+ for i in set_i
1163
1703
  ]
1164
1704
 
1165
1705
  if self.costfun == "profit":
1166
1706
  if self.optim_conf["set_total_pv_sell"]:
1167
1707
  opt_tp["cost_fun_profit"] = [
1168
1708
  -0.001
1169
- * self.timeStep
1709
+ * self.time_step
1170
1710
  * (
1171
- unit_load_cost[i] * (P_load[i] + P_def_sum_tp[i])
1172
- + unit_prod_price[i] * P_grid_neg[i].varValue
1711
+ unit_load_cost[i] * (p_load[i] + p_def_sum_tp[i])
1712
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1173
1713
  )
1174
- for i in set_I
1714
+ for i in set_i
1175
1715
  ]
1176
1716
  else:
1177
1717
  opt_tp["cost_fun_profit"] = [
1178
1718
  -0.001
1179
- * self.timeStep
1719
+ * self.time_step
1180
1720
  * (
1181
- unit_load_cost[i] * P_grid_pos[i].varValue
1182
- + unit_prod_price[i] * P_grid_neg[i].varValue
1721
+ unit_load_cost[i] * p_grid_pos[i].varValue
1722
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1183
1723
  )
1184
- for i in set_I
1724
+ for i in set_i
1185
1725
  ]
1186
1726
  elif self.costfun == "cost":
1187
1727
  if self.optim_conf["set_total_pv_sell"]:
1188
1728
  opt_tp["cost_fun_cost"] = [
1189
- -0.001
1190
- * self.timeStep
1191
- * unit_load_cost[i]
1192
- * (P_load[i] + P_def_sum_tp[i])
1193
- for i in set_I
1729
+ -0.001 * self.time_step * unit_load_cost[i] * (p_load[i] + p_def_sum_tp[i])
1730
+ for i in set_i
1194
1731
  ]
1195
1732
  else:
1196
1733
  opt_tp["cost_fun_cost"] = [
1197
- -0.001 * self.timeStep * unit_load_cost[i] * P_grid_pos[i].varValue
1198
- for i in set_I
1734
+ -0.001 * self.time_step * unit_load_cost[i] * p_grid_pos[i].varValue
1735
+ for i in set_i
1199
1736
  ]
1200
1737
  elif self.costfun == "self-consumption":
1201
1738
  if type_self_conso == "maxmin":
1202
1739
  opt_tp["cost_fun_selfcons"] = [
1203
- -0.001 * self.timeStep * unit_load_cost[i] * SC[i].varValue
1204
- for i in set_I
1740
+ -0.001 * self.time_step * unit_load_cost[i] * SC[i].varValue for i in set_i
1205
1741
  ]
1206
1742
  elif type_self_conso == "bigm":
1207
1743
  opt_tp["cost_fun_selfcons"] = [
1208
1744
  -0.001
1209
- * self.timeStep
1745
+ * self.time_step
1210
1746
  * (
1211
- unit_load_cost[i] * P_grid_pos[i].varValue
1212
- + unit_prod_price[i] * P_grid_neg[i].varValue
1747
+ unit_load_cost[i] * p_grid_pos[i].varValue
1748
+ + unit_prod_price[i] * p_grid_neg[i].varValue
1213
1749
  )
1214
- for i in set_I
1750
+ for i in set_i
1215
1751
  ]
1216
1752
  else:
1217
1753
  self.logger.error("The cost function specified type is not valid")
@@ -1222,25 +1758,58 @@ class Optimization:
1222
1758
  # Debug variables
1223
1759
  if debug:
1224
1760
  for k in range(self.optim_conf["number_of_deferrable_loads"]):
1225
- opt_tp[f"P_def_start_{k}"] = [P_def_start[k][i].varValue for i in set_I]
1226
- opt_tp[f"P_def_bin2_{k}"] = [P_def_bin2[k][i].varValue for i in set_I]
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
+
1227
1764
  for i, predicted_temp in predicted_temps.items():
1228
1765
  opt_tp[f"predicted_temp_heater{i}"] = pd.Series(
1229
1766
  [
1230
- round(pt.value(), 2)
1231
- if isinstance(pt, plp.LpAffineExpression)
1232
- else pt
1767
+ round(pt.value(), 2) if isinstance(pt, plp.LpAffineExpression) else pt
1233
1768
  for pt in predicted_temp
1234
1769
  ],
1235
1770
  index=opt_tp.index,
1236
1771
  )
1237
- opt_tp[f"target_temp_heater{i}"] = pd.Series(
1238
- self.optim_conf["def_load_config"][i]["thermal_config"][
1239
- "desired_temperatures"
1240
- ],
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
+ )
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,
1241
1792
  index=opt_tp.index,
1242
1793
  )
1243
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}")
1244
1813
  return opt_tp
1245
1814
 
1246
1815
  def perform_perfect_forecast_optim(
@@ -1248,7 +1817,7 @@ class Optimization:
1248
1817
  ) -> pd.DataFrame:
1249
1818
  r"""
1250
1819
  Perform an optimization on historical data (perfectly known PV production).
1251
-
1820
+
1252
1821
  :param df_input_data: A DataFrame containing all the input data used for \
1253
1822
  the optimization, notably photovoltaics and load consumption powers.
1254
1823
  :type df_input_data: pandas.DataFrame
@@ -1267,26 +1836,40 @@ class Optimization:
1267
1836
  self.opt_res = pd.DataFrame()
1268
1837
  for day in self.days_list_tz:
1269
1838
  self.logger.info(
1270
- "Solving for day: "
1271
- + str(day.day)
1272
- + "-"
1273
- + str(day.month)
1274
- + "-"
1275
- + str(day.year)
1839
+ "Solving for day: " + str(day.day) + "-" + str(day.month) + "-" + str(day.year)
1276
1840
  )
1277
1841
  # Prepare data
1278
- day_start = day.isoformat()
1279
- day_end = (day + self.time_delta - self.freq).isoformat()
1280
- data_tp = df_input_data.copy().loc[
1281
- pd.date_range(start=day_start, end=day_end, freq=self.freq)
1282
- ]
1283
- P_PV = data_tp[self.var_PV].values
1284
- P_load = data_tp[self.var_load_new].values
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
1285
1868
  unit_load_cost = data_tp[self.var_load_cost].values # €/kWh
1286
1869
  unit_prod_price = data_tp[self.var_prod_price].values # €/kWh
1287
1870
  # Call optimization function
1288
1871
  opt_tp = self.perform_optimization(
1289
- data_tp, P_PV, P_load, unit_load_cost, unit_prod_price
1872
+ data_tp, p_pv, p_load, unit_load_cost, unit_prod_price
1290
1873
  )
1291
1874
  if len(self.opt_res) == 0:
1292
1875
  self.opt_res = opt_tp
@@ -1296,20 +1879,20 @@ class Optimization:
1296
1879
  return self.opt_res
1297
1880
 
1298
1881
  def perform_dayahead_forecast_optim(
1299
- self, df_input_data: pd.DataFrame, P_PV: pd.Series, P_load: pd.Series
1882
+ self, df_input_data: pd.DataFrame, p_pv: pd.Series, p_load: pd.Series
1300
1883
  ) -> pd.DataFrame:
1301
1884
  r"""
1302
1885
  Perform a day-ahead optimization task using real forecast data. \
1303
1886
  This type of optimization is intented to be launched once a day.
1304
-
1887
+
1305
1888
  :param df_input_data: A DataFrame containing all the input data used for \
1306
1889
  the optimization, notably the unit load cost for power consumption.
1307
1890
  :type df_input_data: pandas.DataFrame
1308
- :param P_PV: The forecasted PV power production.
1309
- :type P_PV: pandas.DataFrame
1310
- :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 \
1311
1894
  not include the power from the deferrable load that we want to find.
1312
- :type P_load: pandas.DataFrame
1895
+ :type p_load: pandas.DataFrame
1313
1896
  :return: opt_res: A DataFrame containing the optimization results
1314
1897
  :rtype: pandas.DataFrame
1315
1898
 
@@ -1320,8 +1903,8 @@ class Optimization:
1320
1903
  # Call optimization function
1321
1904
  self.opt_res = self.perform_optimization(
1322
1905
  df_input_data,
1323
- P_PV.values.ravel(),
1324
- P_load.values.ravel(),
1906
+ p_pv.values.ravel(),
1907
+ p_load.values.ravel(),
1325
1908
  unit_load_cost,
1326
1909
  unit_prod_price,
1327
1910
  )
@@ -1330,29 +1913,30 @@ class Optimization:
1330
1913
  def perform_naive_mpc_optim(
1331
1914
  self,
1332
1915
  df_input_data: pd.DataFrame,
1333
- P_PV: pd.Series,
1334
- P_load: pd.Series,
1916
+ p_pv: pd.Series,
1917
+ p_load: pd.Series,
1335
1918
  prediction_horizon: int,
1336
- soc_init: Optional[float] = None,
1337
- soc_final: Optional[float] = None,
1338
- def_total_hours: Optional[list] = None,
1339
- def_start_timestep: Optional[list] = None,
1340
- def_end_timestep: Optional[list] = None,
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,
1341
1925
  ) -> pd.DataFrame:
1342
1926
  r"""
1343
1927
  Perform a naive approach to a Model Predictive Control (MPC). \
1344
1928
  This implementaion is naive because we are not using the formal formulation \
1345
1929
  of a MPC. Only the sense of a receiding horizon is considered here. \
1346
1930
  This optimization is more suitable for higher optimization frequency, ex: 5min.
1347
-
1931
+
1348
1932
  :param df_input_data: A DataFrame containing all the input data used for \
1349
1933
  the optimization, notably the unit load cost for power consumption.
1350
1934
  :type df_input_data: pandas.DataFrame
1351
- :param P_PV: The forecasted PV power production.
1352
- :type P_PV: pandas.DataFrame
1353
- :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 \
1354
1938
  not include the power from the deferrable load that we want to find.
1355
- :type P_load: pandas.DataFrame
1939
+ :type p_load: pandas.DataFrame
1356
1940
  :param prediction_horizon: The prediction horizon of the MPC controller in number \
1357
1941
  of optimization time steps.
1358
1942
  :type prediction_horizon: int
@@ -1361,7 +1945,10 @@ class Optimization:
1361
1945
  :type soc_init: float
1362
1946
  :param soc_final: The final battery SOC for the optimization. This parameter \
1363
1947
  is optional, if not given soc_init = soc_final = soc_target from the configuration file.
1364
- :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
1365
1952
  :param def_total_hours: The functioning hours for this iteration for each deferrable load. \
1366
1953
  (For continuous deferrable loads: functioning hours at nominal power)
1367
1954
  :type def_total_hours: list
@@ -1388,13 +1975,14 @@ class Optimization:
1388
1975
  # Call optimization function
1389
1976
  self.opt_res = self.perform_optimization(
1390
1977
  df_input_data,
1391
- P_PV.values.ravel(),
1392
- P_load.values.ravel(),
1978
+ p_pv.values.ravel(),
1979
+ p_load.values.ravel(),
1393
1980
  unit_load_cost,
1394
1981
  unit_prod_price,
1395
1982
  soc_init=soc_init,
1396
1983
  soc_final=soc_final,
1397
1984
  def_total_hours=def_total_hours,
1985
+ def_total_timestep=def_total_timestep,
1398
1986
  def_start_timestep=def_start_timestep,
1399
1987
  def_end_timestep=def_end_timestep,
1400
1988
  )
@@ -1403,7 +1991,7 @@ class Optimization:
1403
1991
  @staticmethod
1404
1992
  def validate_def_timewindow(
1405
1993
  start: int, end: int, min_steps: int, window: int
1406
- ) -> Tuple[int, int, str]:
1994
+ ) -> tuple[int, int, str]:
1407
1995
  r"""
1408
1996
  Helper function to validate (and if necessary: correct) the defined optimization window of a deferrable load.
1409
1997