-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathNMPC_controller.py
More file actions
1346 lines (1264 loc) · 95.7 KB
/
Copy pathNMPC_controller.py
File metadata and controls
1346 lines (1264 loc) · 95.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
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
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
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
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Import standard libraries
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Any, Union, Optional, Tuple
from dataclasses_json import dataclass_json
import numpy as np
import pandas as pd
import logging
import os
import sys
# Uncomment the following lines if CasADi is not installed in the main environment
# # Add to PATH the path where the casadi library was installed
# path_to_casadi_library = 'c:\\modelonimpact-1.8.1\\oct-dist\\myenv\\lib\\site-packages' # Example path where CasADi 3.6.6 is installed
# if os.path.exists(path_to_casadi_library):
# sys.path.insert(0, path_to_casadi_library)
import casadi as ca
# Import custom utilities and functions
from model_input import*
from general_utils.filter_df_by_timestamp import*
from general_utils.compare_arrays import*
from general_utils.read_file import*
# Define an external cost function for the nominal problem of the NMPC controller
def cost_function_nominal(Uk: ca.DM, Xk: ca.DM, dUk: ca.DM,
weights_y: np.ndarray, weights_du: float,
one_step: Callable, g: Callable,
Xk_scales: np.ndarray, Yk_scales: np.ndarray, uk_scale: float,
y_ref: pd.DataFrame, params: Dict[str, Tuple[float, float]],
nintervals: int, current_timestamp: pd.Timestamp, end_timestamp: pd.Timestamp, MPC_interval: int,
u_values_discrete: np.ndarray, param_u_values_discrete: np.ndarray, delta_t: np.ndarray, timestamp_points_discrete: np.ndarray,
mesh_finesse: int = 12, **kwargs) -> Tuple[ca.DM, Dict[str, float], List[pd.Timestamp], List[ca.MX], List[ca.MX], List[ca.MX], List[ca.MX]]:
'''
Formulate the cost function for the nominal NMPC problem.
CasADi formulation to allow automatic differentiation from symbolic expressions.
Args:
Uk (ca.DM): Control input trajectory over the prediction horizon.
Xk (ca.DM): State trajectory over the prediction horizon.
dUk (ca.DM): Control input rate of change over the prediction horizon.
weights_y (np.ndarray): Weights for the output tracking error in the cost function.
weights_du (float): Weight for the control input rate of change in the cost function.
one_step (Callable): Function to perform one step of system integration.
g (Callable): Output function mapping states to outputs.
Xk_scales (np.ndarray): Scaling factors for the states.
Yk_scales (np.ndarray): Scaling factors for the outputs.
uk_scale (float): Scaling factor for the controllable inputs.
y_ref (pd.DataFrame): Reference output trajectory for tracking (setpoints!!).
params (Dict[str, Tuple[float, float]]): Dictionary of model parameters and their scale.
nintervals (int): Number of control intervals in the prediction horizon.
current_timestamp (pd.Timestamp): Current timestamp at the start of the prediction horizon.
end_timestamp (pd.Timestamp): End timestamp at the end of the prediction horizon.
MPC_interval (int): Control interval duration in hours.
u_values_discrete (np.ndarray): Discrete flow-rate input values for system integration.
param_u_values_discrete (np.ndarray): Discrete influent characterization parameter values for system integration.
delta_t (np.ndarray): Time step sizes for system integration.
timestamp_points_discrete (np.ndarray): Discrete timestamp points for system integration.
mesh_finesse (int, optional): Number of integration steps per hour. Defaults to 12 (5 minutes per step).
Returns:
Tuple[ca.DM, Dict[str, float], List[pd.Timestamp], List[ca.MX], List[ca.MX], List[ca.MX], List[ca.MX]]:
A tuple containing the cost function value (scalar), a dictionary of additional information, a list of result timestamps,
and lists of state and output trajectories.
Note: must have consistency between 'nintervals', 'current_timestamp', 'end_timestamp' and 'timestamp_points_discrete'!!
Note: the last two outputs (z_star_list, y_star_list) are empty lists in this nominal formulation.
Needed anyway to have a common interface with the ancillary formulation, inside the NMPC class ('set_external_cost_function' method).
Note: kwargs can contain additional parameters for slackness (if used).
Note: in this version, there are 'tracking_cost' (simulation error with 'y_ref' setpoints) and 'controlrate_cost' as separate terms in the cost function.
No panalization on input utilization is included here.
Note: in this version, the function extracts output in prediction at every hour allowing for more flexibility for the next control step, i.e. asynchronous NMPC.
'''
# Extract additional parameters and variables from kwargs
# Presence of slack variables and related weights
slackness = kwargs.get('slackness', False)
if slackness:
eps1 = kwargs.get('eps1')
eps2 = kwargs.get('eps2')
weights_eps1 = kwargs.get('weights_eps1')
weights_eps2 = kwargs.get('weights_eps2')
#----------------------------------------------------------------------------------------------------------- #
# Declare cost function
tracking_cost = 0 # Initialize tracking cost of the 'y_ref' setpoints
x_list = []
y_list = [g(Xk[:,0], ca.DM([value[0] for value in params.values()]))]
result_timestamps = [current_timestamp]
time_index_at_k = 0
for k in range(nintervals):
nsteps_in_interval = timestamp_points_discrete[(timestamp_points_discrete >= current_timestamp) & (timestamp_points_discrete<= current_timestamp + pd.Timedelta(hours=MPC_interval))][:-1].shape[0]
if nsteps_in_interval != mesh_finesse*MPC_interval:
logging.warning(f'Steps in interval are {nsteps_in_interval}, but they must be {mesh_finesse*MPC_interval}.')
#----------------------------------------------------------------------------------------------------------- #
uk = Uk[k]
# Integrate over the interval
x = Xk[:,k]
# time_index_at_k = k*nsteps_in_interval # Note: uncomment if does not start from 0 (not in online application but maybe in simulation testing)
time_index_at_kplus1 = time_index_at_k + nsteps_in_interval
# Call openloop model to simulate over the interval
for i in range(time_index_at_k, time_index_at_kplus1, 1):
x = one_step(x,
ca.vertcat(ca.DM(list(u_values_discrete[i,:-1])),uk),
param_u_values_discrete[i].T,
ca.DM(delta_t[i]),
ca.DM([value[0] for value in params.values()]) # In prediction mode, the parameters are fixed.
)
y = g(x,
ca.DM([value[0] for value in params.values()]))
# Note: append values at each hour (added on 25.05.2025 to allow sporadic NMPC runs)
if (i - time_index_at_k) != 0 and (i - time_index_at_k) % mesh_finesse == 0:
x_list.append(x)
y_list.append(y)
x_list.append(x) # Note: added for Multiple-Shooting formulation
y_list.append(y)
#----------------------------------------------------------------------------------------------------------- #
# Update timestamps and indexes
current_timestamp_otherway = current_timestamp + pd.Timedelta(hours=MPC_interval)
current_timestamp = timestamp_points_discrete[time_index_at_kplus1]
if current_timestamp_otherway != current_timestamp:
logging.warning(f'Current timestamp adding control interval is {current_timestamp_otherway}, but adding time_indexes is {current_timestamp}')
result_timestamps.append(current_timestamp)
time_index_at_k += nsteps_in_interval
#----------------------------------------------------------------------------------------------------------- #
# Compute tracking cost at the end of the interval (i.e. at each control step)
if y_ref[y_ref['Timestamp'] == current_timestamp].empty:
logging.warning(f'Reference data at {current_timestamp} is missing.')
track_error = ca.vertcat(
weights_y[0]*((y[0]*y[1] - y_ref[(y_ref['Timestamp']==current_timestamp)].values[0,1])/Yk_scales[0])**2,
weights_y[1]*((y[2]/y[1] - y_ref[(y_ref['Timestamp']==current_timestamp)].values[0,2])/Yk_scales[1])**2)
if weights_y.shape[0] > 2: # Note: add vertically if S2_ref is present in y_ref
track_error = ca.vertcat(track_error, weights_y[2]*((x[6] - y_ref[(y_ref['Timestamp']==current_timestamp)].values[0,3])/Yk_scales[2])**2)
# tracking_cost += track_error.T@track_error # Add quadratic cost on control
tracking_cost += ca.sum1(track_error) # Add quadratic cost on control
#----------------------------------------------------------------------------------------------------------- #
logging.info(f'MPC predicts from {result_timestamps[0]} to {current_timestamp}')
# Final check of timestamps
if current_timestamp != end_timestamp:
logging.warning(f'{current_timestamp} is different from {end_timestamp}.')
# Final objective (sum of costs)
dUk_scaled = dUk*ca.repmat(uk_scale,1,nintervals-1)
controlrate_cost = dUk_scaled@dUk_scaled.T
cost = weights_du*controlrate_cost + tracking_cost
costs_dict = {'tracking_cost': tracking_cost, 'controlrate_cost': controlrate_cost}
# Add slackness cost if applicable
if slackness:
slack_ub_cost = ca.sum1((eps2/Xk_scales)**2)
slack_lb_cost = ca.sum1((eps1/Xk_scales)**2)
cost += weights_eps1*slack_lb_cost + weights_eps2*slack_ub_cost
costs_dict.update({'slack_ub_cost': slack_ub_cost, 'slack_lb_cost': slack_lb_cost})
# Return empty lists for z_star and y_star in nominal formulation (see note in docstring)
z_star_list = []
y_star_list = []
return cost, costs_dict, result_timestamps, x_list, y_list, z_star_list, y_star_list
# Define an external cost function for the ancillary problem of the NMPC controller
def cost_function_ancillary(Uk: ca.DM, Xk: ca.DM, dUk: ca.DM,
weights_y: np.ndarray, weights_du: float,
one_step: Callable, g: Callable,
Xk_scales: np.ndarray, Yk_scales: np.ndarray, uk_scale: float,
y_ref: pd.DataFrame, params: Dict[str, Tuple[float, float]],
nintervals: int, current_timestamp: pd.Timestamp, end_timestamp: pd.Timestamp, MPC_interval: int,
u_values_discrete: np.ndarray, param_u_values_discrete: np.ndarray, delta_t: np.ndarray, timestamp_points_discrete: np.ndarray,
mesh_finesse: int = 12, **kwargs):
'''
Formulate the cost function for the ancillary NMPC problem.
CasADi formulation to allow automatic differentiation from symbolic expressions.
Args:
Uk (ca.DM): Control input trajectory over the prediction horizon.
Xk (ca.DM): State trajectory over the prediction horizon.
dUk (ca.DM): Control input rate of change over the prediction horizon.
weights_y (np.ndarray): Weights for the output tracking error in the cost function.
weights_du (float): Weight for the control input rate of change in the cost function.
one_step (Callable): Function to perform one step of system integration.
g (Callable): Output function mapping states to outputs.
Xk_scales (np.ndarray): Scaling factors for the states.
Yk_scales (np.ndarray): Scaling factors for the outputs.
uk_scale (float): Scaling factor for the controllable inputs.
y_ref (pd.DataFrame): Reference output trajectory for tracking (setpoints!!).
params (Dict[str, Tuple[float, float]]): Dictionary of model parameters and their scale.
nintervals (int): Number of control intervals in the prediction horizon.
current_timestamp (pd.Timestamp): Current timestamp at the start of the prediction horizon.
end_timestamp (pd.Timestamp): End timestamp at the end of the prediction horizon.
MPC_interval (int): Control interval duration in hours.
u_values_discrete (np.ndarray): Discrete flow-rate input values for system integration.
param_u_values_discrete (np.ndarray): Discrete influent characterization parameter values for system integration.
delta_t (np.ndarray): Time step sizes for system integration.
timestamp_points_discrete (np.ndarray): Discrete timestamp points for system integration.
mesh_finesse (int, optional): Number of integration steps per hour. Defaults to 12 (5 minutes per step).
Returns:
Tuple[ca.DM, Dict[str, float], List[pd.Timestamp], List[ca.MX], List[ca.MX], List[ca.MX], List[ca.MX]]:
A tuple containing the cost function value (scalar), a dictionary of additional information, a list of result timestamps,
and lists of state and output trajectories, as well as optimal state and output trajectories from the nominal problem.
Note: must have consistency between 'nintervals', 'current_timestamp', 'end_timestamp' and 'timestamp_points_discrete'!!
Note: kwargs can contain additional the additional meta-parameters and reference trajectories needed for the ancillary formulation:
- formulation (str): 'ancillaryoffline' or 'ancillaryonline'.
- z_star (ca.DM): Optimal state trajectory from nominal problem (for ancillaryoffline).
- z_star0 (ca.DM): Initial state decision variable (for ancillaryonline).
- weights_xstar (float): Weight for state tracking error from nominal trajectory.
- weights_ustar (float): Weight for input tracking error from nominal trajectory.
- weights_yN (float): Weight for terminal setpoints' tracking error.
- v_star (ca.DM): Optimal control trajectory from nominal problem.
Note: in this version, there are 'tracking_cost' (simulation error with 'y_ref' setpoints) and 'controlrate_cost' as separate terms in the cost function.
No panalization on input utilization is included here.
However, there is also a tracking cost on the deviation from the nominal optimal trajectories (states and inputs i.e. z_star and v_star).
Note: in this version, the function extracts output in prediction at every hour allowing for more flexibility for the next control step, i.e. asynchronous NMPC.
'''
# Extract additional parameters, decision variables and/or reference trajectories from kwargs
formulation = kwargs.get('formulation', 'ancillaryoffline')
if formulation == 'ancillaryonline': # Note: if tube-online, get z_star0 from decision variables
z_star0 = kwargs.get('z_star0', None)
z_stark = z_star0
if formulation != 'ancillaryonline': # Note: if tube-offline, define the initial condition of the state decision variables equal to the one given as input (z_star)
z_star = kwargs.get('z_star')
weights_xstar = kwargs.get('weights_xstar')
weights_ustar = kwargs.get('weights_ustar')
weights_yN = kwargs.get('weights_yN')
v_star = kwargs.get('v_star') # Note: optimal control trajectory from the nominal problem to be used in the ancillary problem
#----------------------------------------------------------------------------------------------------------- #
# Declare cost function
tracking_cost_x = 0
tracking_cost_u = 0
x_list = []
y_list = [g(Xk[:,0], ca.DM([value[0] for value in params.values()]))]
z_star_list = []
y_star_list = []
if formulation == 'ancillaryonline': # Note: if tube-online, get z_star0 from decision variables
z_star_list = [z_star0]
y_star_list = [g(z_star0,ca.DM([value[0] for value in params.values()]))]
result_timestamps = [current_timestamp]
time_index_at_k = 0
for k in range(nintervals):
nsteps_in_interval = timestamp_points_discrete[(timestamp_points_discrete >= current_timestamp) & (timestamp_points_discrete<= current_timestamp + pd.Timedelta(hours=MPC_interval))][:-1].shape[0]
if nsteps_in_interval != mesh_finesse*MPC_interval:
logging.warning(f'Steps in interval are {nsteps_in_interval}, but they must be {mesh_finesse*MPC_interval}.')
vk = v_star[:,k]
if formulation != 'ancillaryonline': # Note: if tube-offline, define the initial condition of the state decision variables equal to the one given as input (z_star)
z_stark = z_star[:,k]
#----------------------------------------------------------------------------------------------------------- #
uk = Uk[k]
# Integrate over the interval
x = Xk[:,k]
#----------------------------------------------------------------------------------------------------------- #
track_error_x = (x - z_stark)/Xk_scales
track_error_u = (uk - vk)*uk_scale
tracking_cost_x += track_error_x.T@track_error_x
tracking_cost_u += track_error_u.T@track_error_u #Does it work like this?
#----------------------------------------------------------------------------------------------------------- #
# Call openloop model to optimize zstar(ti)
time_index_at_kplus1 = time_index_at_k + nsteps_in_interval
if formulation == 'ancillaryonline': # Note: if tube-online, call twice the openloop model. Here starting from z_star0 and apply control actions vk as given from function input
for i in range(time_index_at_k, time_index_at_kplus1, 1):
z_stark = one_step(z_stark,
ca.vertcat(ca.DM(list(u_values_discrete[i,:-1])),vk),
param_u_values_discrete[i].T,
ca.DM(delta_t[i]),
ca.DM([value[0] for value in params.values()])
)
y_star = g(z_stark,ca.DM([value[0] for value in params.values()]))
# Note: append values at each hour (added on 25.05.2025 to allow sporadic NMPC runs)
if (i - time_index_at_k) != 0 and (i - time_index_at_k) % mesh_finesse == 0:
z_star_list.append(z_stark)
y_star_list.append(y_star)
z_star_list.append(z_stark)
y_star_list.append(y_star)
#----------------------------------------------------------------------------------------------------------- #
# Call openloop model to simulate over the interval
for i in range(time_index_at_k, time_index_at_kplus1, 1):
x = one_step(x,
ca.vertcat(ca.DM(list(u_values_discrete[i,:-1])),uk),
param_u_values_discrete[i].T,
ca.DM(delta_t[i]),
ca.DM([value[0] for value in params.values()])
)
y = g(x,ca.DM([value[0] for value in params.values()]))
# Note: append values at each hour (added on 25.05.2025 to allow sporadic NMPC runs)
if (i - time_index_at_k) != 0 and (i - time_index_at_k) % mesh_finesse == 0:
x_list.append(x)
y_list.append(y)
x_list.append(x) # Note: added for Multiple-Shooting formulation
y_list.append(y)
#----------------------------------------------------------------------------------------------------------- #
# Update timestamps and indexes
current_timestamp_otherway = current_timestamp + pd.Timedelta(hours=MPC_interval)
current_timestamp = timestamp_points_discrete[time_index_at_kplus1]
if current_timestamp_otherway != current_timestamp:
logging.warning(f'Current timestamp adding control interval is {current_timestamp_otherway}, but adding time_indexes is {current_timestamp}')
result_timestamps.append(current_timestamp)
time_index_at_k += nsteps_in_interval
#----------------------------------------------------------------------------------------------------------- #
# Compute tracking cost at the end of the interval (i.e. at each control step)
logging.info(f'MPC predicts from {result_timestamps[0]} to {current_timestamp}')
# Final check of timestamps
if current_timestamp != end_timestamp:
logging.warning(f'{current_timestamp} is different from {timestamp_points_discrete[-1]}.')
# Final objective (sum of costs)
dUk_scaled = dUk*ca.repmat(uk_scale,1,nintervals-1)
controlrate_cost = dUk_scaled@dUk_scaled.T
terminal_error = ca.vertcat(
weights_y[0]*((y[0]*y[1] - y_ref[(y_ref['Timestamp']==current_timestamp)].values[0,1])/Yk_scales[0])**2,
weights_y[1]*((y[2]/y[1] - y_ref[(y_ref['Timestamp']==current_timestamp)].values[0,2])/Yk_scales[1])**2)
if weights_y.shape[0] > 2: # Note: add vertically if S2_ref is present in y_ref
terminal_error = ca.vertcat(terminal_error, weights_y[2]*((x[6] - y_ref[(y_ref['Timestamp']==current_timestamp)].values[0,3])/Yk_scales[2])**2)
terminal_cost = ca.sum1(terminal_error)
cost = weights_du*controlrate_cost + weights_xstar*tracking_cost_x + weights_ustar*tracking_cost_u + weights_yN*terminal_cost
costs_dict = {'terminal_cost': terminal_cost, 'controlrate_cost': controlrate_cost,
'tracking_cost_x': tracking_cost_x, 'tracking_cost_u': tracking_cost_u}
return cost, costs_dict, result_timestamps, x_list, y_list, z_star_list, y_star_list
############################################################################################################
@dataclass_json
@dataclass
class NMPC:
"""
A class for the NMPC controller block, which, based on the predictions of the 'model' object initialized in the 'init_state',
computes the optimal control action solving a constrained optimization problem, satisfying the 'constraints' and minimizing the 'cost_function'.
Designed for integration with open-loop model simulation, EKF and parameter online recursive update.
Attributes:
model (object): The model object containing the system dynamics and outputs as CasADI functions.
modelname (str): Name of the model, for file naming purposes.
var_couples_list (List): List of model-data variable couples (used to create the 'status' dictionary only).
control_interval (float): Control interval in hours.
N (np.ndarray): Prediction horizon in days.
Nu (np.ndarray): Control horizon in days (must be less than or equal to N).
ref_df (pd.DataFrame): DataFrame containing the reference trajectories with a 'Timestamp' column (setpoints!!).
Optional attributes:
integrator_parameters (Optional[Dict], optional): Dictionary containing integrator parameters (over which to evaluate the prediction model in the future). Defaults to None.
u_values (np.ndarray, optional): Array of flow-rate input values for the prediction model over the simulation horizon. Defaults to None.
param_u_values (np.ndarray, optional): Array of parameter characterization input values for the prediction model over the simulation horizon. Defaults to None.
delta_t (np.ndarray, optional): Array of time step sizes for the prediction model over the simulation horizon. Defaults to None.
time_points (np.ndarray, optional): Array of time points for the prediction model over the simulation horizon. Defaults to None.
timestamp_points (np.ndarray, optional): Array of timestamp points for the prediction model over the simulation horizon. Defaults to None.
state_scales (np.ndarray, optional): Scaling factors for the states. Defaults to None. Can be also object of type ca.DM.
output_scales (np.ndarray, optional): Scaling factors for the outputs. Defaults to None. Can be also object of type ca.DM.
input_scale (np.ndarray, optional): Scaling factors for the controllable inputs. Defaults to None. Can be also object of type ca.DM.
cost_function (Callable, optional): Cost function to be minimized. Defaults to cost_function_nominal.
init_state (Optional[np.ndarray], optional): Initial state of the system. Defaults to None.
slackness (Optional[bool], optional): Whether to include slack variables in the optimization problem. Defaults to False.
problemtype (str, optional): Type of NMPC problem ('NOMINAL' or 'ANCILLARY'). Defaults to 'NOMINAL'.
formulation (str, optional): Formulation of the NMPC problem ('nominal', 'ancillaryoffline', 'ancillaryonline'). Defaults to 'nominal'.
current_date (datetime, optional): Current date and time for file naming purposes. Defaults to datetime.now().replace(microsecond=0).
opti_parameters (Optional[Dict[str, float]], optional): Dictionary of the controller meta-parameters for design/tuning (e.g. weights for the cost function). Defaults to None.
costs_dict (Optional[Dict[str, float]], optional): Dictionary to store the different cost terms after optimization. Defaults to None.
constraints_dict (Optional[Dict[str, float]], optional): Dictionary to store the different constraint after optimization. Defaults to None.
opti (ca.Opti): CasADi Opti instance for the NMPC optimization problem.
solution (ca.OptiSol): Solution of the NMPC optimization problem after solving. Defaults to None.
x_list (ca.MX): List of symbolic state values in the multiple-shooting formulation (used to transfer values from cost function to constraints). Defaults to None.
y_list (ca.MX): List of symbolic output values in the multiple-shooting formulation (used to extract y_star output values from opti.solution, for saving purposes). Defaults to None.
logger (logging.Logger): Logger for logging information and warnings. Defaults to logging.getLogger(__name__).
status (Optional[dict], optional): Dictionary to store the current status of the NMPC controller (e.g., current init_state, decision variables, etc..). Defaults to None.
z_star (Optional[ca.DM], optional): Optimal state trajectory from the nominal problem to be used in the ancillary problem. Defaults to None.
v_star (Optional[ca.DM], optional): Optimal control trajectory from the nominal problem to be used in the ancillary problem. Defaults to None.
z_star_list (Optional[List[ca.MX]], optional): List of updated optimal state trajectories in the ancillaryonline formulation. Defaults to None.
y_star_list (Optional[List[ca.MX]], optional): List of updated optimal output trajectories (consistent with z_star_list) in the ancillaryonline formulation. Defaults to None.
Methods:
__post_init__: Initializes the CasADi Opti instance and problem structure.
add_constraints: Adds constraints to the Opti instance ("standard" constraints, internally defined).
additional_ext_contraint: Adds additional external constraints to the Opti instance (user-defined externally).
setup_solver: Sets up the solver for the Opti instance.
set_external_cost_function: Sets the external 'cost_function' for the Opti instance (user-defined externally).
set_cost_function: Sets the cost function for the Opti instance (internally defined inside the method itself; for the 'nominal' problem only).
set_init_decvars: Sets the initial guess for the decision variables of the NMPC problem.
set_init_state: Sets the initial state for the NMPC problem/'opti' instance (symbolic constraint on the initial states' decision variables).
set_parameters: Sets the meta-parameters for the NMPC problem/'opti' instance (e.g. weights, bounds, etc..).
run_optimization: Solves the NMPC optimization problem, extracts the solutions and updates the solution attributes (e.g. 'status')
one_glance_opti_parameters: Prints a summary of the current NMPC opti_parameters for quick reference.
prepare_inputs: Prepare model inputs before simulation, including influent characterization and flow rates.
Note: init_state should be set, after instantiation, before calling the 'solve' method, calling the 'set_init_state' method.
Note: CasADi-OptiStack is used.
Note: multiple-shooting formulation (states are considered as decision variables and system dynamics are imposed as constraints).
Note: 'model' must contain CasADI-based functions for the model dynamics and outputs.
Note: this version extracts output in prediction at every hour allowing for more flexibility for the next control step i.e. asynchronous NMPC.
Note: run 'prepare_inputs', that reads the start/end timestamps from the 'integrator_parameters' attribute,
to set 'start_timestamp'-'end_timestamp', as well as the other inputs required to perform integration
('u_values_discrete', 'param_u_values_discrete', 'delta_step_discrete', 'time_points_discrete')!!.
Note: and/or give specific inputs to the other internal methods to ovverride (once again/more).
Note: different NMPC problem formulations are possible (see [Carecci *et al.*, 2026](https://doi.org/10.48550/arXiv.2601.01157) for more details and descriptions):
formulation: str = 'nominal' or 'ancillaryoffline' or 'ancillaryonline'.
Regardless these parameters, two different NMPC classes must be instantiated externally for online tube-based NMPC version.
problemtype: str 'NOMINAL' or 'ANCILLARY' to create 'NOMINAL' and 'ANCILLARY' problems.
In 'classical': 'fomulation' is always 'nominal'.
In 'tube-offline': The NOMINAL problem can have 'slackness' or not, and the 'formulation' is 'nominal'.
The ANCILLARY problem has 'slackness'=False and the 'formulation' is 'ancillaryoffline'.
In 'tube-online': The NOMINAL problem can have 'slackness' or not, and the 'formulation' is 'ancillaryoffline'....why??? 'nominal'!!!!
The ANCILLARY problem has 'slackness'=False and the 'formulation' is 'ancillaryonline'.
Recap of decision variables and parameters (and relation with official nomenclature of [Carecci *et al.*, 2026](https://doi.org/10.48550/arXiv.2601.01157)):
Decision Variables:
Xk: states over the prediction horizon (x in ancillary problem, z in nominal problem)
Uk: control inputs over the prediction horizon (u in ancillary problem, v in nominal problem)
dUk: control input rate of change over the prediction horizon (du in ancillary problem, dv in nominal problem)
eps1, eps2: slack variables for state constraints (\epsilon_lb and \epsilon_ub only nominal problem)
z_star0: initial value of the nominal states re-optimized in the ancillary problem (only ancillary problem, online version)
Other quantities:
z_star: optimal nominal states over the prediction horizon (only ancillary problem, offline version)
v_star: optimal nominal control inputs over the prediction horizon (only ancillary problem)
x_star, u_star: name given to the optimal values of Xk and Uk
y_star: name given to optimal outputs over the prediction horizon (evaluate from x_star)
Parameters:
weights_y: weights for output tracking error in the cost function (W_y in paper)
weights_du: weight for control input rate of change in the cost function (not present in paper, added for practical reasons)
weights_xstar: weight for nominal states tracking error in the ancillary cost function (only ancillary problem) (w_x in paper)
weights_ustar: weight for nominal control inputs tracking error in the ancillary cost function (only ancillary problem) (w_u in paper)
weights_yN: weight for terminal setpoints' tracking error in the ancillary cost function (only ancillary problem) (w_{y_{H_p}} in paper)
bnds_x: bounds on the states (bounds on Xk, X in ancillary problem, Z in nominal problem)
bnds_u: bounds on the control inputs (bounds on Uk, U in ancillary problem, same in nominal problem)
bnds_du: bounds on the control input rate of change (bounds on dUk, DU in ancillary problem, same in nominal problem)
bnds_z: bounds on the nominal states in the ancillaryonline formulation (only ancillary problem, online version)
(bounds on optimal state trajectory computed starting from z_star0, Z in paper)
Note: 'x_star' is extracted and saved extenally, instead of 'x_list',
since it considers 'init_state' and not the point computed after integration on the last interval.
Actually, to warm-up, what is done is to remove the initial point from 'x_star' and duplicate the last one
(in future it could also be possible to use directly 'x_list' to warm-up the states...but not possible with the control actions).
IMPORTANT NOTE: it is assumed in the decision variables' definition inside '__post_init__' that the
control action is only one-dimensional (single input system)!!! User should modify accordingly if needed.
"""
model: object
modelname: str
var_couples_list: List
control_interval: float
N: np.ndarray
Nu: np.ndarray
ref_df: pd.DataFrame
integrator_parameters: Optional[Dict] = None
u_values: np.ndarray = field(init=True, default=None)
param_u_values: np.ndarray = field(init=True, default=None)
delta_t: np.ndarray = field(init=True, default=None)
time_points: np.ndarray = field(init=True, default=None)
timestamp_points: np.ndarray = field(init=True, default=None)
state_scales: np.ndarray= None
output_scales: np.ndarray = None
input_scale: np.ndarray = None
cost_function: Callable = cost_function_nominal # Note: used in new implementation (07.02.2025)
init_state: Optional[np.ndarray] = None
slackness: Optional[bool] = False
problemtype: str = 'NOMINAL'
formulation: str = 'nominal'
current_date: datetime = datetime.now().replace(microsecond=0) # Note: ROUND?? .round('T') to round to the nearest minute, but only with pd.Timestamp
opti_parameters: Optional[Dict[str, float]] = None
costs_dict: Optional[Dict[str, float]] = None
constraints_dict: Optional[Dict[str, float]] = None
opti: Optional[ca.Opti] = field(init=False)
solution: Optional[ca.OptiSol] = None
x_list: Optional[List[ca.MX]] = None
y_list: Optional[List[ca.MX]] = None
logger: logging.Logger = logging.getLogger(__name__)
status: Optional[dict] = None
z_star: Optional[ca.DM] = None # Note: used only in the ANCILLARY cases
v_star: Optional[ca.DM] = None # Note: used only in the ANCILLARY cases
z_star_list: Optional[List[ca.MX]] = None # Note: used only in the ANCILLARY cases
y_star_list: Optional[List[ca.MX]] = None # Note: used only in the ANCILLARY cases
def __post_init__(self):
"""
Form the NMPC optimization problem using CasADi-OptiStack.
Initialize the CasADi Opti instance and problem structure.
Declare the OptiStack decision variables and parameters.
Decision Variables and Parameters declared:
all problems and formulations:
VAR: {Uk, Xk}; PARAM: {weights_y, weights_du, bnds_u, bnds_du, bnds_x};
if slackness:
VAR: {eps1, eps2}; PARAM: {weights_eps1, weights_eps2};
if problem = ancillary:
PARAM: {weights_xstar, weights_ustar, weights_yN};
if problem = ancillary and problem = ancillaryonline:
VAR: {z_star0}, PARAM: {weights_xstar, weights_ustar, weights_yN, bnds_z}.
"""
self.logger.info('-----------------------------------------------\n')
self.logger.info(f'Instantiting Opti. CasADi version loaded is {ca.__version__}')
self.opti = ca.Opti()
# DEFINE SOME META-QUANTITIES
self.nintervals = int(self.N*(24/self.control_interval)) # Number of intervals over which to solve the optimization problem
self.logger.info(f'Number of intervals within the prediction horizon is {self.nintervals}')
self.nintervals_moveblocking = int(self.Nu*(24/self.control_interval)) # Number of intervals over which to solve the optimization problem with move-blocking
if self.state_scales is None or self.output_scales is None or self.input_scale is None:
self.state_scales = ca.DM.ones(self.model.init_state.shape[0])
self.output_scales = ca.DM.ones(self.ref_df.shape[1]-1) # Not intended anymore as [qTTot, xM_gb, xC_gb], but as the 'y_ref' present in the 'ref_df' and in 'cost_function'. No time-varying!!
self.input_scale = ca.DM.ones(1)
self.logger.info('Some scales are not given as input, set to default 1.')
self.state_scales = ca.DM(self.state_scales)
self.output_scales = ca.DM(self.output_scales)
self.input_scale = ca.DM(self.input_scale)
# CHECK COHERENCY BETWEEN 'COST_FUNCTION', 'SLACKNESS' AND 'FORMULATION'
if self.problemtype == 'NOMINAL':
self.cost_function = cost_function_nominal
else:
self.cost_function = cost_function_ancillary
if self.problemtype == 'NOMINAL' and self.formulation == 'ancillaryonline':
self.logger.error('Incoherent choice of problemtype and formulation.')
return ValueError('Incoherent choice of problemtype and formulation.')
elif self.problemtype == 'ANCILLARY' and self.formulation == 'nominal' or self.problemtype == 'ANCILLARY' and self.slackness:
self.logger.error('Incoherent choice of problemtype and formulation or problemtype and slackness.')
return ValueError('Incoherent choice of problemtype and formulation or problemtype and slackness.')
# DECLARE OPTIMIZATION VARIABLES
self.Uk = self.opti.variable(1,self.nintervals)/ca.DM(ca.repmat(self.input_scale,1,self.nintervals)) # Note: assumes single input for the moment!!!
self.Xk = self.opti.variable(self.model.init_state.shape[0],self.nintervals)*ca.repmat(self.state_scales,1,self.nintervals)
self.dUk = self.Uk[:,1:] - self.Uk[:,:-1]
if self.slackness:
self.eps1 = self.opti.variable(self.model.init_state.shape[0],1)*self.state_scales # Slack on states (that are decision variables) -> LOWER BOUNDS
self.eps2 = self.opti.variable(self.model.init_state.shape[0],1)*self.state_scales # Slack on states (that are decision variables) -> UPPER BOUNDS
if self.formulation == 'ancillaryonline':
self.z_star0 = self.opti.variable(self.model.init_state.shape[0],1)*self.state_scales
# DECLARE OPTI PARAMETERS
self.weights_y = self.opti.parameter(self.ref_df.shape[1]-1,1)
self.weights_du = self.opti.parameter()
self.bnds_u = self.opti.parameter(1,2) # Note: assumes single input for the moment!!!
self.bnds_du = self.opti.parameter(1,2) # Note: assumes single input for the moment!!!
self.bnds_x = self.opti.parameter(self.model.init_state.shape[0],2)
self.opti_parameters = {'weights_y': self.weights_y, 'weights_du': self.weights_du, 'bnds_u': self.bnds_u, 'bnds_du': self.bnds_du, 'bnds_x': self.bnds_x}
if self.slackness:
self.weights_eps1 = self.opti.parameter()
self.weights_eps2 = self.opti.parameter()
self.opti_parameters.update({'weights_eps1': self.weights_eps1, 'weights_eps2': self.weights_eps2})
if self.formulation != 'nominal':
self.weights_xstar = self.opti.parameter() # Note: better to specify it as a square diagonal matrix to give a weight to each state trajectory?
self.weights_ustar = self.opti.parameter() # Note: better to specify it as a square diagonal matrix to give a weight to each control trajectory?
self.weights_yN = self.opti.parameter()
self.opti_parameters.update({'weights_xstar': self.weights_xstar, 'weights_ustar': self.weights_ustar, 'weights_yN': self.weights_yN})
if self.formulation == 'ancillaryonline':
self.bnds_z = self.opti.parameter(self.model.init_state.shape[0],2)
self.opti_parameters.update({'bnds_z': self.bnds_z})
# DEFINE STATUS DICTIONARY
# Note: uncommented entries can be added if needed
status = {
'Timestamp': self.integrator_parameters['start_timestamp'],
'end': self.integrator_parameters['end_timestamp'],
'run_Timestamp': self.current_date,
# 'Control_interval': self.control_interval,
# 'Prediction_horizon': self.N,
# 'Control_horizon': self.Nu,
}
self.status = status
############################################################################################################
def add_constraints(self, constraints: Optional[List[Callable]] = None) -> None:
"""Function to add constraints to the Opti instance."""
self.logger.info('## Adding constraints to the Opti instance...##')
# Define a dictionary to store constraints for logging purposes
self.constraints_dict = {}
# INPUT BOX BOUNDS
bnd_u = self.opti.bounded(self.bnds_u[:,0], self.Uk, self.bnds_u[:,1])
self.opti.subject_to(bnd_u)
# INPUT RATE BOX BOUNDS
bnd_du = self.opti.bounded(self.bnds_du[:,0], self.dUk, self.bnds_du[:,1])
self.opti.subject_to(bnd_du)
# STATE BOX BOUNDS
bnd_x_lb = ca.MX(0,0)
bnd_x_ub = ca.MX(0,0)
if self.formulation == 'ancillaryonline':
bnd_z_ub = ca.MX(0,0)
bnd_z_lb = ca.MX(0,0)
sampling_step = self.control_interval * 1 # Note: 1 constraint for each control interval
sampled_z_star_list = self.z_star_list[::sampling_step]
for j in range(self.nintervals):
# SLACKNESS ON STATES
if self.slackness:
bnd_x_ub_j = self.Xk[:,j] <= self.bnds_x[:,1] + self.eps2
bnd_x_lb_j = self.Xk[:,j] >= self.bnds_x[:,0] - self.eps1
# Enforce bounds on nominal trajectories re-computed in the ancillary problem
# This enforces constraint also on self.z_star0 decision variable (first element of sampled_z_star_list)
if self.formulation == 'ancillaryonline':
bnd_z_ub_j = sampled_z_star_list[j] <= self.bnds_z[:,1]
bnd_z_lb_j = sampled_z_star_list[j] >= self.bnds_z[:,0]
else:
bnd_x_ub_j = self.Xk[:,j] <= self.bnds_x[:,1]
bnd_x_lb_j = self.Xk[:,j] >= self.bnds_x[:,0]
if self.formulation == 'ancillaryonline':
bnd_z_ub_j = sampled_z_star_list[j] <= self.bnds_z[:,1]
bnd_z_lb_j = sampled_z_star_list[j] >= self.bnds_z[:,0]
bnd_x_ub = ca.vertcat(bnd_x_ub,bnd_x_ub_j)
bnd_x_lb = ca.vertcat(bnd_x_lb,bnd_x_lb_j)
self.opti.subject_to(bnd_x_lb_j)
self.opti.subject_to(bnd_x_ub_j)
if self.formulation == 'ancillaryonline':
bnd_z_ub = ca.vertcat(bnd_z_ub,bnd_z_ub_j)
bnd_z_lb = ca.vertcat(bnd_z_lb,bnd_z_lb_j)
self.opti.subject_to(bnd_z_lb_j)
self.opti.subject_to(bnd_z_ub_j)
if self.formulation == 'ancillaryonline':
self.constraints_dict.update({'bnd_z_ub': bnd_z_ub, 'bnd_z_lb': bnd_z_lb})
# # Already included in the loop above on sampled_z_star_list
# if self.formulation == 'ancillaryonline':
# bnd_z = self.opti.bounded(self.bnds_z[:,0], self.z_star0, self.bnds_z[:,1])
# self.opti.subject_to(bnd_z)
# MOVEBLOCKING (constant control action after Nu control steps i.e. for all steps k > Nu)
moveblocking = self.Uk[self.nintervals_moveblocking-1:] - self.Uk[self.nintervals_moveblocking-1]
moveblock = moveblocking == 0
self.opti.subject_to(moveblock)
# SYSTEM DYNAMICS
# Calculate the starting index for sampling (corresponding to start_timestamp + {control_interval}h)
start_index = (self.control_interval - 1) * 1 # {control_interval}h - 1h offset (missing initial value in self.x_list)
# Sample every {control_interval}hours ({control_interval} * mesh_finesse steps)
sampling_step = self.control_interval * 1 # Note: 1 constraint for each control interval
# Sample self.x_list starting from start_index
sampled_x_list = self.x_list[start_index::sampling_step]
gaps = ca.horzcat(*sampled_x_list[:-1]) - self.Xk[:,1:]
close_gaps = gaps == 0
self.opti.subject_to(close_gaps)
self.constraints_dict.update({'bnd_u': bnd_u, 'bnd_du': bnd_du, 'bnd_x_ub': bnd_x_ub,
'bnd_x_lb': bnd_x_lb, 'moveblock': moveblock, 'close_gaps': close_gaps})
# SLACK POSITIVENESS BOUNDS
if self.slackness:
bnd_eps1 = self.eps1 >= 0
self.opti.subject_to(bnd_eps1)
bnd_eps2 = self.eps2 >= 0
self.opti.subject_to(bnd_eps2)
self.constraints_dict.update({'bnd_eps1': bnd_eps1, 'bnd_eps2': bnd_eps2})
# STATE INITIAL CONDITION (not used anymore, see 'stet_init_state' method)
# close_init_cond_gap = None
# if self.init_state is not None:
# initial_state_cond = self.Xk[:,0] - self.init_state #Set equal to last point of simulator at last MPC step...NOT ANYMORE...SET EQUAL TO EKF!
# close_init_cond_gap = initial_state_cond == 0
# self.opti.subject_to(close_init_cond_gap)
# self.constraints_dict.update({'initial_state_cond': close_init_cond_gap})
# else:
# self.logger.warning('NMPC initial state condition not set, no initial condition constraint is set.')
############################################################################################################
def additional_ext_contraint(self, constraint_name: str = 'additional', constraint: ca.MX = None):
'''
Function to add additional external constraints to the Opti instance (user-defined externally).
Note: internally defined constraints are added in the 'add_constraints' method.
Note: constraint must be a symbolic CasADi expression.
Note: user can use this function to add any kind of custom constraint to the NMPC problem.
'''
self.opti.subject_to(constraint)
self.constraints_dict.update({constraint_name: constraint})
if constraint_name == 'initial_state_cond':
self.logger.warning('Initial state condition constraint should not added here, but calling "set_init_state"!')
# if constraint_name == 'close_gaps':
# self.logger.info('Dynamics constraint added.')
############################################################################################################
def setup_solver(self, maxiter: int, print_level: int = 0, file_print_level: int = 5):
'''
Function to set up the solver for the Opti instance.
Args:
maxiter (int): Maximum number of iterations for the solver.
print_level (int, optional): Print level for the solver. Defaults to 0 (no output).
file_print_level (int, optional): File print level for the solver output file. Defaults to 5.
Note: IPOPT solver is used by default.
Note: alternatively user can provide a dictionary of options
'''
# CasADi plugin options
p_opts = {"expand":False}
# Solver options
s_opts = {"max_iter": maxiter,
'print_level': print_level, # Disable solver output,
'output_file': f'{self.modelname}_IPOPT_output.txt',
'file_print_level':file_print_level,
#"hessian_approximation": "limited-memory" # 'limited-memory' or 'exact'
#'file_append': 'yes',
}
self.opti.solver("ipopt",
p_opts,s_opts)
############################################################################################################
def set_ext_cost_function(self,u_values_discrete: np.ndarray = None, param_u_values_discrete: np.ndarray = None, delta_t: np.ndarray = None,
start_timestamp: Optional[pd.Timestamp] = None, end_timestamp: Optional[pd.Timestamp] = None,
log: bool = False) -> Tuple[ca.MX, Dict]:
'''
Function to set the external 'cost_function' for the Opti instance (user-defined externally).
Args:
u_values_discrete (np.ndarray): discrete flow-rate input values for the prediction model over the simulation horizon
param_u_values_discrete (np.ndarray): discrete parameter characterization input values for the prediction model over the simulation horizon
delta_t (np.ndarray): discrete time step sizes for the prediction model over the simulation horizon. Must be in days!!
start_timestamp: pd.Timestamp of the start of the prediction horizon
end_timestamp: pd.Timestamp of the end of the prediction horizon
log (bool): whether to log information about the cost function setup. Defaults to False.
Returns:
cost (ca.MX): symbolic expression of the cost function to be minimized
costs_dict (Dict): dictionary containing the different cost terms
Note: user can provide specific inputs to override the default ones set in the 'prepare_inputs' method.
Note: the results of the 'self.cost_function' call are set as attributes of the NMPC object.
The symbolic expression of the cost function is returned to be set in the 'opti.minimize' method externally.
'''
self.logger.info('## Setting the external cost function...##')
# Check inputs
if u_values_discrete is None or param_u_values_discrete is None or delta_t is None:
self.logger.warning('Inputs not given, using the default values.')
if self.u_values is None or self.param_u_values is None or self.delta_t is None:
self.logger.error('Default inputs not set. Use the "prepare_inputs" function to set them.')
raise ValueError('Default inputs not set. Use the "prepare_inputs" function to set them.')
u_values_discrete = self.u_values if u_values_discrete is None else u_values_discrete
param_u_values_discrete = self.param_u_values if param_u_values_discrete is None else param_u_values_discrete
delta_t = self.delta_t if delta_t is None else delta_t
#----------------------------------------------------------------------------------------------------------- #
# Check timestamps consistency
start = start_timestamp if start_timestamp is not None else self.integrator_parameters['start_timestamp']
end = end_timestamp if end_timestamp is not None else self.integrator_parameters['end_timestamp']
time_points_computed = None
timestamp_points_computed = None
if start_timestamp is not None and delta_t is not None:
time_points_computed = np.concatenate(([0], np.cumsum(86400*delta_t)))
timestamp_points_computed = (start_timestamp + pd.to_timedelta(time_points_computed-time_points_computed[0], unit='S')).round('S')
time_points_discrete = self.time_points if time_points_computed is None else time_points_computed
timestamp_points_discrete = self.timestamp_points if timestamp_points_computed is None else timestamp_points_computed
if end != timestamp_points_discrete[-1]:
self.logger.warning(f'Timestamps are different: {end} and {timestamp_points_discrete[-1]}.')
if start + pd.Timedelta(hours=self.control_interval*self.nintervals) != end:
self.logger.warning(f'{start + pd.Timedelta(hours=self.control_interval*self.nintervals)} is different from {end}.')
self.logger.info(f'Horizon as given from attribute N is {self.N} days; and {(end-start).total_seconds()/86400} computed from timestamps. Coherent?')
if log:
self.logger.info(f'Integrating from {self.integrator_parameters["start_timestamp"]} to {self.integrator_parameters["end_timestamp"]}...consistent?')
steps = timestamp_points_discrete[(timestamp_points_discrete >= start) & (timestamp_points_discrete<= start + pd.Timedelta(hours=self.control_interval*self.nintervals))][:-1].shape[0]
self.logger.info(f'Integrating from {time_points_discrete[0]} to {time_points_discrete[-1]} with {steps} steps.')
self.logger.info(f'Integrating from {timestamp_points_discrete[0]} to {timestamp_points_discrete[-1]} i.e. {(timestamp_points_discrete[-1]-timestamp_points_discrete[0]).total_seconds()/86400} days with {steps} steps.')
if log:
self.logger.info(f'Integration step for consistent timestamp and time_points is = {len(u_values_discrete)/(self.nintervals*self.control_interval)}. Ok?')
self.logger.info(f'Integration step for consistent timestamp and time_points is = {len(u_values_discrete)/((timestamp_points_discrete[-1] - timestamp_points_discrete[0]).total_seconds()/86400*24)}. Ok?')
#----------------------------------------------------------------------------------------------------------- #
# Check model CasADi functions (systems and output's evaluation) are set in the model object (needed for the cost function evaluation)
if self.model.one_step_ahead_ca is None:
self.model.set_casadi_functions()
if log:
self.logger.info(f'The model gas output are in {self.model.other_parameters["udm"]}; check setpoints are in the same units.')
#----------------------------------------------------------------------------------------------------------- #
# Set the cost function and call it
add_kargs = {}
if self.formulation != 'nominal':
add_kargs = {'formulation':self.formulation,
'v_star':self.v_star,
'weights_xstar':self.weights_xstar,
'weights_ustar':self.weights_ustar,
'weights_yN':self.weights_yN}
if self.formulation == 'ancillaryonline':
add_kargs.update({'z_star0':self.z_star0})
elif self.formulation == 'ancillaryoffline':
add_kargs.update({'z_star':self.z_star})
else:
add_kargs = {'slackness': self.slackness}
if self.slackness:
add_kargs.update({'eps1': self.eps1,
'eps2': self.eps2,
'weights_eps1': self.weights_eps1,
'weights_eps2': self.weights_eps2})
cost, costs_dict, result_timestamps, x_list, y_list, z_star_list, y_star_list = self.cost_function(self.Uk, self.Xk, self.dUk,
self.weights_y, self.weights_du,
self.model.one_step_ahead_ca, self.model.output_ca,
self.state_scales, self.output_scales, self.input_scale,
self.ref_df, self.model.process_parameters,
self.nintervals, start, end, self.control_interval,
u_values_discrete, param_u_values_discrete, delta_t, timestamp_points_discrete,
self.integrator_parameters['mesh_finesse'], **add_kargs)
# Extract symbolic results to be set as attributes
self.costs_dict = costs_dict
self.x_list = x_list
self.y_list = y_list
self.z_star_list = z_star_list
self.y_star_list = y_star_list
# Set the cost function to be minimized in the opti instance
self.opti.minimize(cost)
return cost, result_timestamps
############################################################################################################
def set_cost_function(self,
u_values_discrete: np.ndarray = None, param_u_values_discrete: np.ndarray = None, delta_t: np.ndarray = None,
start_timestamp: Optional[pd.Timestamp] = None, end_timestamp: Optional[pd.Timestamp] = None,
log: bool = False
) -> Tuple[ca.MX, List[pd.Timestamp]]:
'''
DEPRECATED, use the 'set_ext_cost_function' method instead with the 'cost_function_nominal' function
to solve the nominal problem.
Function to set the cost function for the Opti instance (internally defined inside the method itself; for the 'nominal' problem only).
Args:
u_values_discrete (np.ndarray): discrete flow-rate input values for the prediction model over the simulation horizon
param_u_values_discrete (np.ndarray): discrete parameter characterization input values for the prediction model over the simulation horizon
delta_t (np.ndarray): discrete time step sizes for the prediction model over the simulation horizon. Must be in days!!
start_timestamp: pd.Timestamp of the start of the prediction horizon
end_timestamp: pd.Timestamp of the end of the prediction horizon
log (bool): whether to log information about the cost function setup. Defaults to False.
Returns:
cost (ca.MX): symbolic expression of the cost function to be minimized
timestamps (List[pd.Timestamp]): list of timestamps corresponding to each time step in the prediction horizon
'''
start = start_timestamp if start_timestamp is not None else self.integrator_parameters['start_timestamp']
end = end_timestamp if end_timestamp is not None else self.integrator_parameters['end_timestamp']
if u_values_discrete is None or param_u_values_discrete is None or delta_t is None:
self.logger.warning('Inputs not given, using the default values.')
if self.u_values is None or self.param_u_values is None or self.delta_t is None:
self.logger.error('Default inputs not set. Use the "prepare_inputs" function to set them.')
raise ValueError('Default inputs not set. Use the "prepare_inputs" function to set them.')
u_values_discrete = self.u_values if u_values_discrete is None else u_values_discrete
param_u_values_discrete = self.param_u_values if param_u_values_discrete is None else param_u_values_discrete
delta_t = self.delta_t if delta_t is None else delta_t
# Check timestamps consistency ----
time_points_computed = None
timestamp_points_computed = None
if start_timestamp is not None and delta_t is not None:
time_points_computed = np.concatenate(([0], np.cumsum(86400*delta_t)))
timestamp_points_computed = (start_timestamp + pd.to_timedelta(time_points_computed-time_points_computed[0], unit='S')).round('S')
time_points_discrete = self.time_points if time_points_computed is None else time_points_computed
timestamp_points_discrete = self.timestamp_points if timestamp_points_computed is None else timestamp_points_computed
if end != timestamp_points_discrete[-1]:
self.logger.warning(f'Timestamps are different: {end} and {timestamp_points_discrete[-1]}.')
if start + pd.Timedelta(hours=self.control_interval*self.nintervals) != end:
self.logger.warning(f'{start + pd.Timedelta(hours=self.control_interval*self.nintervals)} is different from {end}.')
self.logger.info(f'Horizon as given from attribute N is {self.N} days; and {(end-start).total_seconds()/86400} computed from timestamps. Coherent?')
if log:
self.logger.info(f'Integrating from {self.integrator_parameters["start_timestamp"]} to {self.integrator_parameters["end_timestamp"]}...consistent?')
steps = timestamp_points_discrete[(timestamp_points_discrete >= start) & (timestamp_points_discrete<= start + pd.Timedelta(hours=self.control_interval*self.nintervals))][:-1].shape[0]
self.logger.info(f'Integrating from {time_points_discrete[0]} to {time_points_discrete[-1]} with {steps} steps.')
self.logger.info(f'Integrating from {timestamp_points_discrete[0]} to {timestamp_points_discrete[-1]} i.e. {(timestamp_points_discrete[-1]-timestamp_points_discrete[0]).total_seconds()/86400} days with {steps} steps.')
if log:
self.logger.info(f'Integration step for consistent timestamp and time_points is = {len(u_values_discrete)/(self.nintervals*self.control_interval)}. Ok?')
self.logger.info(f'Integration step for consistent timestamp and time_points is = {len(u_values_discrete)/((timestamp_points_discrete[-1] - timestamp_points_discrete[0]).days*24)}. Ok?')
# --------------------------------
# Check model CasADi functions are set in the model object
if self.model.one_step_ahead_ca is None:
self.model.set_casadi_functions()
if log:
self.logger.info(f'The model gas output are in {self.model.other_parameters["udm"]}; check setpoints are in the same units.')
# Set the cost function, initialize it
tracking_cost = 0
self.x_list = []
self.y_list = [self.model.output_ca(self.Xk[:,0], ca.DM([value[0] for value in self.model.process_parameters.values()]))]
current_timestamp = start
result_timestamps = [current_timestamp]
time_index_at_k = 0
for k in range(self.nintervals):
nsteps_in_interval = timestamp_points_discrete[(timestamp_points_discrete >= current_timestamp) & (timestamp_points_discrete<= current_timestamp + pd.Timedelta(hours=self.control_interval))][:-1].shape[0]
if nsteps_in_interval != self.integrator_parameters['mesh_finesse']*self.control_interval: #TO B
self.logger.warning(f'Steps in interval are {nsteps_in_interval}, but they must be {self.integrator_parameters["mesh_finesse"]*self.control_interval}.')
#--------------------
uk = self.Uk[k]
# Integrate over the interval
x = self.Xk[:,k]
#time_index_at_k = k*nsteps_in_interval
time_index_at_kplus1 = time_index_at_k + nsteps_in_interval
for i in range(time_index_at_k, time_index_at_kplus1, 1):
x = self.model.one_step_ahead_ca(x,
ca.vertcat(ca.DM(list(u_values_discrete[i,:-1])),uk),
param_u_values_discrete[i].T,
ca.DM(delta_t[i]),
ca.DM([value[0] for value in self.model.process_parameters.values()]) #When in prediction mode, the parameters are fixed.
)
y = self.model.output_ca(x,
ca.DM([value[0] for value in self.model.process_parameters.values()]))
self.x_list.append(x) #Added for Multiple Shooting
self.y_list.append(y)
# ------------------
#Update
current_timestamp_otherway = current_timestamp + pd.Timedelta(hours=self.control_interval)
current_timestamp = timestamp_points_discrete[time_index_at_kplus1]
if current_timestamp_otherway != current_timestamp:
self.logger.warning(f'Current timestamp adding control interval is {current_timestamp_otherway}, but adding time_indexes is {current_timestamp}')
result_timestamps.append(current_timestamp)
time_index_at_k += nsteps_in_interval
# ------------------
if self.ref_df[self.ref_df['Timestamp'] == current_timestamp].empty:
self.logger.warning(f'Reference data at {current_timestamp} is missing.')
track_error = ca.vertcat(
self.weights_y[0]*((y[0]*y[1] - self.ref_df[(self.ref_df['Timestamp']==current_timestamp)].values[0,1])/self.output_scales[0])**2,
self.weights_y[1]*((y[2]/y[1] - self.ref_df[(self.ref_df['Timestamp']==current_timestamp)].values[0,2])/self.output_scales[1])**2)
if self.weights_y.shape[0] > 2: #ADD VERTICALLY IF S2,REF IS PRESENT IN Y_REF
track_error = ca.vertcat(track_error, self.weights_y[2]*((x[6] - self.ref_df[(self.ref_df['Timestamp']==current_timestamp)].values[0,3])/self.output_scales[2])**2)
# tracking_cost += track_error.T@track_error # Quadratic cost on control #ADD quadratic cost on FINAL Y ERROR WITH ITS WEIGHT
tracking_cost += ca.sum1(track_error) # Quadratic cost on control #ADD quadratic cost on FINAL Y ERROR WITH ITS WEIGHT
# ------------------
self.logger.info(f'MPC predicts from {result_timestamps[0]} to {current_timestamp}')
# Final check of timestamps
if current_timestamp != end:
self.logger.warning(f'{current_timestamp} is different from {end}.')
# Final objective (sum of costs)
dUk_scaled = self.dUk*ca.repmat(self.input_scale,1,self.nintervals-1)
controlrate_cost = dUk_scaled@dUk_scaled.T
cost = self.weights_du*controlrate_cost + tracking_cost #ADD control rate penalty term
self.costs_dict = {'tracking_cost': tracking_cost, 'controlrate_cost': controlrate_cost}
if self.slackness:
slack_ub_cost = ca.sum1((self.eps2/self.state_scales)**2)
slack_lb_cost = ca.sum1((self.eps1/self.state_scales)**2)
cost += self.weights_eps1*slack_lb_cost + self.weights_eps2*slack_ub_cost
self.costs_dict.update({'slack_ub_cost': slack_ub_cost, 'slack_lb_cost': slack_lb_cost})
self.opti.minimize(cost)
return cost, result_timestamps
############################################################################################################
#--------------------------------------------------------------------------------------------
# These functions are used to attribute values to the parameters/decision variables of the Opti instance.
def set_init_decvar(self, initial_guesses: Optional[Dict[str, np.ndarray]]):
"""Initialize the Opti instance with the initial state and control.
Function to set the initial guesses for the decision variables of the Opti instance,
from the 'initial_guesses' dictionary.
Args:
initial_guesses (Dict[str, np.ndarray]): dictionary containing the initial guesses for the decision variables.
Keys: 'Uk', 'Xk', 'eps1', 'eps2', 'z_star0' (if applicable).
Note: if key not given as input in the dictionary, set to default values.
"""
self.logger.info('## Setting initial decision variables...##')
# CONTROLLABLE INPUTS
if 'Uk' not in initial_guesses:
self.logger.warning('No initial guess for Uk is provided. Set to zero.')
self.opti.set_initial(self.Uk, ca.DM(ca.repmat(0,1,self.nintervals)))
else:
self.opti.set_initial(self.Uk, ca.DM(initial_guesses.get("Uk")))
# STATES
if 'Xk' not in initial_guesses:
self.logger.warning(f'No initial guess for Xk is provided. Set to internal model initial condition = {self.model.init_state}.')
self.opti.set_initial(self.Xk, ca.DM(ca.repmat(self.model.init_state,1,self.nintervals)))
else:
self.opti.set_initial(self.Xk, ca.DM(initial_guesses.get("Xk")))
# SLACKS (if present)
if self.slackness:
if 'eps1' not in initial_guesses:
self.logger.warning('No initial guess for eps1 is provided. Set to 0')
self.opti.set_initial(self.eps1, ca.DM(ca.repmat(0, self.model.init_state.shape[0], 1)))
else:
self.opti.set_initial(self.eps1, ca.DM(initial_guesses.get("eps1")))
if 'eps2' not in initial_guesses:
self.logger.warning('No initial guess for eps2 is provided. Set to 0')
self.opti.set_initial(self.eps2, ca.DM(ca.repmat(0, self.model.init_state.shape[0], 1)))
else:
self.opti.set_initial(self.eps2, ca.DM(initial_guesses.get("eps2")))
# INITIAL VALUES OF THE OPTIMAL NOMINAL STATES UPDATED IN THE ANCILLARY PROBLEM (ONLINE FORMULATION)
if self.formulation == 'ancillaryonline':
if 'z_star0' not in initial_guesses:
self.logger.warning('No initial guess for z_star0 is provided. Set to 0')
self.opti.set_initial(self.z_star0, ca.DM(ca.repmat(0, self.model.init_state.shape[0], 1)))
else:
self.opti.set_initial(self.z_star0, ca.DM(initial_guesses.get("z_star0"))) # Note: In the old codes, it was z_star[:,0] i.e. the nominal solution of the current k^{th} step
############################################################################################################
def set_init_state(self, x0: np.ndarray = None):
"""Set the initial state of the NMPC problem.
Function to set the initial state of the NMPC problem, thus setting the initial condition constraint in the Opti instance,
given the multiple shooting formulation.
Args:
x0 (np.ndarray): initial state of the NMPC problem. If None, set to the model's default initial state.
Note: if x0 is None, set to the model's default initial state.
"""
if x0 is None:
self.init_state = ca.DM(self.model.init_state)
self.logger.info(f'Initial state of the NMPC set to the default model init_state: {x0}.')
else:
self.init_state = ca.DM(x0)
self.logger.info(f'Initial state of the NMPC set to {x0}.')
if not np.array_equal(x0, self.model.init_state):
self.logger.warning(f'Initial state NMPC is different from the model initial state. Check if it is correct.')
compare_arrays(x0, self.model.init_state)
state_keys = self.model.get_state_dict().keys()
self.status.update(dict(zip(state_keys, list(self.init_state.full()[:,0]))))
# Set up initial state constraint (on the Xk decision variable)
self.logger.info('Setting initial state constraint.')
initial_state_cond = self.Xk[:,0] - self.init_state #Set equal to last point of simulator at last MPC step...NOT ANYMORE...SET EQUAL TO EKF!
close_init_cond_gap = initial_state_cond == 0
self.opti.subject_to(close_init_cond_gap)
self.constraints_dict.update({'initial_state_cond': close_init_cond_gap})
############################################################################################################
def set_parameters(self, opti_param_values: Dict[str, np.ndarray]):
"""Set the parameters of the Opti instance, from the 'opti_param_values' dictionary.
Args:
opti_param_values (Dict[str, np.ndarray]): dictionary containing the values for the parameters
Keys: 'weights_y', 'weights_du', 'bnds_u', 'bnds_du', 'bnds_x', 'weights_eps1', 'weights_eps2',
'weights_xstar', 'weights_ustar', 'weights_yN', 'bnds_z' (if applicable).
Note: if key not given as input in the dictionary, set to default values.
Note:
always:
weights_y, weights_du, bnds_u, bnds_du, bnds_x,;
if slackness:
weights_eps1, weights_eps2;
if problem == ancillary:
weights_xstar, weights_ustar, weights_yN;
if problem == ancillary and formulation == ancillaryonline: