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