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