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