emhass 0.13.0__py3-none-any.whl → 0.13.2__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 ADDED
@@ -0,0 +1,1548 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import bz2
4
+ import copy
5
+ import logging
6
+ import pickle as cPickle
7
+ from math import ceil
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import pulp as plp
12
+ from pulp import COIN_CMD, GLPK_CMD, PULP_CBC_CMD
13
+
14
+
15
+ class Optimization:
16
+ r"""
17
+ Optimize the deferrable load and battery energy dispatch problem using \
18
+ the linear programming optimization technique. All equipement equations, \
19
+ including the battery equations are hence transformed in a linear form.
20
+
21
+ This class methods are:
22
+
23
+ - perform_optimization
24
+
25
+ - perform_perfect_forecast_optim
26
+
27
+ - perform_dayahead_forecast_optim
28
+
29
+ - perform_naive_mpc_optim
30
+
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ retrieve_hass_conf: dict,
36
+ optim_conf: dict,
37
+ plant_conf: dict,
38
+ var_load_cost: str,
39
+ var_prod_price: str,
40
+ costfun: str,
41
+ emhass_conf: dict,
42
+ logger: logging.Logger,
43
+ opt_time_delta: int | None = 24,
44
+ ) -> None:
45
+ r"""
46
+ Define constructor for Optimization class.
47
+
48
+ :param retrieve_hass_conf: Configuration parameters used to retrieve data \
49
+ from hass
50
+ :type retrieve_hass_conf: dict
51
+ :param optim_conf: Configuration parameters used for the optimization task
52
+ :type optim_conf: dict
53
+ :param plant_conf: Configuration parameters used to model the electrical \
54
+ system: PV production, battery, etc.
55
+ :type plant_conf: dict
56
+ :param var_load_cost: The column name for the unit load cost.
57
+ :type var_load_cost: str
58
+ :param var_prod_price: The column name for the unit power production price.
59
+ :type var_prod_price: str
60
+ :param costfun: The type of cost function to use for optimization problem
61
+ :type costfun: str
62
+ :param emhass_conf: Dictionary containing the needed emhass paths
63
+ :type emhass_conf: dict
64
+ :param logger: The passed logger object
65
+ :type logger: logging object
66
+ :param opt_time_delta: The number of hours to optimize. If days_list has \
67
+ more than one day then the optimization will be peformed by chunks of \
68
+ opt_time_delta periods, defaults to 24
69
+ :type opt_time_delta: float, optional
70
+
71
+ """
72
+ self.retrieve_hass_conf = retrieve_hass_conf
73
+ self.optim_conf = optim_conf
74
+ self.plant_conf = plant_conf
75
+ self.freq = self.retrieve_hass_conf["optimization_time_step"]
76
+ self.time_zone = self.retrieve_hass_conf["time_zone"]
77
+ self.timeStep = self.freq.seconds / 3600 # in hours
78
+ self.time_delta = pd.to_timedelta(
79
+ opt_time_delta, "hours"
80
+ ) # The period of optimization
81
+ self.var_PV = self.retrieve_hass_conf["sensor_power_photovoltaics"]
82
+ self.var_load = self.retrieve_hass_conf["sensor_power_load_no_var_loads"]
83
+ self.var_load_new = self.var_load + "_positive"
84
+ self.costfun = costfun
85
+ self.emhass_conf = emhass_conf
86
+ self.logger = logger
87
+ self.var_load_cost = var_load_cost
88
+ self.var_prod_price = var_prod_price
89
+ self.optim_status = None
90
+ if "lp_solver" in optim_conf.keys():
91
+ self.lp_solver = optim_conf["lp_solver"]
92
+ else:
93
+ self.lp_solver = "default"
94
+ if "lp_solver_path" in optim_conf.keys():
95
+ self.lp_solver_path = optim_conf["lp_solver_path"]
96
+ else:
97
+ self.lp_solver_path = "empty"
98
+ if self.lp_solver != "COIN_CMD" and self.lp_solver_path != "empty":
99
+ self.logger.error(
100
+ "Use COIN_CMD solver name if you want to set a path for the LP solver"
101
+ )
102
+ if (
103
+ self.lp_solver == "COIN_CMD" and self.lp_solver_path == "empty"
104
+ ): # if COIN_CMD but lp_solver_path is empty
105
+ self.logger.warning(
106
+ "lp_solver=COIN_CMD but lp_solver_path=empty, attempting to use lp_solver_path=/usr/bin/cbc"
107
+ )
108
+ self.lp_solver_path = "/usr/bin/cbc"
109
+ self.logger.debug(f"Initialized Optimization with retrieve_hass_conf: {retrieve_hass_conf}")
110
+ self.logger.debug(f"Optimization configuration: {optim_conf}")
111
+ self.logger.debug(f"Plant configuration: {plant_conf}")
112
+ self.logger.debug(f"Solver configuration: lp_solver={self.lp_solver}, lp_solver_path={self.lp_solver_path}")
113
+
114
+ def perform_optimization(
115
+ self,
116
+ data_opt: pd.DataFrame,
117
+ P_PV: np.array,
118
+ P_load: np.array,
119
+ unit_load_cost: np.array,
120
+ unit_prod_price: np.array,
121
+ soc_init: float | None = None,
122
+ soc_final: float | None = None,
123
+ def_total_hours: list | None = None,
124
+ def_total_timestep: list | None = None,
125
+ def_start_timestep: list | None = None,
126
+ def_end_timestep: list | None = None,
127
+ debug: bool | None = False,
128
+ ) -> pd.DataFrame:
129
+ r"""
130
+ Perform the actual optimization using linear programming (LP).
131
+
132
+ :param data_opt: A DataFrame containing the input data. The results of the \
133
+ optimization will be appended (decision variables, cost function values, etc)
134
+ :type data_opt: pd.DataFrame
135
+ :param P_PV: The photovoltaic power values. This can be real historical \
136
+ values or forecasted values.
137
+ :type P_PV: numpy.array
138
+ :param P_load: The load power consumption values
139
+ :type P_load: np.array
140
+ :param unit_load_cost: The cost of power consumption for each unit of time. \
141
+ This is the cost of the energy from the utility in a vector sampled \
142
+ at the fixed freq value
143
+ :type unit_load_cost: np.array
144
+ :param unit_prod_price: The price of power injected to the grid each unit of time. \
145
+ This is the price of the energy injected to the utility in a vector \
146
+ sampled at the fixed freq value.
147
+ :type unit_prod_price: np.array
148
+ :param soc_init: The initial battery SOC for the optimization. This parameter \
149
+ is optional, if not given soc_init = soc_final = soc_target from the configuration file.
150
+ :type soc_init: float
151
+ :param soc_final: The final battery SOC for the optimization. This parameter \
152
+ is optional, if not given soc_init = soc_final = soc_target from the configuration file.
153
+ :type soc_final:
154
+ :param def_total_hours: The functioning hours for this iteration for each deferrable load. \
155
+ (For continuous deferrable loads: functioning hours at nominal power)
156
+ :type def_total_hours: list
157
+ :param def_total_timestep: The functioning timesteps for this iteration for each deferrable load. \
158
+ (For continuous deferrable loads: functioning timesteps at nominal power)
159
+ :type def_total_timestep: list
160
+ :param def_start_timestep: The timestep as from which each deferrable load is allowed to operate.
161
+ :type def_start_timestep: list
162
+ :param def_end_timestep: The timestep before which each deferrable load should operate.
163
+ :type def_end_timestep: list
164
+ :return: The input DataFrame with all the different results from the \
165
+ optimization appended
166
+ :rtype: pd.DataFrame
167
+
168
+ """
169
+ # Prepare some data in the case of a battery
170
+ if self.optim_conf["set_use_battery"]:
171
+ if soc_init is None:
172
+ if soc_final is not None:
173
+ soc_init = soc_final
174
+ else:
175
+ soc_init = self.plant_conf["battery_target_state_of_charge"]
176
+ if soc_final is None:
177
+ if soc_init is not None:
178
+ soc_final = soc_init
179
+ else:
180
+ soc_final = self.plant_conf["battery_target_state_of_charge"]
181
+ self.logger.debug(f"Battery usage enabled. Initial SOC: {soc_init}, Final SOC: {soc_final}")
182
+
183
+ # If def_total_timestep os set, bypass def_total_hours
184
+ if def_total_timestep is not None:
185
+ def_total_hours = [0 if x != 0 else x for x in def_total_hours]
186
+ elif def_total_hours is None:
187
+ def_total_hours = self.optim_conf["operating_hours_of_each_deferrable_load"]
188
+
189
+ if def_start_timestep is None:
190
+ def_start_timestep = self.optim_conf[
191
+ "start_timesteps_of_each_deferrable_load"
192
+ ]
193
+ if def_end_timestep is None:
194
+ def_end_timestep = self.optim_conf["end_timesteps_of_each_deferrable_load"]
195
+ type_self_conso = "bigm" # maxmin
196
+
197
+ num_deferrable_loads = self.optim_conf["number_of_deferrable_loads"]
198
+
199
+ def_total_hours = def_total_hours + [0] * (num_deferrable_loads - len(def_total_hours))
200
+ def_start_timestep = def_start_timestep + [0] * (num_deferrable_loads - len(def_start_timestep))
201
+ def_end_timestep = def_end_timestep + [0] * (num_deferrable_loads - len(def_end_timestep))
202
+
203
+ #### The LP problem using Pulp ####
204
+ opt_model = plp.LpProblem("LP_Model", plp.LpMaximize)
205
+
206
+ n = len(data_opt.index)
207
+ set_I = range(n)
208
+ M = 10e10
209
+
210
+ ## Add decision variables
211
+ P_grid_neg = {
212
+ (i): plp.LpVariable(
213
+ cat="Continuous",
214
+ lowBound=-self.plant_conf["maximum_power_to_grid"],
215
+ upBound=0,
216
+ name=f"P_grid_neg{i}",
217
+ )
218
+ for i in set_I
219
+ }
220
+ P_grid_pos = {
221
+ (i): plp.LpVariable(
222
+ cat="Continuous",
223
+ lowBound=0,
224
+ upBound=self.plant_conf["maximum_power_from_grid"],
225
+ name=f"P_grid_pos{i}",
226
+ )
227
+ for i in set_I
228
+ }
229
+ P_deferrable = []
230
+ P_def_bin1 = []
231
+ for k in range(num_deferrable_loads):
232
+ if isinstance(
233
+ self.optim_conf["nominal_power_of_deferrable_loads"][k], list
234
+ ):
235
+ upBound = np.max(
236
+ self.optim_conf["nominal_power_of_deferrable_loads"][k]
237
+ )
238
+ else:
239
+ upBound = self.optim_conf["nominal_power_of_deferrable_loads"][k]
240
+ if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
241
+ P_deferrable.append(
242
+ {
243
+ (i): plp.LpVariable(
244
+ cat="Continuous", name=f"P_deferrable{k}_{i}"
245
+ )
246
+ for i in set_I
247
+ }
248
+ )
249
+ else:
250
+ P_deferrable.append(
251
+ {
252
+ (i): plp.LpVariable(
253
+ cat="Continuous",
254
+ lowBound=0,
255
+ upBound=upBound,
256
+ name=f"P_deferrable{k}_{i}",
257
+ )
258
+ for i in set_I
259
+ }
260
+ )
261
+ P_def_bin1.append(
262
+ {
263
+ (i): plp.LpVariable(cat="Binary", name=f"P_def{k}_bin1_{i}")
264
+ for i in set_I
265
+ }
266
+ )
267
+ P_def_start = []
268
+ P_def_bin2 = []
269
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
270
+ P_def_start.append(
271
+ {
272
+ (i): plp.LpVariable(cat="Binary", name=f"P_def{k}_start_{i}")
273
+ for i in set_I
274
+ }
275
+ )
276
+ P_def_bin2.append(
277
+ {
278
+ (i): plp.LpVariable(cat="Binary", name=f"P_def{k}_bin2_{i}")
279
+ for i in set_I
280
+ }
281
+ )
282
+ D = {(i): plp.LpVariable(cat="Binary", name=f"D_{i}") for i in set_I}
283
+ E = {(i): plp.LpVariable(cat="Binary", name=f"E_{i}") for i in set_I}
284
+ if self.optim_conf["set_use_battery"]:
285
+ P_sto_pos = {
286
+ (i): plp.LpVariable(
287
+ cat="Continuous",
288
+ lowBound=0,
289
+ upBound=self.plant_conf["battery_discharge_power_max"],
290
+ name=f"P_sto_pos_{i}",
291
+ )
292
+ for i in set_I
293
+ }
294
+ P_sto_neg = {
295
+ (i): plp.LpVariable(
296
+ cat="Continuous",
297
+ lowBound=-self.plant_conf["battery_charge_power_max"],
298
+ upBound=0,
299
+ name=f"P_sto_neg_{i}",
300
+ )
301
+ for i in set_I
302
+ }
303
+ else:
304
+ P_sto_pos = {(i): i * 0 for i in set_I}
305
+ P_sto_neg = {(i): i * 0 for i in set_I}
306
+
307
+ if self.costfun == "self-consumption":
308
+ SC = {(i): plp.LpVariable(cat="Continuous", name=f"SC_{i}") for i in set_I}
309
+ if self.plant_conf["inverter_is_hybrid"]:
310
+ P_hybrid_inverter = {
311
+ (i): plp.LpVariable(cat="Continuous", name=f"P_hybrid_inverter{i}")
312
+ for i in set_I
313
+ }
314
+ P_PV_curtailment = {
315
+ (i): plp.LpVariable(
316
+ cat="Continuous", lowBound=0, name=f"P_PV_curtailment{i}"
317
+ )
318
+ for i in set_I
319
+ }
320
+
321
+ ## Define objective
322
+ P_def_sum = []
323
+ for i in set_I:
324
+ P_def_sum.append(
325
+ plp.lpSum(
326
+ P_deferrable[k][i]
327
+ for k in range(self.optim_conf["number_of_deferrable_loads"])
328
+ )
329
+ )
330
+ if self.costfun == "profit":
331
+ if self.optim_conf["set_total_pv_sell"]:
332
+ objective = plp.lpSum(
333
+ -0.001
334
+ * self.timeStep
335
+ * (
336
+ unit_load_cost[i] * (P_load[i] + P_def_sum[i])
337
+ + unit_prod_price[i] * P_grid_neg[i]
338
+ )
339
+ for i in set_I
340
+ )
341
+ else:
342
+ objective = plp.lpSum(
343
+ -0.001
344
+ * self.timeStep
345
+ * (
346
+ unit_load_cost[i] * P_grid_pos[i]
347
+ + unit_prod_price[i] * P_grid_neg[i]
348
+ )
349
+ for i in set_I
350
+ )
351
+ elif self.costfun == "cost":
352
+ if self.optim_conf["set_total_pv_sell"]:
353
+ objective = plp.lpSum(
354
+ -0.001
355
+ * self.timeStep
356
+ * unit_load_cost[i]
357
+ * (P_load[i] + P_def_sum[i])
358
+ for i in set_I
359
+ )
360
+ else:
361
+ objective = plp.lpSum(
362
+ -0.001 * self.timeStep * unit_load_cost[i] * P_grid_pos[i]
363
+ for i in set_I
364
+ )
365
+ elif self.costfun == "self-consumption":
366
+ if type_self_conso == "bigm":
367
+ bigm = 1e3
368
+ objective = plp.lpSum(
369
+ -0.001
370
+ * self.timeStep
371
+ * (
372
+ bigm * unit_load_cost[i] * P_grid_pos[i]
373
+ + unit_prod_price[i] * P_grid_neg[i]
374
+ )
375
+ for i in set_I
376
+ )
377
+ elif type_self_conso == "maxmin":
378
+ objective = plp.lpSum(
379
+ 0.001 * self.timeStep * unit_load_cost[i] * SC[i] for i in set_I
380
+ )
381
+ else:
382
+ self.logger.error("Not a valid option for type_self_conso parameter")
383
+ else:
384
+ self.logger.error("The cost function specified type is not valid")
385
+ # Add more terms to the objective function in the case of battery use
386
+ if self.optim_conf["set_use_battery"]:
387
+ objective = objective + plp.lpSum(
388
+ -0.001
389
+ * self.timeStep
390
+ * (
391
+ self.optim_conf["weight_battery_discharge"] * P_sto_pos[i]
392
+ - self.optim_conf["weight_battery_charge"] * P_sto_neg[i]
393
+ )
394
+ for i in set_I
395
+ )
396
+
397
+ # Add term penalizing each startup where configured
398
+ if (
399
+ "set_deferrable_startup_penalty" in self.optim_conf
400
+ and self.optim_conf["set_deferrable_startup_penalty"]
401
+ ):
402
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
403
+ if (
404
+ len(self.optim_conf["set_deferrable_startup_penalty"]) > k
405
+ and self.optim_conf["set_deferrable_startup_penalty"][k]
406
+ ):
407
+ objective = objective + plp.lpSum(
408
+ -0.001
409
+ * self.timeStep
410
+ * self.optim_conf["set_deferrable_startup_penalty"][k]
411
+ * P_def_start[k][i]
412
+ * unit_load_cost[i]
413
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k]
414
+ for i in set_I
415
+ )
416
+
417
+ opt_model.setObjective(objective)
418
+
419
+ ## Setting constraints
420
+ # The main constraint: power balance
421
+ if self.plant_conf["inverter_is_hybrid"]:
422
+ constraints = {
423
+ f"constraint_main1_{i}": plp.LpConstraint(
424
+ e=P_hybrid_inverter[i]
425
+ - P_def_sum[i]
426
+ - P_load[i]
427
+ + P_grid_neg[i]
428
+ + P_grid_pos[i],
429
+ sense=plp.LpConstraintEQ,
430
+ rhs=0,
431
+ )
432
+ for i in set_I
433
+ }
434
+ else:
435
+ if self.plant_conf["compute_curtailment"]:
436
+ constraints = {
437
+ f"constraint_main2_{i}": plp.LpConstraint(
438
+ e=P_PV[i]
439
+ - P_PV_curtailment[i]
440
+ - P_def_sum[i]
441
+ - P_load[i]
442
+ + P_grid_neg[i]
443
+ + P_grid_pos[i]
444
+ + P_sto_pos[i]
445
+ + P_sto_neg[i],
446
+ sense=plp.LpConstraintEQ,
447
+ rhs=0,
448
+ )
449
+ for i in set_I
450
+ }
451
+ else:
452
+ constraints = {
453
+ f"constraint_main3_{i}": plp.LpConstraint(
454
+ e=P_PV[i]
455
+ - P_def_sum[i]
456
+ - P_load[i]
457
+ + P_grid_neg[i]
458
+ + P_grid_pos[i]
459
+ + P_sto_pos[i]
460
+ + P_sto_neg[i],
461
+ sense=plp.LpConstraintEQ,
462
+ rhs=0,
463
+ )
464
+ for i in set_I
465
+ }
466
+
467
+ # Constraint for hybrid inverter and curtailment cases
468
+ if isinstance(self.plant_conf["pv_module_model"], list):
469
+ P_nom_inverter = 0.0
470
+ for i in range(len(self.plant_conf["pv_inverter_model"])):
471
+ if isinstance(self.plant_conf["pv_inverter_model"][i], str):
472
+ cec_inverters = bz2.BZ2File(
473
+ self.emhass_conf["root_path"] / "data" / "cec_inverters.pbz2",
474
+ "rb",
475
+ )
476
+ cec_inverters = cPickle.load(cec_inverters)
477
+ inverter = cec_inverters[self.plant_conf["pv_inverter_model"][i]]
478
+ P_nom_inverter += inverter.Paco
479
+ else:
480
+ P_nom_inverter += self.plant_conf["pv_inverter_model"][i]
481
+ else:
482
+ if isinstance(self.plant_conf["pv_inverter_model"][i], str):
483
+ cec_inverters = bz2.BZ2File(
484
+ self.emhass_conf["root_path"] / "data" / "cec_inverters.pbz2", "rb"
485
+ )
486
+ cec_inverters = cPickle.load(cec_inverters)
487
+ inverter = cec_inverters[self.plant_conf["pv_inverter_model"]]
488
+ P_nom_inverter = inverter.Paco
489
+ else:
490
+ P_nom_inverter = self.plant_conf["pv_inverter_model"]
491
+ if self.plant_conf["inverter_is_hybrid"]:
492
+ constraints.update(
493
+ {
494
+ f"constraint_hybrid_inverter1_{i}": plp.LpConstraint(
495
+ e=P_PV[i]
496
+ - P_PV_curtailment[i]
497
+ + P_sto_pos[i]
498
+ + P_sto_neg[i]
499
+ - P_nom_inverter,
500
+ sense=plp.LpConstraintLE,
501
+ rhs=0,
502
+ )
503
+ for i in set_I
504
+ }
505
+ )
506
+ constraints.update(
507
+ {
508
+ f"constraint_hybrid_inverter2_{i}": plp.LpConstraint(
509
+ e=P_PV[i]
510
+ - P_PV_curtailment[i]
511
+ + P_sto_pos[i]
512
+ + P_sto_neg[i]
513
+ - P_hybrid_inverter[i],
514
+ sense=plp.LpConstraintEQ,
515
+ rhs=0,
516
+ )
517
+ for i in set_I
518
+ }
519
+ )
520
+ else:
521
+ if self.plant_conf["compute_curtailment"]:
522
+ constraints.update(
523
+ {
524
+ f"constraint_curtailment_{i}": plp.LpConstraint(
525
+ e=P_PV_curtailment[i] - max(P_PV[i], 0),
526
+ sense=plp.LpConstraintLE,
527
+ rhs=0,
528
+ )
529
+ for i in set_I
530
+ }
531
+ )
532
+
533
+ # Two special constraints just for a self-consumption cost function
534
+ if self.costfun == "self-consumption":
535
+ if type_self_conso == "maxmin": # maxmin linear problem
536
+ constraints.update(
537
+ {
538
+ f"constraint_selfcons_PV1_{i}": plp.LpConstraint(
539
+ e=SC[i] - P_PV[i], sense=plp.LpConstraintLE, rhs=0
540
+ )
541
+ for i in set_I
542
+ }
543
+ )
544
+ constraints.update(
545
+ {
546
+ f"constraint_selfcons_PV2_{i}": plp.LpConstraint(
547
+ e=SC[i] - P_load[i] - P_def_sum[i],
548
+ sense=plp.LpConstraintLE,
549
+ rhs=0,
550
+ )
551
+ for i in set_I
552
+ }
553
+ )
554
+
555
+ # Avoid injecting and consuming from grid at the same time
556
+ constraints.update(
557
+ {
558
+ f"constraint_pgridpos_{i}": plp.LpConstraint(
559
+ e=P_grid_pos[i] - self.plant_conf["maximum_power_from_grid"] * D[i],
560
+ sense=plp.LpConstraintLE,
561
+ rhs=0,
562
+ )
563
+ for i in set_I
564
+ }
565
+ )
566
+ constraints.update(
567
+ {
568
+ f"constraint_pgridneg_{i}": plp.LpConstraint(
569
+ e=-P_grid_neg[i]
570
+ - self.plant_conf["maximum_power_to_grid"] * (1 - D[i]),
571
+ sense=plp.LpConstraintLE,
572
+ rhs=0,
573
+ )
574
+ for i in set_I
575
+ }
576
+ )
577
+
578
+ # Treat deferrable loads constraints
579
+ predicted_temps = {}
580
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
581
+ self.logger.debug(f"Processing deferrable load {k}")
582
+ if isinstance(
583
+ self.optim_conf["nominal_power_of_deferrable_loads"][k], list
584
+ ):
585
+ self.logger.debug(f"Load {k} is sequence-based. Sequence: {self.optim_conf['nominal_power_of_deferrable_loads'][k]}")
586
+ # Constraint for sequence of deferrable
587
+ # WARNING: This is experimental, formulation seems correct but feasibility problems.
588
+ # Probably uncomptabile with other constraints
589
+ power_sequence = self.optim_conf["nominal_power_of_deferrable_loads"][k]
590
+ sequence_length = len(power_sequence)
591
+
592
+ def create_matrix(input_list, n):
593
+ matrix = []
594
+ for i in range(n + 1):
595
+ row = [0] * i + input_list + [0] * (n - i)
596
+ matrix.append(row[: n * 2])
597
+ return matrix
598
+
599
+ matrix = create_matrix(power_sequence, n - sequence_length)
600
+ y = plp.LpVariable.dicts(
601
+ f"y{k}", (i for i in range(len(matrix))), cat="Binary"
602
+ )
603
+ self.logger.debug(f"Load {k}: Created binary variables for sequence placement: y = {list(y.keys())}")
604
+ constraints.update(
605
+ {
606
+ f"single_value_constraint_{k}": plp.LpConstraint(
607
+ e=plp.lpSum(y[i] for i in range(len(matrix))) - 1,
608
+ sense=plp.LpConstraintEQ,
609
+ rhs=0,
610
+ )
611
+ }
612
+ )
613
+ constraints.update(
614
+ {
615
+ f"pdef{k}_sumconstraint_{i}": plp.LpConstraint(
616
+ e=plp.lpSum(P_deferrable[k][i] for i in set_I)
617
+ - np.sum(power_sequence),
618
+ sense=plp.LpConstraintEQ,
619
+ rhs=0,
620
+ )
621
+ }
622
+ )
623
+ constraints.update(
624
+ {
625
+ f"pdef{k}_positive_constraint_{i}": plp.LpConstraint(
626
+ e=P_deferrable[k][i], sense=plp.LpConstraintGE, rhs=0
627
+ )
628
+ for i in set_I
629
+ }
630
+ )
631
+ for num, mat in enumerate(matrix):
632
+ constraints.update(
633
+ {
634
+ f"pdef{k}_value_constraint_{num}_{i}": plp.LpConstraint(
635
+ e=P_deferrable[k][i] - mat[i] * y[num],
636
+ sense=plp.LpConstraintEQ,
637
+ rhs=0,
638
+ )
639
+ for i in set_I
640
+ }
641
+ )
642
+ self.logger.debug(f"Load {k}: Sequence-based constraints set.")
643
+
644
+ # --- Thermal deferrable load logic first ---
645
+ elif ("def_load_config" in self.optim_conf.keys()
646
+ and len(self.optim_conf["def_load_config"]) > k
647
+ and "thermal_config" in self.optim_conf["def_load_config"][k]):
648
+
649
+
650
+ self.logger.debug(f"Load {k} is a thermal deferrable load.")
651
+
652
+ # Special case of a thermal deferrable load
653
+ def_load_config = self.optim_conf["def_load_config"][k]
654
+ if def_load_config and "thermal_config" in def_load_config:
655
+ hc = def_load_config["thermal_config"]
656
+ start_temperature = hc["start_temperature"]
657
+ cooling_constant = hc["cooling_constant"]
658
+ heating_rate = hc["heating_rate"]
659
+ overshoot_temperature = hc["overshoot_temperature"]
660
+ outdoor_temperature_forecast = data_opt["outdoor_temperature_forecast"]
661
+ desired_temperatures = hc["desired_temperatures"]
662
+ sense = hc.get("sense", "heat")
663
+ sense_coeff = 1 if sense == "heat" else -1
664
+
665
+ self.logger.debug(f"Load {k}: Thermal parameters: start_temperature={start_temperature}, cooling_constant={cooling_constant}, heating_rate={heating_rate}, overshoot_temperature={overshoot_temperature}")
666
+
667
+ predicted_temp = [start_temperature]
668
+ for Id in set_I:
669
+ if Id == 0:
670
+ continue
671
+ predicted_temp.append(
672
+ predicted_temp[Id - 1]
673
+ + (
674
+ P_deferrable[k][Id - 1]
675
+ * (
676
+ heating_rate
677
+ * self.timeStep
678
+ / self.optim_conf["nominal_power_of_deferrable_loads"][k]
679
+ )
680
+ )
681
+ - (
682
+ cooling_constant
683
+ * (
684
+ predicted_temp[Id - 1]
685
+ - outdoor_temperature_forecast.iloc[Id - 1]
686
+ )
687
+ )
688
+ )
689
+
690
+ is_overshoot = plp.LpVariable(
691
+ f"defload_{k}_overshoot_{Id}"
692
+ )
693
+ constraints.update(
694
+ {
695
+ f"constraint_defload{k}_overshoot_{Id}_1": plp.LpConstraint(
696
+ predicted_temp[Id]
697
+ - overshoot_temperature
698
+ - (100 * sense_coeff * is_overshoot),
699
+ sense=plp.LpConstraintLE
700
+ if sense == "heat"
701
+ else plp.LpConstraintGE,
702
+ rhs=0,
703
+ ),
704
+ f"constraint_defload{k}_overshoot_{Id}_2": plp.LpConstraint(
705
+ predicted_temp[Id]
706
+ - overshoot_temperature
707
+ + (100 * sense_coeff * (1 - is_overshoot)),
708
+ sense=plp.LpConstraintGE
709
+ if sense == "heat"
710
+ else plp.LpConstraintLE,
711
+ rhs=0,
712
+ ),
713
+ f"constraint_defload{k}_overshoot_temp_{Id}": plp.LpConstraint(
714
+ e=is_overshoot + P_def_bin2[k][Id - 1],
715
+ sense=plp.LpConstraintLE,
716
+ rhs=1,
717
+ ),
718
+ }
719
+ )
720
+
721
+ if len(desired_temperatures) > Id and desired_temperatures[Id]:
722
+ penalty_factor = hc.get("penalty_factor", 10)
723
+ if penalty_factor < 0:
724
+ raise ValueError(
725
+ "penalty_factor must be positive, otherwise the problem will become unsolvable"
726
+ )
727
+ penalty_value = (
728
+ predicted_temp[Id]
729
+ - desired_temperatures[Id]
730
+ ) * penalty_factor * sense_coeff
731
+ penalty_var = plp.LpVariable(
732
+ f"defload_{k}_thermal_penalty_{Id}",
733
+ cat="Continuous",
734
+ upBound=0,
735
+ )
736
+ constraints.update(
737
+ {
738
+ f"constraint_defload{k}_penalty_{Id}": plp.LpConstraint(
739
+ e=penalty_var - penalty_value,
740
+ sense=plp.LpConstraintLE,
741
+ rhs=0,
742
+ )
743
+ }
744
+ )
745
+ opt_model.setObjective(opt_model.objective + penalty_var)
746
+
747
+ predicted_temps[k] = predicted_temp
748
+ self.logger.debug(f"Load {k}: Thermal constraints set.")
749
+
750
+ # --- Standard/non-thermal deferrable load logic comes after thermal ---
751
+ elif (
752
+ (def_total_timestep and def_total_timestep[k] > 0)
753
+ or (len(def_total_hours) > k and def_total_hours[k] > 0)):
754
+
755
+ self.logger.debug(f"Load {k} is standard/non-thermal.")
756
+ if def_total_timestep and def_total_timestep[k] > 0:
757
+ self.logger.debug(f"Load {k}: Using total timesteps constraint: {def_total_timestep[k]}")
758
+ constraints.update(
759
+ {
760
+ f"constraint_defload{k}_energy": plp.LpConstraint(
761
+ e=plp.lpSum(
762
+ P_deferrable[k][i] * self.timeStep for i in set_I
763
+ ),
764
+ sense=plp.LpConstraintEQ,
765
+ rhs=(self.timeStep * def_total_timestep[k])
766
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k],
767
+ )
768
+ }
769
+ )
770
+ else:
771
+ self.logger.debug(f"Load {k}: Using total hours constraint: {def_total_hours[k]}")
772
+ constraints.update(
773
+ {
774
+ f"constraint_defload{k}_energy": plp.LpConstraint(
775
+ e=plp.lpSum(
776
+ P_deferrable[k][i] * self.timeStep for i in set_I
777
+ ),
778
+ sense=plp.LpConstraintEQ,
779
+ rhs=def_total_hours[k]
780
+ * self.optim_conf["nominal_power_of_deferrable_loads"][k],
781
+ )
782
+ }
783
+ )
784
+ self.logger.debug(f"Load {k}: Standard load constraints set.")
785
+
786
+
787
+ # Ensure deferrable loads consume energy between def_start_timestep & def_end_timestep
788
+ self.logger.debug(
789
+ f"Deferrable load {k}: Proposed optimization window: {def_start_timestep[k]} --> {def_end_timestep[k]}"
790
+ )
791
+ if def_total_timestep and def_total_timestep[k] > 0:
792
+ def_start, def_end, warning = Optimization.validate_def_timewindow(
793
+ def_start_timestep[k],
794
+ def_end_timestep[k],
795
+ ceil(
796
+ (60 / ((self.freq.seconds / 60) * def_total_timestep[k]))
797
+ / self.timeStep
798
+ ),
799
+ n,
800
+ )
801
+ else:
802
+ def_start, def_end, warning = Optimization.validate_def_timewindow(
803
+ def_start_timestep[k],
804
+ def_end_timestep[k],
805
+ ceil(def_total_hours[k] / self.timeStep),
806
+ n,
807
+ )
808
+ if warning is not None:
809
+ self.logger.warning(f"Deferrable load {k} : {warning}")
810
+ self.logger.debug(
811
+ f"Deferrable load {k}: Validated optimization window: {def_start} --> {def_end}"
812
+ )
813
+ if def_start > 0:
814
+ constraints.update(
815
+ {
816
+ f"constraint_defload{k}_start_timestep": plp.LpConstraint(
817
+ e=plp.lpSum(
818
+ P_deferrable[k][i] * self.timeStep
819
+ for i in range(0, def_start)
820
+ ),
821
+ sense=plp.LpConstraintEQ,
822
+ rhs=0,
823
+ )
824
+ }
825
+ )
826
+ if def_end > 0:
827
+ constraints.update(
828
+ {
829
+ f"constraint_defload{k}_end_timestep": plp.LpConstraint(
830
+ e=plp.lpSum(
831
+ P_deferrable[k][i] * self.timeStep
832
+ for i in range(def_end, n)
833
+ ),
834
+ sense=plp.LpConstraintEQ,
835
+ rhs=0,
836
+ )
837
+ }
838
+ )
839
+
840
+ # Treat the number of starts for a deferrable load (new method considering current state)
841
+ current_state = 0
842
+ if (
843
+ "def_current_state" in self.optim_conf
844
+ and len(self.optim_conf["def_current_state"]) > k
845
+ ):
846
+ current_state = 1 if self.optim_conf["def_current_state"][k] else 0
847
+ # P_deferrable < P_def_bin2 * 1 million
848
+ # P_deferrable must be zero if P_def_bin2 is zero
849
+ constraints.update(
850
+ {
851
+ f"constraint_pdef{k}_start1_{i}": plp.LpConstraint(
852
+ e=P_deferrable[k][i] - P_def_bin2[k][i] * M,
853
+ sense=plp.LpConstraintLE,
854
+ rhs=0,
855
+ )
856
+ for i in set_I
857
+ }
858
+ )
859
+ # P_deferrable - P_def_bin2 <= 0
860
+ # P_def_bin2 must be zero if P_deferrable is zero
861
+ constraints.update(
862
+ {
863
+ f"constraint_pdef{k}_start1a_{i}": plp.LpConstraint(
864
+ e=P_def_bin2[k][i] - P_deferrable[k][i],
865
+ sense=plp.LpConstraintLE,
866
+ rhs=0,
867
+ )
868
+ for i in set_I
869
+ }
870
+ )
871
+ # P_def_start + P_def_bin2[i-1] >= P_def_bin2[i]
872
+ # 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
873
+ # For first timestep, use current state if provided by caller.
874
+ constraints.update(
875
+ {
876
+ f"constraint_pdef{k}_start2_{i}": plp.LpConstraint(
877
+ e=P_def_start[k][i]
878
+ - P_def_bin2[k][i]
879
+ + (P_def_bin2[k][i - 1] if i - 1 >= 0 else current_state),
880
+ sense=plp.LpConstraintGE,
881
+ rhs=0,
882
+ )
883
+ for i in set_I
884
+ }
885
+ )
886
+ # P_def_bin2[i-1] + P_def_start <= 1
887
+ # If load started this cycle (P_def_start[i] is 1) then P_def_bin2[i-1] must be 0
888
+ constraints.update(
889
+ {
890
+ f"constraint_pdef{k}_start3_{i}": plp.LpConstraint(
891
+ e=(P_def_bin2[k][i - 1] if i - 1 >= 0 else 0)
892
+ + P_def_start[k][i],
893
+ sense=plp.LpConstraintLE,
894
+ rhs=1,
895
+ )
896
+ for i in set_I
897
+ }
898
+ )
899
+
900
+ # Treat deferrable as a fixed value variable with just one startup
901
+ if self.optim_conf["set_deferrable_load_single_constant"][k]:
902
+ # P_def_start[i] must be 1 for exactly 1 value of i
903
+ constraints.update(
904
+ {
905
+ f"constraint_pdef{k}_start4": plp.LpConstraint(
906
+ e=plp.lpSum(P_def_start[k][i] for i in set_I),
907
+ sense=plp.LpConstraintEQ,
908
+ rhs=1,
909
+ )
910
+ }
911
+ )
912
+ # P_def_bin2 must be 1 for exactly the correct number of timesteps.
913
+ if def_total_timestep and def_total_timestep[k] > 0:
914
+ constraints.update(
915
+ {
916
+ f"constraint_pdef{k}_start5": plp.LpConstraint(
917
+ e=plp.lpSum(P_def_bin2[k][i] for i in set_I),
918
+ sense=plp.LpConstraintEQ,
919
+ rhs=(
920
+ (
921
+ 60
922
+ / (
923
+ (self.freq.seconds / 60)
924
+ * def_total_timestep[k]
925
+ )
926
+ )
927
+ / self.timeStep
928
+ ),
929
+ )
930
+ }
931
+ )
932
+ else:
933
+ constraints.update(
934
+ {
935
+ f"constraint_pdef{k}_start5": plp.LpConstraint(
936
+ e=plp.lpSum(P_def_bin2[k][i] for i in set_I),
937
+ sense=plp.LpConstraintEQ,
938
+ rhs=def_total_hours[k] / self.timeStep,
939
+ )
940
+ }
941
+ )
942
+
943
+ # Treat deferrable load as a semi-continuous variable
944
+ if self.optim_conf["treat_deferrable_load_as_semi_cont"][k]:
945
+ constraints.update(
946
+ {
947
+ f"constraint_pdef{k}_semicont1_{i}": plp.LpConstraint(
948
+ e=P_deferrable[k][i]
949
+ - self.optim_conf["nominal_power_of_deferrable_loads"][k]
950
+ * P_def_bin1[k][i],
951
+ sense=plp.LpConstraintGE,
952
+ rhs=0,
953
+ )
954
+ for i in set_I
955
+ }
956
+ )
957
+ constraints.update(
958
+ {
959
+ f"constraint_pdef{k}_semicont2_{i}": plp.LpConstraint(
960
+ e=P_deferrable[k][i]
961
+ - self.optim_conf["nominal_power_of_deferrable_loads"][k]
962
+ * P_def_bin1[k][i],
963
+ sense=plp.LpConstraintLE,
964
+ rhs=0,
965
+ )
966
+ for i in set_I
967
+ }
968
+ )
969
+
970
+ # The battery constraints
971
+ if self.optim_conf["set_use_battery"]:
972
+ # Optional constraints to avoid charging the battery from the grid
973
+ if self.optim_conf["set_nocharge_from_grid"]:
974
+ constraints.update(
975
+ {
976
+ f"constraint_nocharge_from_grid_{i}": plp.LpConstraint(
977
+ e=P_sto_neg[i] + P_PV[i], sense=plp.LpConstraintGE, rhs=0
978
+ )
979
+ for i in set_I
980
+ }
981
+ )
982
+ # Optional constraints to avoid discharging the battery to the grid
983
+ if self.optim_conf["set_nodischarge_to_grid"]:
984
+ constraints.update(
985
+ {
986
+ f"constraint_nodischarge_to_grid_{i}": plp.LpConstraint(
987
+ e=P_grid_neg[i] + P_PV[i], sense=plp.LpConstraintGE, rhs=0
988
+ )
989
+ for i in set_I
990
+ }
991
+ )
992
+ # Limitation of power dynamics in power per unit of time
993
+ if self.optim_conf["set_battery_dynamic"]:
994
+ constraints.update(
995
+ {
996
+ f"constraint_pos_batt_dynamic_max_{i}": plp.LpConstraint(
997
+ e=P_sto_pos[i + 1] - P_sto_pos[i],
998
+ sense=plp.LpConstraintLE,
999
+ rhs=self.timeStep
1000
+ * self.optim_conf["battery_dynamic_max"]
1001
+ * self.plant_conf["battery_discharge_power_max"],
1002
+ )
1003
+ for i in range(n - 1)
1004
+ }
1005
+ )
1006
+ constraints.update(
1007
+ {
1008
+ f"constraint_pos_batt_dynamic_min_{i}": plp.LpConstraint(
1009
+ e=P_sto_pos[i + 1] - P_sto_pos[i],
1010
+ sense=plp.LpConstraintGE,
1011
+ rhs=self.timeStep
1012
+ * self.optim_conf["battery_dynamic_min"]
1013
+ * self.plant_conf["battery_discharge_power_max"],
1014
+ )
1015
+ for i in range(n - 1)
1016
+ }
1017
+ )
1018
+ constraints.update(
1019
+ {
1020
+ f"constraint_neg_batt_dynamic_max_{i}": plp.LpConstraint(
1021
+ e=P_sto_neg[i + 1] - P_sto_neg[i],
1022
+ sense=plp.LpConstraintLE,
1023
+ rhs=self.timeStep
1024
+ * self.optim_conf["battery_dynamic_max"]
1025
+ * self.plant_conf["battery_charge_power_max"],
1026
+ )
1027
+ for i in range(n - 1)
1028
+ }
1029
+ )
1030
+ constraints.update(
1031
+ {
1032
+ f"constraint_neg_batt_dynamic_min_{i}": plp.LpConstraint(
1033
+ e=P_sto_neg[i + 1] - P_sto_neg[i],
1034
+ sense=plp.LpConstraintGE,
1035
+ rhs=self.timeStep
1036
+ * self.optim_conf["battery_dynamic_min"]
1037
+ * self.plant_conf["battery_charge_power_max"],
1038
+ )
1039
+ for i in range(n - 1)
1040
+ }
1041
+ )
1042
+ # Then the classic battery constraints
1043
+ constraints.update(
1044
+ {
1045
+ f"constraint_pstopos_{i}": plp.LpConstraint(
1046
+ e=P_sto_pos[i]
1047
+ - self.plant_conf["battery_discharge_efficiency"]
1048
+ * self.plant_conf["battery_discharge_power_max"]
1049
+ * E[i],
1050
+ sense=plp.LpConstraintLE,
1051
+ rhs=0,
1052
+ )
1053
+ for i in set_I
1054
+ }
1055
+ )
1056
+ constraints.update(
1057
+ {
1058
+ f"constraint_pstoneg_{i}": plp.LpConstraint(
1059
+ e=-P_sto_neg[i]
1060
+ - (1 / self.plant_conf["battery_charge_efficiency"])
1061
+ * self.plant_conf["battery_charge_power_max"]
1062
+ * (1 - E[i]),
1063
+ sense=plp.LpConstraintLE,
1064
+ rhs=0,
1065
+ )
1066
+ for i in set_I
1067
+ }
1068
+ )
1069
+ constraints.update(
1070
+ {
1071
+ f"constraint_socmax_{i}": plp.LpConstraint(
1072
+ e=-plp.lpSum(
1073
+ P_sto_pos[j]
1074
+ * (1 / self.plant_conf["battery_discharge_efficiency"])
1075
+ + self.plant_conf["battery_charge_efficiency"]
1076
+ * P_sto_neg[j]
1077
+ for j in range(i)
1078
+ ),
1079
+ sense=plp.LpConstraintLE,
1080
+ rhs=(
1081
+ self.plant_conf["battery_nominal_energy_capacity"]
1082
+ / self.timeStep
1083
+ )
1084
+ * (
1085
+ self.plant_conf["battery_maximum_state_of_charge"]
1086
+ - soc_init
1087
+ ),
1088
+ )
1089
+ for i in set_I
1090
+ }
1091
+ )
1092
+ constraints.update(
1093
+ {
1094
+ f"constraint_socmin_{i}": plp.LpConstraint(
1095
+ e=plp.lpSum(
1096
+ P_sto_pos[j]
1097
+ * (1 / self.plant_conf["battery_discharge_efficiency"])
1098
+ + self.plant_conf["battery_charge_efficiency"]
1099
+ * P_sto_neg[j]
1100
+ for j in range(i)
1101
+ ),
1102
+ sense=plp.LpConstraintLE,
1103
+ rhs=(
1104
+ self.plant_conf["battery_nominal_energy_capacity"]
1105
+ / self.timeStep
1106
+ )
1107
+ * (
1108
+ soc_init
1109
+ - self.plant_conf["battery_minimum_state_of_charge"]
1110
+ ),
1111
+ )
1112
+ for i in set_I
1113
+ }
1114
+ )
1115
+ constraints.update(
1116
+ {
1117
+ f"constraint_socfinal_{0}": plp.LpConstraint(
1118
+ e=plp.lpSum(
1119
+ P_sto_pos[i]
1120
+ * (1 / self.plant_conf["battery_discharge_efficiency"])
1121
+ + self.plant_conf["battery_charge_efficiency"]
1122
+ * P_sto_neg[i]
1123
+ for i in set_I
1124
+ ),
1125
+ sense=plp.LpConstraintEQ,
1126
+ rhs=(soc_init - soc_final)
1127
+ * self.plant_conf["battery_nominal_energy_capacity"]
1128
+ / self.timeStep,
1129
+ )
1130
+ }
1131
+ )
1132
+ opt_model.constraints = constraints
1133
+
1134
+ ## Finally, we call the solver to solve our optimization model:
1135
+ timeout = self.optim_conf["lp_solver_timeout"]
1136
+ # solving with default solver CBC
1137
+ if self.lp_solver == "PULP_CBC_CMD":
1138
+ opt_model.solve(PULP_CBC_CMD(msg=0, timeLimit=timeout, threads=7))
1139
+ elif self.lp_solver == "GLPK_CMD":
1140
+ opt_model.solve(GLPK_CMD(msg=0, timeLimit=timeout, threads=7))
1141
+ elif self.lp_solver == "COIN_CMD":
1142
+ opt_model.solve(
1143
+ COIN_CMD(msg=0, path=self.lp_solver_path, timeLimit=timeout, threads=7)
1144
+ )
1145
+ else:
1146
+ self.logger.warning("Solver %s unknown, using default", self.lp_solver)
1147
+ opt_model.solve(PULP_CBC_CMD(msg=0, timeLimit=timeout, threads=7))
1148
+
1149
+ # The status of the solution is printed to the screen
1150
+ self.optim_status = plp.LpStatus[opt_model.status]
1151
+ self.logger.info("Status: " + self.optim_status)
1152
+ if plp.value(opt_model.objective) is None:
1153
+ self.logger.warning("Cost function cannot be evaluated")
1154
+ return
1155
+ else:
1156
+ self.logger.info(
1157
+ "Total value of the Cost function = %.02f",
1158
+ plp.value(opt_model.objective),
1159
+ )
1160
+
1161
+ # Build results Dataframe
1162
+ opt_tp = pd.DataFrame()
1163
+ opt_tp["P_PV"] = [P_PV[i] for i in set_I]
1164
+ opt_tp["P_Load"] = [P_load[i] for i in set_I]
1165
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
1166
+ opt_tp[f"P_deferrable{k}"] = [P_deferrable[k][i].varValue for i in set_I]
1167
+ opt_tp["P_grid_pos"] = [P_grid_pos[i].varValue for i in set_I]
1168
+ opt_tp["P_grid_neg"] = [P_grid_neg[i].varValue for i in set_I]
1169
+ opt_tp["P_grid"] = [
1170
+ P_grid_pos[i].varValue + P_grid_neg[i].varValue for i in set_I
1171
+ ]
1172
+ if self.optim_conf["set_use_battery"]:
1173
+ opt_tp["P_batt"] = [
1174
+ P_sto_pos[i].varValue + P_sto_neg[i].varValue for i in set_I
1175
+ ]
1176
+ SOC_opt_delta = [
1177
+ (
1178
+ P_sto_pos[i].varValue
1179
+ * (1 / self.plant_conf["battery_discharge_efficiency"])
1180
+ + self.plant_conf["battery_charge_efficiency"]
1181
+ * P_sto_neg[i].varValue
1182
+ )
1183
+ * (self.timeStep / (self.plant_conf["battery_nominal_energy_capacity"]))
1184
+ for i in set_I
1185
+ ]
1186
+ SOCinit = copy.copy(soc_init)
1187
+ SOC_opt = []
1188
+ for i in set_I:
1189
+ SOC_opt.append(SOCinit - SOC_opt_delta[i])
1190
+ SOCinit = SOC_opt[i]
1191
+ opt_tp["SOC_opt"] = SOC_opt
1192
+ if self.plant_conf["inverter_is_hybrid"]:
1193
+ opt_tp["P_hybrid_inverter"] = [P_hybrid_inverter[i].varValue for i in set_I]
1194
+ if self.plant_conf["compute_curtailment"]:
1195
+ opt_tp["P_PV_curtailment"] = [P_PV_curtailment[i].varValue for i in set_I]
1196
+ opt_tp.index = data_opt.index
1197
+
1198
+ # Lets compute the optimal cost function
1199
+ P_def_sum_tp = []
1200
+ for i in set_I:
1201
+ P_def_sum_tp.append(
1202
+ sum(
1203
+ P_deferrable[k][i].varValue
1204
+ for k in range(self.optim_conf["number_of_deferrable_loads"])
1205
+ )
1206
+ )
1207
+ opt_tp["unit_load_cost"] = [unit_load_cost[i] for i in set_I]
1208
+ opt_tp["unit_prod_price"] = [unit_prod_price[i] for i in set_I]
1209
+ if self.optim_conf["set_total_pv_sell"]:
1210
+ opt_tp["cost_profit"] = [
1211
+ -0.001
1212
+ * self.timeStep
1213
+ * (
1214
+ unit_load_cost[i] * (P_load[i] + P_def_sum_tp[i])
1215
+ + unit_prod_price[i] * P_grid_neg[i].varValue
1216
+ )
1217
+ for i in set_I
1218
+ ]
1219
+ else:
1220
+ opt_tp["cost_profit"] = [
1221
+ -0.001
1222
+ * self.timeStep
1223
+ * (
1224
+ unit_load_cost[i] * P_grid_pos[i].varValue
1225
+ + unit_prod_price[i] * P_grid_neg[i].varValue
1226
+ )
1227
+ for i in set_I
1228
+ ]
1229
+
1230
+ if self.costfun == "profit":
1231
+ if self.optim_conf["set_total_pv_sell"]:
1232
+ opt_tp["cost_fun_profit"] = [
1233
+ -0.001
1234
+ * self.timeStep
1235
+ * (
1236
+ unit_load_cost[i] * (P_load[i] + P_def_sum_tp[i])
1237
+ + unit_prod_price[i] * P_grid_neg[i].varValue
1238
+ )
1239
+ for i in set_I
1240
+ ]
1241
+ else:
1242
+ opt_tp["cost_fun_profit"] = [
1243
+ -0.001
1244
+ * self.timeStep
1245
+ * (
1246
+ unit_load_cost[i] * P_grid_pos[i].varValue
1247
+ + unit_prod_price[i] * P_grid_neg[i].varValue
1248
+ )
1249
+ for i in set_I
1250
+ ]
1251
+ elif self.costfun == "cost":
1252
+ if self.optim_conf["set_total_pv_sell"]:
1253
+ opt_tp["cost_fun_cost"] = [
1254
+ -0.001
1255
+ * self.timeStep
1256
+ * unit_load_cost[i]
1257
+ * (P_load[i] + P_def_sum_tp[i])
1258
+ for i in set_I
1259
+ ]
1260
+ else:
1261
+ opt_tp["cost_fun_cost"] = [
1262
+ -0.001 * self.timeStep * unit_load_cost[i] * P_grid_pos[i].varValue
1263
+ for i in set_I
1264
+ ]
1265
+ elif self.costfun == "self-consumption":
1266
+ if type_self_conso == "maxmin":
1267
+ opt_tp["cost_fun_selfcons"] = [
1268
+ -0.001 * self.timeStep * unit_load_cost[i] * SC[i].varValue
1269
+ for i in set_I
1270
+ ]
1271
+ elif type_self_conso == "bigm":
1272
+ opt_tp["cost_fun_selfcons"] = [
1273
+ -0.001
1274
+ * self.timeStep
1275
+ * (
1276
+ unit_load_cost[i] * P_grid_pos[i].varValue
1277
+ + unit_prod_price[i] * P_grid_neg[i].varValue
1278
+ )
1279
+ for i in set_I
1280
+ ]
1281
+ else:
1282
+ self.logger.error("The cost function specified type is not valid")
1283
+
1284
+ # Add the optimization status
1285
+ opt_tp["optim_status"] = self.optim_status
1286
+
1287
+ # Debug variables
1288
+ if debug:
1289
+ for k in range(self.optim_conf["number_of_deferrable_loads"]):
1290
+ opt_tp[f"P_def_start_{k}"] = [P_def_start[k][i].varValue for i in set_I]
1291
+ opt_tp[f"P_def_bin2_{k}"] = [P_def_bin2[k][i].varValue for i in set_I]
1292
+ for i, predicted_temp in predicted_temps.items():
1293
+ opt_tp[f"predicted_temp_heater{i}"] = pd.Series(
1294
+ [
1295
+ round(pt.value(), 2)
1296
+ if isinstance(pt, plp.LpAffineExpression)
1297
+ else pt
1298
+ for pt in predicted_temp
1299
+ ],
1300
+ index=opt_tp.index,
1301
+ )
1302
+ opt_tp[f"target_temp_heater{i}"] = pd.Series(
1303
+ self.optim_conf["def_load_config"][i]["thermal_config"][
1304
+ "desired_temperatures"
1305
+ ],
1306
+ index=opt_tp.index,
1307
+ )
1308
+
1309
+ # Battery initialization logging
1310
+ if self.optim_conf["set_use_battery"]:
1311
+ self.logger.debug(f"Battery usage enabled. Initial SOC: {soc_init}, Final SOC: {soc_final}")
1312
+
1313
+ # Deferrable load initialization logging
1314
+ self.logger.debug(f"Deferrable load operating hours: {def_total_hours}")
1315
+ self.logger.debug(f"Deferrable load timesteps: {def_total_timestep}")
1316
+ self.logger.debug(f"Deferrable load start timesteps: {def_start_timestep}")
1317
+ self.logger.debug(f"Deferrable load end timesteps: {def_end_timestep}")
1318
+
1319
+ # Objective function logging
1320
+ self.logger.debug(f"Selected cost function type: {self.costfun}")
1321
+
1322
+ # Solver execution logging
1323
+ self.logger.debug(f"Solver selected: {self.lp_solver}")
1324
+ self.logger.info(f"Optimization status: {self.optim_status}")
1325
+
1326
+ # Results logging
1327
+
1328
+ return opt_tp
1329
+
1330
+ def perform_perfect_forecast_optim(
1331
+ self, df_input_data: pd.DataFrame, days_list: pd.date_range
1332
+ ) -> pd.DataFrame:
1333
+ r"""
1334
+ Perform an optimization on historical data (perfectly known PV production).
1335
+
1336
+ :param df_input_data: A DataFrame containing all the input data used for \
1337
+ the optimization, notably photovoltaics and load consumption powers.
1338
+ :type df_input_data: pandas.DataFrame
1339
+ :param days_list: A list of the days of data that will be retrieved from \
1340
+ hass and used for the optimization task. We will retrieve data from \
1341
+ now and up to days_to_retrieve days
1342
+ :type days_list: list
1343
+ :return: opt_res: A DataFrame containing the optimization results
1344
+ :rtype: pandas.DataFrame
1345
+
1346
+ """
1347
+ self.logger.info("Perform optimization for perfect forecast scenario")
1348
+ self.days_list_tz = days_list.tz_convert(self.time_zone).round(self.freq)[
1349
+ :-1
1350
+ ] # Converted to tz and without the current day (today)
1351
+ self.opt_res = pd.DataFrame()
1352
+ for day in self.days_list_tz:
1353
+ self.logger.info(
1354
+ "Solving for day: "
1355
+ + str(day.day)
1356
+ + "-"
1357
+ + str(day.month)
1358
+ + "-"
1359
+ + str(day.year)
1360
+ )
1361
+ # Prepare data
1362
+ if day.tzinfo is None:
1363
+ day = day.replace(tzinfo=self.time_zone) # Assign timezone if naive
1364
+ else:
1365
+ day = day.astimezone(self.time_zone)
1366
+ day_start = day
1367
+ day_end = day + self.time_delta - self.freq
1368
+ if day_start.tzinfo != day_end.tzinfo:
1369
+ self.logger.warning(
1370
+ f"Skipping day {day} as days have ddifferent timezone, probably because of DST."
1371
+ )
1372
+ continue # Skip this day and move to the next iteration
1373
+ else:
1374
+ day_start = day_start.astimezone(self.time_zone).isoformat()
1375
+ day_end = day_end.astimezone(self.time_zone).isoformat()
1376
+ # Generate the date range for the current day
1377
+ day_range = pd.date_range(start=day_start, end=day_end, freq=self.freq)
1378
+ # Check if all timestamps in the range exist in the DataFrame index
1379
+ if not day_range.isin(df_input_data.index).all():
1380
+ self.logger.warning(
1381
+ f"Skipping day {day} as some timestamps are missing in the data."
1382
+ )
1383
+ continue # Skip this day and move to the next iteration
1384
+ # If all timestamps exist, proceed with the data preparation
1385
+ data_tp = df_input_data.copy().loc[day_range]
1386
+ P_PV = data_tp[self.var_PV].values
1387
+ P_load = data_tp[self.var_load_new].values
1388
+ unit_load_cost = data_tp[self.var_load_cost].values # €/kWh
1389
+ unit_prod_price = data_tp[self.var_prod_price].values # €/kWh
1390
+ # Call optimization function
1391
+ opt_tp = self.perform_optimization(
1392
+ data_tp, P_PV, P_load, unit_load_cost, unit_prod_price
1393
+ )
1394
+ if len(self.opt_res) == 0:
1395
+ self.opt_res = opt_tp
1396
+ else:
1397
+ self.opt_res = pd.concat([self.opt_res, opt_tp], axis=0)
1398
+
1399
+ return self.opt_res
1400
+
1401
+ def perform_dayahead_forecast_optim(
1402
+ self, df_input_data: pd.DataFrame, P_PV: pd.Series, P_load: pd.Series
1403
+ ) -> pd.DataFrame:
1404
+ r"""
1405
+ Perform a day-ahead optimization task using real forecast data. \
1406
+ This type of optimization is intented to be launched once a day.
1407
+
1408
+ :param df_input_data: A DataFrame containing all the input data used for \
1409
+ the optimization, notably the unit load cost for power consumption.
1410
+ :type df_input_data: pandas.DataFrame
1411
+ :param P_PV: The forecasted PV power production.
1412
+ :type P_PV: pandas.DataFrame
1413
+ :param P_load: The forecasted Load power consumption. This power should \
1414
+ not include the power from the deferrable load that we want to find.
1415
+ :type P_load: pandas.DataFrame
1416
+ :return: opt_res: A DataFrame containing the optimization results
1417
+ :rtype: pandas.DataFrame
1418
+
1419
+ """
1420
+ self.logger.info("Perform optimization for the day-ahead")
1421
+ unit_load_cost = df_input_data[self.var_load_cost].values # €/kWh
1422
+ unit_prod_price = df_input_data[self.var_prod_price].values # €/kWh
1423
+ # Call optimization function
1424
+ self.opt_res = self.perform_optimization(
1425
+ df_input_data,
1426
+ P_PV.values.ravel(),
1427
+ P_load.values.ravel(),
1428
+ unit_load_cost,
1429
+ unit_prod_price,
1430
+ )
1431
+ return self.opt_res
1432
+
1433
+ def perform_naive_mpc_optim(
1434
+ self,
1435
+ df_input_data: pd.DataFrame,
1436
+ P_PV: pd.Series,
1437
+ P_load: pd.Series,
1438
+ prediction_horizon: int,
1439
+ soc_init: float | None = None,
1440
+ soc_final: float | None = None,
1441
+ def_total_hours: list | None = None,
1442
+ def_total_timestep: list | None = None,
1443
+ def_start_timestep: list | None = None,
1444
+ def_end_timestep: list | None = None,
1445
+ ) -> pd.DataFrame:
1446
+ r"""
1447
+ Perform a naive approach to a Model Predictive Control (MPC). \
1448
+ This implementaion is naive because we are not using the formal formulation \
1449
+ of a MPC. Only the sense of a receiding horizon is considered here. \
1450
+ This optimization is more suitable for higher optimization frequency, ex: 5min.
1451
+
1452
+ :param df_input_data: A DataFrame containing all the input data used for \
1453
+ the optimization, notably the unit load cost for power consumption.
1454
+ :type df_input_data: pandas.DataFrame
1455
+ :param P_PV: The forecasted PV power production.
1456
+ :type P_PV: pandas.DataFrame
1457
+ :param P_load: The forecasted Load power consumption. This power should \
1458
+ not include the power from the deferrable load that we want to find.
1459
+ :type P_load: pandas.DataFrame
1460
+ :param prediction_horizon: The prediction horizon of the MPC controller in number \
1461
+ of optimization time steps.
1462
+ :type prediction_horizon: int
1463
+ :param soc_init: The initial battery SOC for the optimization. This parameter \
1464
+ is optional, if not given soc_init = soc_final = soc_target from the configuration file.
1465
+ :type soc_init: float
1466
+ :param soc_final: The final battery SOC for the optimization. This parameter \
1467
+ is optional, if not given soc_init = soc_final = soc_target from the configuration file.
1468
+ :type soc_final:
1469
+ :param def_total_timestep: The functioning timesteps for this iteration for each deferrable load. \
1470
+ (For continuous deferrable loads: functioning timesteps at nominal power)
1471
+ :type def_total_timestep: list
1472
+ :param def_total_hours: The functioning hours for this iteration for each deferrable load. \
1473
+ (For continuous deferrable loads: functioning hours at nominal power)
1474
+ :type def_total_hours: list
1475
+ :param def_start_timestep: The timestep as from which each deferrable load is allowed to operate.
1476
+ :type def_start_timestep: list
1477
+ :param def_end_timestep: The timestep before which each deferrable load should operate.
1478
+ :type def_end_timestep: list
1479
+ :return: opt_res: A DataFrame containing the optimization results
1480
+ :rtype: pandas.DataFrame
1481
+
1482
+ """
1483
+ self.logger.info("Perform an iteration of a naive MPC controller")
1484
+ if prediction_horizon < 5:
1485
+ self.logger.error(
1486
+ "Set the MPC prediction horizon to at least 5 times the optimization time step"
1487
+ )
1488
+ return pd.DataFrame()
1489
+ else:
1490
+ df_input_data = copy.deepcopy(df_input_data)[
1491
+ df_input_data.index[0] : df_input_data.index[prediction_horizon - 1]
1492
+ ]
1493
+ unit_load_cost = df_input_data[self.var_load_cost].values # €/kWh
1494
+ unit_prod_price = df_input_data[self.var_prod_price].values # €/kWh
1495
+ # Call optimization function
1496
+ self.opt_res = self.perform_optimization(
1497
+ df_input_data,
1498
+ P_PV.values.ravel(),
1499
+ P_load.values.ravel(),
1500
+ unit_load_cost,
1501
+ unit_prod_price,
1502
+ soc_init=soc_init,
1503
+ soc_final=soc_final,
1504
+ def_total_hours=def_total_hours,
1505
+ def_total_timestep=def_total_timestep,
1506
+ def_start_timestep=def_start_timestep,
1507
+ def_end_timestep=def_end_timestep,
1508
+ )
1509
+ return self.opt_res
1510
+
1511
+ @staticmethod
1512
+ def validate_def_timewindow(
1513
+ start: int, end: int, min_steps: int, window: int
1514
+ ) -> tuple[int, int, str]:
1515
+ r"""
1516
+ Helper function to validate (and if necessary: correct) the defined optimization window of a deferrable load.
1517
+
1518
+ :param start: Start timestep of the optimization window of the deferrable load
1519
+ :type start: int
1520
+ :param end: End timestep of the optimization window of the deferrable load
1521
+ :type end: int
1522
+ :param min_steps: Minimal timesteps during which the load should operate (at nominal power)
1523
+ :type min_steps: int
1524
+ :param window: Total number of timesteps in the optimization window
1525
+ :type window: int
1526
+ :return: start_validated: Validated start timestep of the optimization window of the deferrable load
1527
+ :rtype: int
1528
+ :return: end_validated: Validated end timestep of the optimization window of the deferrable load
1529
+ :rtype: int
1530
+ :return: warning: Any warning information to be returned from the validation steps
1531
+ :rtype: string
1532
+
1533
+ """
1534
+ start_validated = 0
1535
+ end_validated = 0
1536
+ warning = None
1537
+ # Verify that start <= end
1538
+ if start <= end or start <= 0 or end <= 0:
1539
+ # start and end should be within the optimization timewindow [0, window]
1540
+ start_validated = max(0, min(window, start))
1541
+ end_validated = max(0, min(window, end))
1542
+ if end_validated > 0:
1543
+ # If the available timeframe is shorter than the number of timesteps needed to meet the hours to operate (def_total_hours), issue a warning.
1544
+ if (end_validated - start_validated) < min_steps:
1545
+ warning = "Available timeframe is shorter than the specified number of hours to operate. Optimization will fail."
1546
+ else:
1547
+ warning = "Invalid timeframe for deferrable load (start timestep is not <= end timestep). Continuing optimization without timewindow constraint."
1548
+ return start_validated, end_validated, warning