├── .gitignore ├── LICENSE ├── README.md ├── control_arduino.py ├── control_demo.py ├── demo.ipynb ├── img ├── APP.PNG └── CONFIG.PNG ├── pyproject.toml ├── requirements.txt └── runtime.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .venv* 2 | .ipynb* 3 | __pycache__ 4 | poetry.lock 5 | .pyc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Everton Colling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://opensource.org/licenses/MIT) 2 | [](https://mybinder.org/v2/gh/evertoncolling/tclab_jupyter/master) 3 | 4 | # tclab_jupyter 5 | A jupyter based application to explore different control techniques of a simple temperature plant 6 | 7 | **Overview** 8 | 9 | This code was written to showcase an Arduino based Temperature Control Lab (https://apmonitor.com/pdc/index.php/Main/ArduinoTemperatureControl) for a Lecture on Advanced Control Techniques. 10 | 11 | The TCLab system is built with two temperature sensors and two heaters. A Matlab or Python interface is provided to read the temperature data from the board and control the heaters power output. Two classes were created, one that aims to control the Arduino System (**control_arduino.py**) and another one replacing the Arduino interface with a simulator (**control_demo.py**), so the app can be used without the real hardware, as a demonstration. 12 | 13 | This project implements four different control techniques (Manual, On-Off, PID and MPC) so the user can test and visualize the differences between them. 14 | 15 | There is also a configurations window that presents some parameters that can be adjusted for the whole simulation or for each control technique. 16 | 17 | The interface was build using ipywidgets and bqplot. The dynamic plant simulation is done using scipy `odeint` function, whilst the MPC is implemented using the gekko library. For more information regarding the MPC options refer to the gekko documentation (https://gekko.readthedocs.io/en/latest/). 18 | 19 | **Dependencies** 20 | - numpy 21 | - scipy 22 | - ipywidgets (https://github.com/jupyter-widgets/ipywidgets) 23 | - bqplot==0.11.9 (https://github.com/bloomberg/bqplot) 24 | - gekko (https://github.com/BYU-PRISM/GEKKO) 25 | - tclab (only for the control_arduino.py) 26 | 27 | **Usage** 28 | 29 | Just download the `control_demo.py` (or `control_arduino.py` if you are using it with the TCLab) to your system and create a Jupyter Notebook file (.ipynb) on the same folder. 30 | 31 | Import the module and create an object as shown below. 32 | ```python 33 | import control_demo as cn # change to import control_arduino to use with TCLab 34 | demo = cn.GUI() 35 | ``` 36 | 37 | To open the main window, call the app function. 38 | ```python 39 | demo.app() 40 | ``` 41 | 42 |
43 |
44 |
52 |
53 |
Δt (s):
', 328 | layout=lay), 329 | wi.FloatSlider(value=4.0, min=1.0, max=10.0, step=0.5, 330 | description='', style=style))) 331 | 332 | self._conf12 = wi.HBox(( 333 | wi.HTML(value='' 334 | 'Sleep time (s):
', 335 | layout=lay), 336 | wi.FloatSlider(value=0.5, min=0.25, max=1.5, step=0.05, 337 | description='', style=style))) 338 | 339 | but11 = wi.Button(description='Apply', icon='check', 340 | layout=wi.Layout(width='100px', height='32px')) 341 | but11.on_click(self._conf_general) 342 | 343 | but12 = wi.Button(description='Reset', icon='refresh', 344 | layout=wi.Layout(width='100px', height='32px')) 345 | but12.on_click(self._reset_general) 346 | 347 | conf13 = wi.HBox((but11, but12), layout=wi.Layout(margin='10px 0 0 0')) 348 | 349 | ####################################################################### 350 | # ON-OFF OPTIONS 351 | ####################################################################### 352 | lay5 = wi.Layout(width='90px', margin='0 10px 0 15px') 353 | 354 | conf20 = wi.Button(description='TEMPERATURE 01 / HEATER 01', 355 | disabled=True, 356 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 357 | 358 | self._conf21 = wi.HBox(( 359 | wi.HTML(value='' 360 | 'Deadband (K):
', 361 | layout=lay5), 362 | wi.FloatSlider(value=0.1, min=0.0, max=2.0, step=0.1, 363 | description='', style=style, 364 | layout=wi.Layout(width='200px'))), 365 | layout=wi.Layout(margin='5px 0 0 0') 366 | ) 367 | 368 | box21 = wi.VBox((conf20, self._conf21), 369 | layout=wi.Layout(border='solid 2px gray', 370 | width='325px', 371 | margin='10px 0 0 0px')) 372 | 373 | conf22 = wi.Button(description='TEMPERATURE 02 / HEATER 02', 374 | disabled=True, 375 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 376 | 377 | self._conf23 = wi.HBox(( 378 | wi.HTML(value='' 379 | 'Deadband (K):
', 380 | layout=lay5), 381 | wi.FloatSlider(value=0.1, min=0.0, max=2.0, step=0.1, 382 | description='', style=style, 383 | layout=wi.Layout(width='200px'))), 384 | layout=wi.Layout(margin='5px 0 0 0') 385 | ) 386 | 387 | box22 = wi.VBox((conf22, self._conf23), 388 | layout=wi.Layout(border='solid 2px gray', 389 | width='325px', 390 | margin='10px 0 0 10px')) 391 | 392 | but21 = wi.Button(description='Apply', icon='check', 393 | layout=wi.Layout(width='100px', height='32px')) 394 | but21.on_click(self._conf_on_off) 395 | 396 | but22 = wi.Button(description='Reset', icon='refresh', 397 | layout=wi.Layout(width='100px', height='32px')) 398 | but22.on_click(self._reset_on_off) 399 | 400 | conf24 = wi.HBox((but21, but22), layout=wi.Layout(margin='10px 0 0 0')) 401 | 402 | ####################################################################### 403 | # PID OPTIONS 404 | ####################################################################### 405 | lay4 = wi.Layout(width='100px', margin='0 10px 0 15px') 406 | 407 | conf30 = wi.Button(description='TEMPERATURE 01 / HEATER 01', 408 | disabled=True, 409 | layout=wi.Layout(width='330px', margin='0 0 0 0')) 410 | 411 | self._conf31 = wi.HBox(( 412 | wi.HTML(value='' 413 | 'Kc (K/%Heater):
', 414 | layout=lay4), 415 | wi.FloatSlider(value=10.0, min=0.0, max=20.0, step=0.5, 416 | description='', style=style, 417 | layout=wi.Layout(width='200px'))), 418 | layout=wi.Layout(margin='5px 0 0 0') 419 | ) 420 | 421 | self._conf32 = wi.HBox(( 422 | wi.HTML(value='tauI (s):
', 423 | layout=lay4), 424 | wi.FloatSlider(value=50.0, min=0.0, max=200.0, step=1.0, 425 | description='', style=style, 426 | layout=wi.Layout(width='200px')))) 427 | 428 | self._conf33 = wi.HBox(( 429 | wi.HTML(value='tauD (s):
', 430 | layout=lay4), 431 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.5, 432 | description='', style=style, 433 | layout=wi.Layout(width='200px')))) 434 | 435 | box31 = wi.VBox((conf30, self._conf31, self._conf32, self._conf33), 436 | layout=wi.Layout(border='solid 2px gray', 437 | width='335px', 438 | margin='10px 0 0 0px')) 439 | 440 | conf34 = wi.Button(description='TEMPERATURE 02 / HEATER 02', 441 | disabled=True, 442 | layout=wi.Layout(width='330px', margin='0 0 0 0')) 443 | 444 | self._conf35 = wi.HBox(( 445 | wi.HTML(value='' 446 | 'Kc (K/%Heater):
', 447 | layout=lay4), 448 | wi.FloatSlider(value=10.0, min=0.0, max=20.0, step=0.5, 449 | description='', style=style, 450 | layout=wi.Layout(width='200px'))), 451 | layout=wi.Layout(margin='5px 0 0 0') 452 | ) 453 | 454 | self._conf36 = wi.HBox(( 455 | wi.HTML(value='tauI (s):
', 456 | layout=lay4), 457 | wi.FloatSlider(value=50.0, min=0.0, max=200.0, step=1.0, 458 | description='', style=style, 459 | layout=wi.Layout(width='200px')))) 460 | 461 | self._conf37 = wi.HBox(( 462 | wi.HTML(value='tauD (s):
', 463 | layout=lay4), 464 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.5, 465 | description='', style=style, 466 | layout=wi.Layout(width='200px')))) 467 | 468 | box32 = wi.VBox((conf34, self._conf35, self._conf36, self._conf37), 469 | layout=wi.Layout(border='solid 2px gray', 470 | width='335px', 471 | margin='10px 0 0 10px')) 472 | 473 | but31 = wi.Button(description='Apply', icon='check', 474 | layout=wi.Layout(width='100px', height='32px')) 475 | but31.on_click(self._conf_pid) 476 | 477 | but32 = wi.Button(description='Reset', icon='refresh', 478 | layout=wi.Layout(width='100px', height='32px')) 479 | but32.on_click(self._reset_pid) 480 | 481 | conf38 = wi.HBox((but31, but32), layout=wi.Layout(margin='10px 0 0 0')) 482 | 483 | ####################################################################### 484 | # MPC OPTIONS 485 | ####################################################################### 486 | lay1 = wi.Layout(width='60px', margin='0 10px 0 25px') 487 | lay2 = wi.Layout(width='95px', margin='0 20px 0 10px') 488 | lay3 = wi.Layout(width='55px', margin='0 20px 0 10px') 489 | 490 | self._conf40 = wi.HBox(( 491 | wi.HTML(value='SOLVER:
', 492 | layout=lay1), 493 | wi.Dropdown(options=['1 - APOPT', '2 - BPOPT', '3 - IPOPT'], 494 | value='1 - APOPT', 495 | layout=wi.Layout(width='100px')), 496 | wi.HTML(value='CVTYPE:
', 497 | layout=lay1), 498 | wi.Dropdown(options=['1 - Deadband', '2 - Trajectory'], 499 | value='1 - Deadband', 500 | layout=wi.Layout(width='130px'))), 501 | layout=wi.Layout(margin='5px 0 0 0') 502 | ) 503 | 504 | conf41 = wi.Button(description='TEMPERATURE 01', 505 | disabled=True, 506 | layout=wi.Layout(width='360px', margin='0 0 0 0')) 507 | 508 | self._conf42 = wi.HBox(( 509 | wi.HTML(value='' 510 | 'Deadband (K):
', 511 | layout=lay2), 512 | wi.FloatSlider(value=0.1, min=0.1, max=1.0, step=0.1, 513 | description='', style=style, 514 | layout=wi.Layout(width='250px'))), 515 | layout=wi.Layout(margin='5px 0 0 0') 516 | ) 517 | 518 | self._conf43 = wi.HBox(( 519 | wi.HTML(value='TAU:
', 520 | layout=lay2), 521 | wi.FloatSlider(value=10.0, min=1.0, max=100.0, step=1.0, 522 | description='', style=style, 523 | layout=wi.Layout(width='250px')))) 524 | 525 | box41 = wi.VBox((conf41, self._conf42, self._conf43), 526 | layout=wi.Layout(border='solid 2px gray', 527 | width='365px', 528 | margin='10px 0 0 0')) 529 | 530 | conf44 = wi.Button(description='TEMPERATURE 02', 531 | disabled=True, 532 | layout=wi.Layout(width='360px', margin='0 0 0 0')) 533 | 534 | self._conf45 = wi.HBox(( 535 | wi.HTML(value='' 536 | 'Deadband (K):
', 537 | layout=lay2), 538 | wi.FloatSlider(value=0.1, min=0.1, max=1.0, step=0.1, 539 | description='', style=style, 540 | layout=wi.Layout(width='250px'))), 541 | layout=wi.Layout(margin='5px 0 0 0') 542 | ) 543 | 544 | self._conf46 = wi.HBox(( 545 | wi.HTML(value='TAU:
', 546 | layout=lay2), 547 | wi.FloatSlider(value=10.0, min=1.0, max=100.0, step=1.0, 548 | description='', style=style, 549 | layout=wi.Layout(width='250px')))) 550 | 551 | box42 = wi.VBox((conf44, self._conf45, self._conf46), 552 | layout=wi.Layout(border='solid 2px gray', 553 | width='365px', 554 | margin='10px 0 0 0')) 555 | 556 | conf47 = wi.Button(description='HEATER 01', 557 | disabled=True, 558 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 559 | 560 | self._conf48 = wi.HBox(( 561 | wi.HTML(value='DMAX:
', 562 | layout=lay3), 563 | wi.FloatSlider(value=30.0, min=1.0, max=100.0, step=0.5, 564 | description='', style=style, 565 | layout=wi.Layout(width='250px'))), 566 | layout=wi.Layout(margin='5px 0 0 0') 567 | ) 568 | 569 | self._conf49 = wi.HBox(( 570 | wi.HTML(value='DCOST:
', 571 | layout=lay3), 572 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.1, 573 | description='', style=style, 574 | layout=wi.Layout(width='250px')))) 575 | 576 | box43 = wi.VBox((conf47, self._conf48, self._conf49), 577 | layout=wi.Layout(border='solid 2px gray', 578 | width='325px', 579 | margin='10px 0 0 10px')) 580 | 581 | conf410 = wi.Button(description='HEATER 02', 582 | disabled=True, 583 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 584 | 585 | self._conf411 = wi.HBox(( 586 | wi.HTML(value='DMAX:
', 587 | layout=lay3), 588 | wi.FloatSlider(value=30.0, min=1.0, max=100.0, step=0.5, 589 | description='', style=style, 590 | layout=wi.Layout(width='250px'))), 591 | layout=wi.Layout(margin='5px 0 0 0') 592 | ) 593 | 594 | self._conf412 = wi.HBox(( 595 | wi.HTML(value='DCOST:
', 596 | layout=lay3), 597 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.1, 598 | description='', style=style, 599 | layout=wi.Layout(width='250px')))) 600 | 601 | box44 = wi.VBox((conf410, self._conf411, self._conf412), 602 | layout=wi.Layout(border='solid 2px gray', 603 | width='325px', 604 | margin='10px 0 0 10px')) 605 | 606 | but41 = wi.Button(description='Apply', icon='check', 607 | layout=wi.Layout(width='100px', height='32px')) 608 | but41.on_click(self._conf_mpc) 609 | but42 = wi.Button(description='Reset', icon='refresh', 610 | layout=wi.Layout(width='100px', height='32px')) 611 | but42.on_click(self._reset_mpc) 612 | conf413 = wi.HBox((but41, but42), 613 | layout=wi.Layout(margin='10px 0 0 0')) 614 | 615 | ####################################################################### 616 | # CONFIGURATOR LAYOUT 617 | ####################################################################### 618 | tab = wi.Tab([wi.VBox((self._conf11, self._conf12, 619 | wi.Label(layout=wi.Layout(height='206px')), 620 | conf13)), 621 | wi.VBox((wi.HBox((box21, box22)), 622 | wi.Label(layout=wi.Layout(height='191px')), 623 | conf24)), 624 | wi.VBox((wi.HBox((box31, box32)), 625 | wi.Label(layout=wi.Layout(height='127px')), 626 | conf38)), 627 | wi.VBox((self._conf40, 628 | wi.HBox((box41, box43)), 629 | wi.HBox((box42, box44)), 630 | wi.Label(layout=wi.Layout(height='11px')), 631 | conf413))], 632 | layout=wi.Layout(width='800px', height='380px')) 633 | tab.set_title(0, 'General Options') 634 | tab.set_title(1, 'On-Off Options') 635 | tab.set_title(2, 'PID Options') 636 | tab.set_title(3, 'MPC Options') 637 | 638 | ####################################################################### 639 | # DISPLAY CONFIGURATOR 640 | ####################################################################### 641 | self._conf = tab 642 | 643 | def app(self): 644 | display(self._gui) 645 | 646 | def config(self): 647 | display(self._conf) 648 | 649 | def _conf_general(self, b): 650 | self._delta_t = self._conf11.children[1].value 651 | self._maxtime = int(500/self._delta_t) 652 | 653 | self._sleep = self._conf12.children[1].value 654 | 655 | def _reset_general(self, b): 656 | self._conf11.children[1].value = 4.0 657 | self._delta_t = self._conf11.children[1].value 658 | self._maxtime = int(500/self._delta_t) 659 | 660 | self._conf12.children[1].value = 0.5 661 | self._sleep = self._conf12.children[1].value 662 | 663 | def _conf_on_off(self, b): 664 | self._q1_dt_on_off = self._conf21.children[1].value 665 | 666 | self._q2_dt_on_off = self._conf23.children[1].value 667 | 668 | def _reset_on_off(self, b): 669 | self._conf21.children[1].value = 0.1 670 | self._q1_dt_on_off = self._conf21.children[1].value 671 | 672 | self._conf23.children[1].value = 0.1 673 | self._q2_dt_on_off = self._conf23.children[1].value 674 | 675 | def _conf_pid(self, b): 676 | self._pid1_gain = self._conf31.children[1].value 677 | self._pid1_reset = self._conf32.children[1].value 678 | self._pid1_rate = self._conf33.children[1].value 679 | 680 | self._pid2_gain = self._conf35.children[1].value 681 | self._pid2_reset = self._conf36.children[1].value 682 | self._pid2_rate = self._conf37.children[1].value 683 | 684 | def _reset_pid(self, b): 685 | self._conf31.children[1].value = 10.0 # gain 686 | self._conf32.children[1].value = 50.0 # reset 687 | self._conf33.children[1].value = 1.0 # rate 688 | self._pid1_gain = self._conf31.children[1].value 689 | self._pid1_reset = self._conf32.children[1].value 690 | self._pid1_rate = self._conf33.children[1].value 691 | 692 | self._conf35.children[1].value = 10.0 # gain 693 | self._conf36.children[1].value = 50.0 # reset 694 | self._conf37.children[1].value = 1.0 # rate 695 | self._pid2_gain = self._conf35.children[1].value 696 | self._pid2_reset = self._conf36.children[1].value 697 | self._pid2_rate = self._conf37.children[1].valu 698 | 699 | def _conf_mpc(self, b): 700 | self._SOLVER = self._conf40.children[1].value 701 | self._CVTYPE = self._conf40.children[3].value 702 | 703 | self._T1_dt = self._conf42.children[1].value 704 | self._T1_tau = self._conf43.children[1].value 705 | self._T2_dt = self._conf45.children[1].value 706 | self._T2_tau = self._conf46.children[1].value 707 | 708 | self._Q1_DMAX = self._conf48.children[1].value 709 | self._Q1_DCOST = self._conf49.children[1].value 710 | self._Q2_DMAX = self._conf411.children[1].value 711 | self._Q2_DCOST = self._conf412.children[1].value 712 | 713 | def _reset_mpc(self, b): 714 | self._conf40.children[1].value = '1 - APOPT' 715 | self._conf40.children[3].value = '1 - Deadband' 716 | 717 | self._conf42.children[1].value = 0.1 718 | self._conf43.children[1].value = 30. 719 | self._conf45.children[1].value = 0.1 720 | self._conf46.children[1].value = 30. 721 | 722 | self._conf48.children[1].value = 30. 723 | self._conf49.children[1].value = 1. 724 | self._conf411.children[1].value = 30. 725 | self._conf412.children[1].value = 1. 726 | 727 | self._SOLVER = self._conf40.children[1].value 728 | self._CVTYPE = self._conf40.children[3].value 729 | 730 | self._T1_dt = self._conf42.children[1].value 731 | self._T1_tau = self._conf43.children[1].value 732 | self._T2_dt = self._conf45.children[1].value 733 | self._T2_tau = self._conf46.children[1].value 734 | 735 | self._Q1_DMAX = self._conf48.children[1].value 736 | self._Q1_DCOST = self._conf49.children[1].value 737 | self._Q2_DMAX = self._conf411.children[1].value 738 | self._Q2_DCOST = self._conf412.children[1].value 739 | 740 | def _Q1_click(self, b): 741 | self._Q10 = self._wQ1.value 742 | 743 | def _Q2_click(self, b): 744 | self._Q20 = self._wQ2.value 745 | 746 | def _T1_click(self, b): 747 | self._T1_SP = self._wT1.value 748 | 749 | def _T2_click(self, b): 750 | self._T2_SP = self._wT2.value 751 | 752 | def _stop_click(self, b): 753 | self._flag = False 754 | self._mode.disabled = False 755 | 756 | def _play_click(self, b): 757 | if not self._flag: 758 | if self._mode.value == "Manual": 759 | self._flag = True 760 | self._mode.disabled = True 761 | thread = threading.Thread(target=self._work_man) 762 | thread.start() 763 | elif self._mode.value == "On-Off": 764 | self._flag = True 765 | self._mode.disabled = True 766 | thread = threading.Thread(target=self._work_on_off) 767 | thread.start() 768 | elif self._mode.value == "PID": 769 | self._flag = True 770 | self._mode.disabled = True 771 | thread = threading.Thread(target=self._work_pid) 772 | thread.start() 773 | elif self._mode.value == "MPC": 774 | self._flag = True 775 | self._mode.disabled = True 776 | thread = threading.Thread(target=self._work_mpc) 777 | thread.start() 778 | 779 | def _mode_switch(self, value): 780 | # Reinitialize parameters 781 | self._T1_SP = 30 782 | self._T2_SP = 30 783 | self._Q10 = 0 784 | self._Q20 = 0 785 | 786 | # Reset figures 787 | self._T1_meas.x = [] 788 | self._T1_meas.y = [] 789 | self._T2_meas.x = [] 790 | self._T2_meas.y = [] 791 | 792 | self._T1_set_point.x = [] 793 | self._T1_set_point.y = [] 794 | self._T2_set_point.x = [] 795 | self._T2_set_point.y = [] 796 | 797 | self._u1.x = [] 798 | self._u1.y = [] 799 | self._u2.x = [] 800 | self._u2.y = [] 801 | 802 | # Reset controls 803 | self._PT1.value = self._Tc0[0]-273.15 804 | self._PT2.value = self._Tc0[1]-273.15 805 | self._wT1.value = self._T1_SP 806 | self._wT2.value = self._T2_SP 807 | self._wQ1.value = self._Q10 808 | self._wQ2.value = self._Q20 809 | 810 | if value['new'] == "Manual": 811 | self._wQ1.disabled = False 812 | self._tQ1.disabled = False 813 | self._bQ1.disabled = False 814 | self._wT1.disabled = True 815 | self._tT1.disabled = True 816 | self._bT1.disabled = True 817 | 818 | self._wQ2.disabled = False 819 | self._tQ2.disabled = False 820 | self._bQ2.disabled = False 821 | self._wT2.disabled = True 822 | self._tT2.disabled = True 823 | self._bT2.disabled = True 824 | 825 | else: 826 | self._wQ1.disabled = True 827 | self._tQ1.disabled = True 828 | self._bQ1.disabled = True 829 | self._wT1.disabled = False 830 | self._tT1.disabled = False 831 | self._bT1.disabled = False 832 | 833 | self._wQ2.disabled = True 834 | self._tQ2.disabled = True 835 | self._bQ2.disabled = True 836 | self._wT2.disabled = False 837 | self._tT2.disabled = False 838 | self._bT2.disabled = False 839 | 840 | ########################################################################### 841 | # PID CONTROLLER 842 | ########################################################################### 843 | 844 | # inputs ----------------------------------- 845 | # sp = setpoint 846 | # pv = current temperature 847 | # pv_last = prior temperature 848 | # ierr = integral error 849 | # dt = time increment between measurements 850 | 851 | # outputs ---------------------------------- 852 | # op = output of the PID controller 853 | # I = integral contribution 854 | 855 | def _PID(self, sp, pv, pv_last, ierr, dt, Kc=10.0, tauI=50.0, tauD=1.0): 856 | # Default Parameters 857 | # Kc = 10.0 # K/%Heater 858 | # tauI = 50.0 # sec 859 | # tauD = 1.0 # sec 860 | 861 | # Parameters in terms of PID coefficients 862 | KP = Kc 863 | if tauI == 0: 864 | KI = 1e5 865 | else: 866 | KI = Kc/tauI 867 | KD = Kc*tauD 868 | 869 | # ubias for controller (initial heater) 870 | op0 = 0 871 | 872 | # upper and lower bounds on heater level 873 | ophi = 100 874 | oplo = 0 875 | 876 | # calculate the error 877 | error = sp-pv 878 | 879 | # calculate the integral error 880 | ierr = ierr + KI * error * dt 881 | 882 | # calculate the measurement derivative 883 | dpv = (pv - pv_last) / dt 884 | 885 | # calculate the PID output 886 | P = KP * error 887 | I = ierr 888 | D = -KD * dpv 889 | op = op0 + P + I + D 890 | 891 | # implement anti-reset windup 892 | if op < oplo or op > ophi: 893 | I = I - KI * error * dt 894 | # clip output 895 | op = max(oplo, min(ophi, op)) 896 | 897 | # return the controller output and PID terms 898 | return [op, I] 899 | 900 | ########################################################################### 901 | # MPC 902 | ########################################################################### 903 | def _MPC(self): 904 | 905 | m = GEKKO(remote=False) 906 | 907 | # 60 second time horizon, 4 sec cycle time, non-uniform 908 | m.time = [0, 4, 8, 12, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90] 909 | 910 | # Parameters 911 | m.U = m.FV(value=10) 912 | m.tau = m.FV(value=5) 913 | m.alpha1 = m.FV(value=0.01) # W / % heater 914 | m.alpha2 = m.FV(value=0.0075) # W / % heater 915 | 916 | # Manipulated variables 917 | m.Q1 = m.MV(value=0) 918 | m.Q1.STATUS = 1 # use to control temperature 919 | m.Q1.FSTATUS = 0 # no feedback measurement 920 | m.Q1.LOWER = 0.0 921 | m.Q1.UPPER = 100.0 922 | m.Q1.DMAX = 20.0 923 | m.Q1.COST = 0.0 924 | m.Q1.DCOST = 2.0 925 | 926 | m.Q2 = m.MV(value=0) 927 | m.Q2.STATUS = 1 # use to control temperature 928 | m.Q2.FSTATUS = 0 # no feedback measurement 929 | m.Q2.LOWER = 0.0 930 | m.Q2.UPPER = 100.0 931 | m.Q2.DMAX = 20.0 932 | m.Q2.COST = 0.0 933 | m.Q2.DCOST = 2.0 934 | 935 | # Controlled variable 936 | m.TC1 = m.CV(value=22) 937 | m.TC1.STATUS = 1 # minimize error with setpoint range 938 | m.TC1.FSTATUS = 1 # receive measurement 939 | m.TC1.TR_INIT = 1 # reference trajectory 940 | m.TC1.TAU = 10 # time constant for response 941 | 942 | # Controlled variable 943 | m.TC2 = m.CV(value=22) 944 | m.TC2.STATUS = 1 # minimize error with setpoint range 945 | m.TC2.FSTATUS = 1 # receive measurement 946 | m.TC2.TR_INIT = 1 # reference trajectory 947 | m.TC2.TAU = 10 # time constant for response 948 | 949 | # State variables 950 | m.TH1 = m.SV(value=22) 951 | m.TH2 = m.SV(value=22) 952 | 953 | m.Ta = m.Param(value=23.0+273.15) # K 954 | m.mass = m.Param(value=4.0/1000.0) # kg 955 | m.Cp = m.Param(value=0.5*1000.0) # J/kg-K 956 | m.A = m.Param(value=10.0/100.0**2) # Area not between heaters in m^2 957 | m.As = m.Param(value=2.0/100.0**2) # Area between heaters in m^2 958 | m.eps = m.Param(value=0.9) # Emissivity 959 | m.sigma = m.Const(5.67e-8) # Stefan-Boltzmann 960 | 961 | # Heater temperatures 962 | m.T1i = m.Intermediate(m.TH1+273.15) 963 | m.T2i = m.Intermediate(m.TH2+273.15) 964 | 965 | # Heat transfer between two heaters 966 | m.Q_C12 = m.Intermediate(m.U*m.As*(m.T2i-m.T1i)) # Conv 967 | m.Q_R12 = m.Intermediate(m.eps*m.sigma*m.As*(m.T2i**4-m.T1i**4)) # Rad 968 | 969 | # Semi-fundamental correlations (energy balances) 970 | m.Equation(m.TH1.dt() == (1.0/(m.mass*m.Cp)) * 971 | (m.U*m.A*(m.Ta-m.T1i) + 972 | m.eps * m.sigma * m.A * 973 | (m.Ta**4 - m.T1i**4) + m.Q_C12 + 974 | m.Q_R12 + m.alpha1*m.Q1)) 975 | 976 | m.Equation(m.TH2.dt() == (1.0/(m.mass*m.Cp)) * 977 | (m.U*m.A*(m.Ta-m.T2i) + 978 | m.eps * m.sigma * m.A * 979 | (m.Ta**4 - m.T2i**4) - m.Q_C12 - 980 | m.Q_R12 + m.alpha2*m.Q2)) 981 | 982 | # Empirical correlations (lag equations to emulate conduction) 983 | m.Equation(m.tau * m.TC1.dt() == -m.TC1 + m.TH1) 984 | m.Equation(m.tau * m.TC2.dt() == -m.TC2 + m.TH2) 985 | 986 | # Global Options 987 | m.options.IMODE = 6 # MPC 988 | m.options.CV_TYPE = 1 # Objective type 989 | m.options.NODES = 3 # Collocation nodes 990 | m.options.SOLVER = 3 # 1=APOPT, 3=IPOPT 991 | 992 | return m 993 | 994 | ########################################################################### 995 | # THREADING FUNCTION - OPEN LOOP 996 | ########################################################################### 997 | def _work_man(self): 998 | try: 999 | a = TCLab() 1000 | except: 1001 | a.close() 1002 | a = TCLab() 1003 | 1004 | # Parater to start each cycle 1005 | self._Tc0 = np.array([ 1006 | a.T1 + 273.15, 1007 | a.T2 + 273.15 1008 | ]) 1009 | 1010 | # arrays to store data 1011 | t = np.array([]) 1012 | Q1 = np.array([]) 1013 | Q2 = np.array([]) 1014 | T = np.array([[]]).reshape((0, 2)) 1015 | 1016 | t = np.append(t, np.array([0]), axis=0) 1017 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1018 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1019 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1020 | 1021 | # Main Loop 1022 | start_time = time.time() 1023 | prev_time = start_time 1024 | 1025 | while self._flag: 1026 | 1027 | # Sleep time 1028 | sleep_max = self._delta_t 1029 | sleep = sleep_max - (time.time() - prev_time) 1030 | if sleep >= 0.01: 1031 | time.sleep(sleep-0.01) 1032 | else: 1033 | time.sleep(0.01) 1034 | 1035 | # Read temperatures in Celsius 1036 | self._Tc0 = np.array([ 1037 | a.T1 + 273.15, 1038 | a.T2 + 273.15 1039 | ]) 1040 | 1041 | # Write new heater values (0-100) 1042 | a.Q1(self._Q10) 1043 | a.Q2(self._Q20) 1044 | 1045 | if len(t) >= self._maxtime: 1046 | t = np.delete(t, 0, 0) 1047 | T = np.delete(T, 0, 0) 1048 | Q1 = np.delete(Q1, 0, 0) 1049 | Q2 = np.delete(Q2, 0, 0) 1050 | 1051 | # Record time and change in time 1052 | tm = time.time() 1053 | prev_time = tm 1054 | tm = tm - start_time 1055 | 1056 | t = np.append(t, np.array([tm]), axis=0) 1057 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1058 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1059 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1060 | 1061 | self._T1_meas.x = t/60 1062 | self._T1_meas.y = T[:, 0] - 273.15 1063 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1064 | self._wT1.value = np.round(T[-1, 0]-273.15, 1) 1065 | 1066 | self._T2_meas.x = t/60 1067 | self._T2_meas.y = T[:, 1] - 273.15 1068 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1069 | self._wT2.value = np.round(T[-1, 1]-273.15, 1) 1070 | 1071 | self._u1.x = t/60 1072 | self._u1.y = Q1 1073 | 1074 | self._u2.x = t/60 1075 | self._u2.y = Q2 1076 | 1077 | time.sleep(self._sleep) 1078 | 1079 | a.Q1(0) 1080 | a.Q2(0) 1081 | a.close() 1082 | 1083 | ########################################################################### 1084 | # THREADING FUNCTION - ON-OFF 1085 | ########################################################################### 1086 | def _work_on_off(self): 1087 | try: 1088 | a = TCLab() 1089 | except: 1090 | a.close() 1091 | a = TCLab() 1092 | 1093 | # Parater to start each cycle 1094 | self._Tc0 = np.array([ 1095 | a.T1 + 273.15, 1096 | a.T2 + 273.15 1097 | ]) 1098 | 1099 | # arrays to store data 1100 | t = np.array([]) 1101 | Q1 = np.array([]) 1102 | Q2 = np.array([]) 1103 | T = np.array([[]]).reshape((0, 2)) 1104 | SP_T1 = np.array([]) 1105 | SP_T2 = np.array([]) 1106 | 1107 | t = np.append(t, np.array([0]), axis=0) 1108 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1109 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1110 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1111 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1112 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1113 | 1114 | # Main Loop 1115 | start_time = time.time() 1116 | prev_time = start_time 1117 | 1118 | while self._flag: 1119 | 1120 | # Sleep time 1121 | sleep_max = self._delta_t 1122 | sleep = sleep_max - (time.time() - prev_time) 1123 | if sleep >= 0.01: 1124 | time.sleep(sleep-0.01) 1125 | else: 1126 | time.sleep(0.01) 1127 | 1128 | # Read temperatures in Celsius 1129 | self._Tc0 = np.array([ 1130 | a.T1 + 273.15, 1131 | a.T2 + 273.15 1132 | ]) 1133 | 1134 | # apply ON/OFF controller 1135 | # heater 1 1136 | if (self._Tc0[0]-273.15) < (self._T1_SP - self._q1_dt_on_off): 1137 | self._Q10 = 100.0 1138 | elif (self._Tc0[0]-273.15) > (self._T1_SP + self._q1_dt_on_off): 1139 | self._Q10 = 0.0 1140 | # heater 2 1141 | if (self._Tc0[1]-273.15) < (self._T2_SP - self._q2_dt_on_off): 1142 | self._Q20 = 100.0 1143 | elif (self._Tc0[1]-273.15) > (self._T2_SP + self._q2_dt_on_off): 1144 | self._Q20 = 0.0 1145 | 1146 | # Write new heater values (0-100) 1147 | a.Q1(self._Q10) 1148 | a.Q2(self._Q20) 1149 | 1150 | if len(t) >= self._maxtime: 1151 | t = np.delete(t, 0, 0) 1152 | T = np.delete(T, 0, 0) 1153 | Q1 = np.delete(Q1, 0, 0) 1154 | Q2 = np.delete(Q2, 0, 0) 1155 | SP_T1 = np.delete(SP_T1, 0, 0) 1156 | SP_T2 = np.delete(SP_T2, 0, 0) 1157 | 1158 | # Record time and change in time 1159 | tm = time.time() 1160 | prev_time = tm 1161 | tm = tm - start_time 1162 | 1163 | t = np.append(t, np.array([tm]), axis=0) 1164 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1165 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1166 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1167 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1168 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1169 | 1170 | self._T1_meas.x = t/60 1171 | self._T1_meas.y = T[:, 0] - 273.15 1172 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1173 | 1174 | self._T1_set_point.x = t/60 1175 | self._T1_set_point.y = SP_T1 1176 | 1177 | self._T2_meas.x = t/60 1178 | self._T2_meas.y = T[:, 1] - 273.15 1179 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1180 | 1181 | self._T2_set_point.x = t/60 1182 | self._T2_set_point.y = SP_T2 1183 | 1184 | self._u1.x = t/60 1185 | self._u1.y = Q1 1186 | self._wQ1.value = np.round(Q1[-1], 1) 1187 | 1188 | self._u2.x = t/60 1189 | self._u2.y = Q2 1190 | self._wQ2.value = np.round(Q2[-1], 1) 1191 | 1192 | time.sleep(self._sleep) 1193 | 1194 | a.Q1(0) 1195 | a.Q2(0) 1196 | a.close() 1197 | 1198 | ########################################################################### 1199 | # THREADING FUNCTION - PID 1200 | ########################################################################### 1201 | def _work_pid(self): 1202 | try: 1203 | a = TCLab() 1204 | except: 1205 | a.close() 1206 | a = TCLab() 1207 | 1208 | # Parater to start each cycle 1209 | self._Tc0 = np.array([ 1210 | a.T1 + 273.15, 1211 | a.T2 + 273.15 1212 | ]) 1213 | 1214 | # arrays to store data 1215 | t = np.array([]) 1216 | Q1 = np.array([]) 1217 | Q2 = np.array([]) 1218 | T = np.array([[]]).reshape((0, 2)) 1219 | SP_T1 = np.array([]) 1220 | SP_T2 = np.array([]) 1221 | 1222 | t = np.append(t, np.array([0]), axis=0) 1223 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1224 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1225 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1226 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1227 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1228 | 1229 | # Integral error 1230 | ierr1 = 0.0 1231 | ierr2 = 0.0 1232 | 1233 | # Main Loop 1234 | start_time = time.time() 1235 | prev_time = start_time 1236 | 1237 | while self._flag: 1238 | 1239 | # Sleep time 1240 | sleep_max = self._delta_t 1241 | sleep = sleep_max - (time.time() - prev_time) 1242 | if sleep >= 0.01: 1243 | time.sleep(sleep-0.01) 1244 | else: 1245 | time.sleep(0.01) 1246 | 1247 | # Read temperatures in Celsius 1248 | self._Tc0 = np.array([ 1249 | a.T1 + 273.15, 1250 | a.T2 + 273.15 1251 | ]) 1252 | 1253 | if len(t) >= self._maxtime: 1254 | t = np.delete(t, 0, 0) 1255 | T = np.delete(T, 0, 0) 1256 | Q1 = np.delete(Q1, 0, 0) 1257 | Q2 = np.delete(Q2, 0, 0) 1258 | SP_T1 = np.delete(SP_T1, 0, 0) 1259 | SP_T2 = np.delete(SP_T2, 0, 0) 1260 | 1261 | # Record time and change in time 1262 | tm = time.time() 1263 | prev_time = tm 1264 | tm = tm - start_time 1265 | 1266 | t = np.append(t, np.array([tm]), axis=0) 1267 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1268 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1269 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1270 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1271 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1272 | 1273 | # Calculate PID output 1274 | [self._Q10, ierr1] = self._PID( 1275 | self._T1_SP, T[-1, 0]-273.15, T[-2, 0]-273.15, ierr1, 1276 | self._delta_t, self._pid1_gain, self._pid1_reset, 1277 | self._pid1_rate) 1278 | [self._Q20, ierr2] = self._PID( 1279 | self._T2_SP, T[-1, 1]-273.15, T[-2, 1]-273.15, ierr2, 1280 | self._delta_t, self._pid2_gain, self._pid2_reset, 1281 | self._pid2_rate) 1282 | 1283 | # Write new heater values (0-100) 1284 | a.Q1(self._Q10) 1285 | a.Q2(self._Q20) 1286 | 1287 | self._T1_meas.x = t/60 1288 | self._T1_meas.y = T[:, 0] - 273.15 1289 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1290 | 1291 | self._T1_set_point.x = t/60 1292 | self._T1_set_point.y = SP_T1 1293 | 1294 | self._T2_meas.x = t/60 1295 | self._T2_meas.y = T[:, 1] - 273.15 1296 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1297 | 1298 | self._T2_set_point.x = t/60 1299 | self._T2_set_point.y = SP_T2 1300 | 1301 | self._u1.x = t/60 1302 | self._u1.y = Q1 1303 | self._wQ1.value = np.round(Q1[-1], 1) 1304 | 1305 | self._u2.x = t/60 1306 | self._u2.y = Q2 1307 | self._wQ2.value = np.round(Q2[-1], 1) 1308 | 1309 | time.sleep(self._sleep) 1310 | 1311 | a.Q1(0) 1312 | a.Q2(0) 1313 | a.close() 1314 | 1315 | ########################################################################### 1316 | # THREADING FUNCTION - MPC 1317 | ########################################################################### 1318 | def _work_mpc(self): 1319 | try: 1320 | a = TCLab() 1321 | except: 1322 | a.close() 1323 | a = TCLab() 1324 | 1325 | # Parater to start each cycle 1326 | self._Tc0 = np.array([ 1327 | a.T1 + 273.15, 1328 | a.T2 + 273.15 1329 | ]) 1330 | 1331 | # arrays to store data 1332 | t = np.array([]) 1333 | Q1 = np.array([]) 1334 | Q2 = np.array([]) 1335 | T = np.array([[]]).reshape((0, 2)) 1336 | SP_T1 = np.array([]) 1337 | SP_T2 = np.array([]) 1338 | 1339 | t = np.append(t, np.array([0]), axis=0) 1340 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1341 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1342 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1343 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1344 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1345 | 1346 | # Create MPC object 1347 | m = self._MPC() 1348 | 1349 | # Main Loop 1350 | start_time = time.time() 1351 | prev_time = start_time 1352 | 1353 | while self._flag: 1354 | 1355 | # Sleep time 1356 | sleep_max = self._delta_t 1357 | sleep = sleep_max - (time.time() - prev_time) 1358 | if sleep >= 0.01: 1359 | time.sleep(sleep-0.01) 1360 | else: 1361 | time.sleep(0.01) 1362 | 1363 | # Read temperatures in Celsius 1364 | self._Tc0 = np.array([ 1365 | a.T1 + 273.15, 1366 | a.T2 + 273.15 1367 | ]) 1368 | 1369 | if len(t) >= self._maxtime: 1370 | t = np.delete(t, 0, 0) 1371 | T = np.delete(T, 0, 0) 1372 | Q1 = np.delete(Q1, 0, 0) 1373 | Q2 = np.delete(Q2, 0, 0) 1374 | SP_T1 = np.delete(SP_T1, 0, 0) 1375 | SP_T2 = np.delete(SP_T2, 0, 0) 1376 | 1377 | # Record time and change in time 1378 | tm = time.time() 1379 | prev_time = tm 1380 | tm = tm - start_time 1381 | 1382 | t = np.append(t, np.array([tm]), axis=0) 1383 | T = np.append(T, self._Tc0.reshape((1, 2)), axis=0) 1384 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1385 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1386 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1387 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1388 | 1389 | # Change SOLVER 1390 | if self._SOLVER == '1 - APOPT': 1391 | m.options.SOLVER = 1 1392 | elif self._SOLVER == '2 - BPOPT': 1393 | m.options.SOLVER = 2 1394 | else: 1395 | m.options.SOLVER = 3 1396 | 1397 | # Change CVTYPE 1398 | if self._CVTYPE == '1 - Deadband': 1399 | m.options.CV_TYPE = 1 1400 | else: 1401 | m.options.CV_TYPE = 2 1402 | 1403 | # Add measurements to the MPC 1404 | m.TC1.MEAS = self._Tc0[0] - 273.15 1405 | m.TC2.MEAS = self._Tc0[1] - 273.15 1406 | 1407 | # Update Parameters 1408 | m.TC1.TAU = self._T1_tau 1409 | m.TC2.TAU = self._T2_tau 1410 | 1411 | m.Q1.DMAX = self._Q1_DMAX 1412 | m.Q1.DCOST = self._Q1_DCOST 1413 | m.Q2.DMAX = self._Q2_DMAX 1414 | m.Q2.DCOST = self._Q2_DCOST 1415 | 1416 | # Update prediction horizon 1417 | DT = self._delta_t 1418 | m.time = [ 1419 | 0, 1420 | DT, 1421 | DT*2, 1422 | DT*3, 1423 | DT*4, 1424 | DT*5, 1425 | DT*6, 1426 | DT*7, 1427 | DT*8, 1428 | DT*10, 1429 | DT*12, 1430 | DT*15, 1431 | DT*18, 1432 | DT*20, 1433 | DT*25] 1434 | 1435 | if m.options.CV_TYPE == 1: 1436 | # Input setpoint with deadband +/- DT 1437 | DT1 = self._T1_dt 1438 | m.TC1.SPHI = self._T1_SP + DT1 1439 | m.TC1.SPLO = self._T1_SP - DT1 1440 | 1441 | DT2 = self._T2_dt 1442 | m.TC2.SPHI = self._T2_SP + DT2 1443 | m.TC2.SPLO = self._T2_SP - DT2 1444 | else: 1445 | m.TC1.SP = self._T1_SP 1446 | m.TC2.SP = self._T2_SP 1447 | 1448 | try: 1449 | # Solve MPC 1450 | m.solve(disp=False) 1451 | # Check if successful solution 1452 | if (m.options.APPSTATUS == 1): 1453 | # retrieve new value 1454 | self._Q10 = m.Q1.NEWVAL 1455 | self._Q20 = m.Q2.NEWVAL 1456 | except: 1457 | # Keep previous value 1458 | pass 1459 | 1460 | # Write new heater values (0-100) 1461 | a.Q1(self._Q10) 1462 | a.Q2(self._Q20) 1463 | 1464 | self._T1_meas.x = t/60 1465 | self._T1_meas.y = T[:, 0] - 273.15 1466 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1467 | 1468 | self._T1_set_point.x = t/60 1469 | self._T1_set_point.y = SP_T1 1470 | 1471 | self._T2_meas.x = t/60 1472 | self._T2_meas.y = T[:, 1] - 273.15 1473 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1474 | 1475 | self._T2_set_point.x = t/60 1476 | self._T2_set_point.y = SP_T2 1477 | 1478 | self._u1.x = t/60 1479 | self._u1.y = Q1 1480 | self._wQ1.value = np.round(Q1[-1], 1) 1481 | 1482 | self._u2.x = t/60 1483 | self._u2.y = Q2 1484 | self._wQ2.value = np.round(Q2[-1], 1) 1485 | 1486 | time.sleep(self._sleep) 1487 | 1488 | a.Q1(0) 1489 | a.Q2(0) 1490 | a.close() 1491 | -------------------------------------------------------------------------------- /control_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Wed Jan 23 19:02:13 2019 5 | 6 | @author: evertoncolling 7 | @email: evertoncolling@gmail.com 8 | @licence: MIT 9 | """ 10 | 11 | from __future__ import print_function, division 12 | from ipywidgets import widgets as wi 13 | from IPython.display import display 14 | import threading 15 | import time 16 | import numpy as np 17 | from gekko import GEKKO 18 | from scipy.integrate import odeint 19 | import bqplot as bq 20 | 21 | 22 | class GUI(object): 23 | """ 24 | Class that defines the _GUI applications 25 | """ 26 | def __init__(self): 27 | """ 28 | Initialize the _GUI elements 29 | """ 30 | ####################################################################### 31 | # PLOTTING CONFIGURATION 32 | ####################################################################### 33 | colors = [ 34 | '#1f77b4', # T1 35 | '#5ebdff', # T1_SP 36 | '#d62728', # T2 37 | '#ff7f0e', # T2_SP 38 | '#1f77b4', # Q1 39 | '#d62728' # Q2 40 | ] 41 | 42 | # Create scales 43 | t_sc = bq.LinearScale() 44 | T_sc = bq.LinearScale(min=20., max=60.) 45 | Q_sc = bq.LinearScale(min=0., max=100.) 46 | T_sc_set = {'x': t_sc, 'y': T_sc} 47 | Q_sc_set = {'x': t_sc, 'y': Q_sc} 48 | 49 | # Create Axis 50 | ax_x1 = bq.Axis(label="", scale=t_sc, visible=False) # upper 51 | ax_x2 = bq.Axis(label="time [min]", scale=t_sc) # down 52 | ax_T1 = bq.Axis(label="Temperature 1 [C]", scale=T_sc, 53 | orientation="vertical", label_color=colors[0]) 54 | ax_T2 = bq.Axis(label="Temperature 2 [C]", scale=T_sc, 55 | orientation="vertical", label_color=colors[2]) 56 | ax_Q = bq.Axis(label="Heater Output [%]", scale=Q_sc, 57 | orientation="vertical", label_color="black") 58 | 59 | # Create Lines/Markers 60 | self._T1_meas = bq.Scatter(x=[], y=[], scales=T_sc_set, 61 | marker='circle', colors=[colors[0]], 62 | display_legend=False, default_size=20) 63 | self._T2_meas = bq.Scatter(x=[], y=[], scales=T_sc_set, 64 | marker='circle', colors=[colors[2]], 65 | display_legend=False, default_size=20) 66 | 67 | self._T1_set_point = bq.Lines(x=[], y=[], scales=T_sc_set, 68 | stroke_width=4, colors=[colors[1]], 69 | interpolation='step-before', 70 | display_legend=False) 71 | self._T2_set_point = bq.Lines(x=[], y=[], scales=T_sc_set, 72 | stroke_width=4, colors=[colors[3]], 73 | interpolation='step-before', 74 | display_legend=False) 75 | 76 | self._u1 = bq.Lines(x=[], y=[], scales=Q_sc_set, 77 | stroke_width=2, colors=[colors[4]], 78 | interpolation='step-before', 79 | display_legend=True, labels=['Heater 1']) 80 | self._u2 = bq.Lines(x=[], y=[], scales=Q_sc_set, 81 | stroke_width=2, colors=[colors[5]], 82 | interpolation='step-before', 83 | display_legend=True, labels=['Heater 2']) 84 | 85 | # Mix everything and create figures 86 | fig_lay_up = wi.Layout(width="400px", height="240px") 87 | fig_lay_down = wi.Layout(width="400px", height="290px") 88 | 89 | box1 = dict(top=10, bottom=0, left=60, right=5) 90 | box2 = dict(top=10, bottom=0, left=60, right=20) 91 | box3 = dict(top=10, bottom=50, left=60, right=10) 92 | 93 | fig1 = bq.Figure(layout=fig_lay_up, axes=[ax_x1, ax_T1], 94 | marks=[self._T1_meas, self._T1_set_point], 95 | fig_margin=box1) 96 | 97 | fig2 = bq.Figure(layout=fig_lay_up, axes=[ax_x1, ax_T2], 98 | marks=[self._T2_meas, self._T2_set_point], 99 | fig_margin=box2) 100 | 101 | fig3 = bq.Figure(layout=fig_lay_down, axes=[ax_x2, ax_Q], 102 | marks=[self._u1, self._u2], fig_margin=box3, 103 | legend_location="left", 104 | legend_style={"fill": "white", 105 | "fill-opacity": "0.7", 106 | "width": "130px"}) 107 | 108 | ####################################################################### 109 | # SPACING WIDGETS CRIATION 110 | ####################################################################### 111 | v_space = wi.Label(value="", layout=wi.Layout(height='2px')) 112 | h_space = wi.Label(value="", layout=wi.Layout(width='2px')) 113 | 114 | ####################################################################### 115 | # VARYING ARRAYS TO STORE DATA 116 | ####################################################################### 117 | self._t = np.array([]) 118 | self._Q1 = np.array([]) 119 | self._Q2 = np.array([]) 120 | self._T = np.array([[]]).reshape((0, 2)) 121 | self._SP_T1 = np.array([]) 122 | self._SP_T2 = np.array([]) 123 | 124 | ####################################################################### 125 | # PARAMETERS 126 | ####################################################################### 127 | self._delta_t = 4.0 128 | self._maxtime = int(500/self._delta_t) 129 | self._Th0 = np.array([293.15, 293.15]) 130 | self._Tc0 = np.array([293.15, 293.15]) 131 | self._T1_SP = 30 132 | self._T2_SP = 30 133 | self._Q10 = 0 134 | self._Q20 = 0 135 | self._flag = False 136 | self._sleep = 0.5 137 | 138 | self._q1_dt_on_off = 0.1 139 | self._q2_dt_on_off = 0.1 140 | 141 | self._pid1_gain = 10. 142 | self._pid1_reset = 50. 143 | self._pid1_rate = 1. 144 | 145 | self._pid2_gain = 10. 146 | self._pid2_reset = 50. 147 | self._pid2_rate = 1. 148 | 149 | self._SOLVER = '1 - APOPT' 150 | self._CVTYPE = '1 - Deadband' 151 | 152 | self._T1_dt = 0.1 153 | self._T1_tau = 10. 154 | self._T2_dt = 0.1 155 | self._T2_tau = 10. 156 | 157 | self._Q1_DMAX = 30. 158 | self._Q1_DCOST = 1. 159 | self._Q2_DMAX = 30. 160 | self._Q2_DCOST = 1. 161 | 162 | ####################################################################### 163 | # OUTPUT WIDGETS CRIATION 164 | ####################################################################### 165 | style = {'description_width': 'initial'} 166 | 167 | self._PT1 = wi.FloatProgress(value=self._Tc0[0]-273.15, min=0, 168 | max=100.0, 169 | description='PV (°C):', 170 | bar_style='warning', 171 | orientation='horizontal', 172 | style=style) 173 | self._LT1 = wi.Label(value=str(self._PT1.value)) 174 | wi.jslink((self._PT1, 'value'), (self._LT1, 'value')) 175 | 176 | self._PT2 = wi.FloatProgress(value=self._Tc0[1]-273.15, min=0, 177 | max=100.0, 178 | description='PV (°C):', 179 | bar_style='warning', 180 | orientation='horizontal', 181 | style=style) 182 | self._LT2 = wi.Label(value=str(self._PT2.value)) 183 | wi.jslink((self._PT2, 'value'), (self._LT2, 'value')) 184 | 185 | ####################################################################### 186 | # INTERACTION WIDGETS CREATION 187 | ####################################################################### 188 | self._wQ1 = wi.FloatSlider(value=self._Q10, min=0, max=100.0, step=0.5, 189 | description='Q1 (%):', 190 | continuous_update=False, 191 | orientation='horizontal', 192 | readout=False, style=style, 193 | layout=wi.Layout(width='230px')) 194 | self._tQ1 = wi.BoundedFloatText(value=self._Q10, min=0, max=100.0, 195 | step=0.5, 196 | layout=wi.Layout(width='60px')) 197 | wi.jslink((self._wQ1, 'value'), (self._tQ1, 'value')) 198 | 199 | self._bQ1 = wi.Button(description='Set', 200 | layout=wi.Layout(width='50px')) 201 | self._bQ1.on_click(self._Q1_click) 202 | 203 | self._wQ2 = wi.FloatSlider(value=self._Q20, min=0, max=100.0, step=0.5, 204 | description='Q2 (%):', 205 | continuous_update=False, 206 | orientation='horizontal', 207 | readout=False, style=style, 208 | layout=wi.Layout(width='230px')) 209 | self._tQ2 = wi.BoundedFloatText(value=self._Q20, min=0, max=100.0, 210 | step=0.5, 211 | layout=wi.Layout(width='60px')) 212 | wi.jslink((self._wQ2, 'value'), (self._tQ2, 'value')) 213 | 214 | self._bQ2 = wi.Button(description='Set', 215 | layout=wi.Layout(width='50px')) 216 | self._bQ2.on_click(self._Q2_click) 217 | 218 | self._wT1 = wi.FloatSlider(value=self._T1_SP, min=20, max=60.0, 219 | step=0.5, description='T1 SP:', 220 | continuous_update=False, 221 | orientation='horizontal', 222 | readout=False, style=style, 223 | disabled=True, 224 | layout=wi.Layout(width='230px')) 225 | self._tT1 = wi.BoundedFloatText(value=self._T1_SP, min=20, max=60.0, 226 | step=0.5, disabled=True, 227 | layout=wi.Layout(width='60px')) 228 | wi.jslink((self._wT1, 'value'), (self._tT1, 'value')) 229 | 230 | self._bT1 = wi.Button(description='Set', 231 | layout=wi.Layout(width='50px'), 232 | disabled=True) 233 | self._bT1.on_click(self._T1_click) 234 | 235 | self._wT2 = wi.FloatSlider(value=self._T2_SP, min=20, max=60.0, 236 | step=0.5, description='T2 SP:', 237 | continuous_update=False, 238 | orientation='horizontal', disabled=True, 239 | readout=False, style=style, 240 | layout=wi.Layout(width='230px')) 241 | self._tT2 = wi.BoundedFloatText(value=self._T2_SP, min=20, max=60.0, 242 | step=0.5, disabled=True, 243 | layout=wi.Layout(width='60px')) 244 | wi.jslink((self._wT2, 'value'), (self._tT2, 'value')) 245 | 246 | self._bT2 = wi.Button(description='Set', 247 | layout=wi.Layout(width='50px'), disabled=True) 248 | self._bT2.on_click(self._T2_click) 249 | 250 | ####################################################################### 251 | # MODE SELECTION 252 | ####################################################################### 253 | self._mode = wi.ToggleButtons(options=['Manual', 254 | 'On-Off', 255 | 'PID', 256 | 'MPC'], 257 | style={'button_width': '100px'}) 258 | self._mode.observe(self._mode_switch, names='value') 259 | 260 | ####################################################################### 261 | # STOP THREAD 262 | ####################################################################### 263 | self._b_stop = wi.Button(description='Stop', button_style='warning', 264 | icon='stop', layout=wi.Layout(width='100px', 265 | height='32px')) 266 | self._b_stop.on_click(self._stop_click) 267 | 268 | ####################################################################### 269 | # START THREAD - OPEN LOOP 270 | ####################################################################### 271 | self._b_play = wi.Button(description='Start', button_style='success', 272 | icon='play', layout=wi.Layout(width='100px', 273 | height='32px')) 274 | self._b_play.on_click(self._play_click) 275 | 276 | # Join Buttons 277 | buttons = wi.HBox((self._b_play, h_space, self._b_stop, 278 | wi.Label(value="", layout=wi.Layout(width='165px')), 279 | self._mode)) 280 | 281 | ####################################################################### 282 | # LAYOUT 283 | ####################################################################### 284 | Q1_Set = wi.HBox((h_space, self._wQ1, self._tQ1, self._bQ1), 285 | layout=wi.Layout(border='solid 2px gray', 286 | width='360px')) 287 | T1_Set = wi.HBox((h_space, self._wT1, self._tT1, self._bT1), 288 | layout=wi.Layout(border='solid 2px gray', 289 | width='360px')) 290 | T1_View = wi.HBox((h_space, self._PT1, self._LT1), 291 | layout=wi.Layout(border='solid 2px gray', 292 | width='360px')) 293 | Q2_Set = wi.HBox((h_space, self._wQ2, self._tQ2, self._bQ2), 294 | layout=wi.Layout(border='solid 2px gray', 295 | width='360px')) 296 | T2_Set = wi.HBox((h_space, self._wT2, self._tT2, self._bT2), 297 | layout=wi.Layout(border='solid 2px gray', 298 | width='360px')) 299 | T2_View = wi.HBox((h_space, self._PT2, self._LT2), 300 | layout=wi.Layout(border='solid 2px gray', 301 | width='360px')) 302 | 303 | co1 = wi.VBox((T1_Set, v_space, T1_View, v_space, Q1_Set)) 304 | co2 = wi.VBox((T2_Set, v_space, T2_View, v_space, Q2_Set)) 305 | co = wi.VBox((v_space, v_space, co1, v_space, v_space, v_space, co2)) 306 | fig1x = wi.HBox((fig1, fig2)) 307 | fig2x = wi.HBox((fig3, h_space, h_space, co)) 308 | figy = wi.VBox((fig1x, v_space, fig2x), 309 | layout=wi.Layout(border='solid 2px gray', 310 | width='800px')) 311 | 312 | ####################################################################### 313 | # APPLICATION TO DISPLAY 314 | ####################################################################### 315 | self._gui = wi.VBox((buttons, v_space, figy)) 316 | 317 | ####################################################################### 318 | # CONFIGURATOR 319 | ####################################################################### 320 | style = {'description_width': 'initial'} 321 | lay = wi.Layout(width='120px', margin='0 20px 0 0') 322 | 323 | ####################################################################### 324 | # GENERAL OPTIONS 325 | ####################################################################### 326 | self._conf11 = wi.HBox(( 327 | wi.HTML( 328 | value='Δt (s):
', 329 | layout=lay), 330 | wi.FloatSlider(value=4.0, min=1.0, max=10.0, step=0.5, 331 | description='', style=style))) 332 | 333 | self._conf12 = wi.HBox(( 334 | wi.HTML(value='' 335 | 'Sleep time (s):
', 336 | layout=lay), 337 | wi.FloatSlider(value=0.5, min=0.25, max=1.5, step=0.05, 338 | description='', style=style))) 339 | 340 | but11 = wi.Button(description='Apply', icon='check', 341 | layout=wi.Layout(width='100px', height='32px')) 342 | but11.on_click(self._conf_general) 343 | 344 | but12 = wi.Button(description='Reset', icon='refresh', 345 | layout=wi.Layout(width='100px', height='32px')) 346 | but12.on_click(self._reset_general) 347 | 348 | conf13 = wi.HBox((but11, but12), layout=wi.Layout(margin='10px 0 0 0')) 349 | 350 | ####################################################################### 351 | # ON-OFF OPTIONS 352 | ####################################################################### 353 | lay5 = wi.Layout(width='90px', margin='0 10px 0 15px') 354 | 355 | conf20 = wi.Button(description='TEMPERATURE 01 / HEATER 01', 356 | disabled=True, 357 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 358 | 359 | self._conf21 = wi.HBox(( 360 | wi.HTML(value='' 361 | 'Deadband (K):
', 362 | layout=lay5), 363 | wi.FloatSlider(value=0.1, min=0.0, max=2.0, step=0.1, 364 | description='', style=style, 365 | layout=wi.Layout(width='200px'))), 366 | layout=wi.Layout(margin='5px 0 0 0') 367 | ) 368 | 369 | box21 = wi.VBox((conf20, self._conf21), 370 | layout=wi.Layout(border='solid 2px gray', 371 | width='325px', 372 | margin='10px 0 0 0px')) 373 | 374 | conf22 = wi.Button(description='TEMPERATURE 02 / HEATER 02', 375 | disabled=True, 376 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 377 | 378 | self._conf23 = wi.HBox(( 379 | wi.HTML(value='' 380 | 'Deadband (K):
', 381 | layout=lay5), 382 | wi.FloatSlider(value=0.1, min=0.0, max=2.0, step=0.1, 383 | description='', style=style, 384 | layout=wi.Layout(width='200px'))), 385 | layout=wi.Layout(margin='5px 0 0 0') 386 | ) 387 | 388 | box22 = wi.VBox((conf22, self._conf23), 389 | layout=wi.Layout(border='solid 2px gray', 390 | width='325px', 391 | margin='10px 0 0 10px')) 392 | 393 | but21 = wi.Button(description='Apply', icon='check', 394 | layout=wi.Layout(width='100px', height='32px')) 395 | but21.on_click(self._conf_on_off) 396 | 397 | but22 = wi.Button(description='Reset', icon='refresh', 398 | layout=wi.Layout(width='100px', height='32px')) 399 | but22.on_click(self._reset_on_off) 400 | 401 | conf24 = wi.HBox((but21, but22), layout=wi.Layout(margin='10px 0 0 0')) 402 | 403 | ####################################################################### 404 | # PID OPTIONS 405 | ####################################################################### 406 | lay4 = wi.Layout(width='100px', margin='0 10px 0 15px') 407 | 408 | conf30 = wi.Button(description='TEMPERATURE 01 / HEATER 01', 409 | disabled=True, 410 | layout=wi.Layout(width='330px', margin='0 0 0 0')) 411 | 412 | self._conf31 = wi.HBox(( 413 | wi.HTML(value='' 414 | 'Kc (K/%Heater):
', 415 | layout=lay4), 416 | wi.FloatSlider(value=10.0, min=0.0, max=20.0, step=0.5, 417 | description='', style=style, 418 | layout=wi.Layout(width='200px'))), 419 | layout=wi.Layout(margin='5px 0 0 0') 420 | ) 421 | 422 | self._conf32 = wi.HBox(( 423 | wi.HTML(value='tauI (s):
', 424 | layout=lay4), 425 | wi.FloatSlider(value=50.0, min=0.0, max=200.0, step=1.0, 426 | description='', style=style, 427 | layout=wi.Layout(width='200px')))) 428 | 429 | self._conf33 = wi.HBox(( 430 | wi.HTML(value='tauD (s):
', 431 | layout=lay4), 432 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.5, 433 | description='', style=style, 434 | layout=wi.Layout(width='200px')))) 435 | 436 | box31 = wi.VBox((conf30, self._conf31, self._conf32, self._conf33), 437 | layout=wi.Layout(border='solid 2px gray', 438 | width='335px', 439 | margin='10px 0 0 0px')) 440 | 441 | conf34 = wi.Button(description='TEMPERATURE 02 / HEATER 02', 442 | disabled=True, 443 | layout=wi.Layout(width='330px', margin='0 0 0 0')) 444 | 445 | self._conf35 = wi.HBox(( 446 | wi.HTML(value='' 447 | 'Kc (K/%Heater):
', 448 | layout=lay4), 449 | wi.FloatSlider(value=10.0, min=0.0, max=20.0, step=0.5, 450 | description='', style=style, 451 | layout=wi.Layout(width='200px'))), 452 | layout=wi.Layout(margin='5px 0 0 0') 453 | ) 454 | 455 | self._conf36 = wi.HBox(( 456 | wi.HTML(value='tauI (s):
', 457 | layout=lay4), 458 | wi.FloatSlider(value=50.0, min=0.0, max=200.0, step=1.0, 459 | description='', style=style, 460 | layout=wi.Layout(width='200px')))) 461 | 462 | self._conf37 = wi.HBox(( 463 | wi.HTML(value='tauD (s):
', 464 | layout=lay4), 465 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.5, 466 | description='', style=style, 467 | layout=wi.Layout(width='200px')))) 468 | 469 | box32 = wi.VBox((conf34, self._conf35, self._conf36, self._conf37), 470 | layout=wi.Layout(border='solid 2px gray', 471 | width='335px', 472 | margin='10px 0 0 10px')) 473 | 474 | but31 = wi.Button(description='Apply', icon='check', 475 | layout=wi.Layout(width='100px', height='32px')) 476 | but31.on_click(self._conf_pid) 477 | 478 | but32 = wi.Button(description='Reset', icon='refresh', 479 | layout=wi.Layout(width='100px', height='32px')) 480 | but32.on_click(self._reset_pid) 481 | 482 | conf38 = wi.HBox((but31, but32), layout=wi.Layout(margin='10px 0 0 0')) 483 | 484 | ####################################################################### 485 | # MPC OPTIONS 486 | ####################################################################### 487 | lay1 = wi.Layout(width='60px', margin='0 10px 0 25px') 488 | lay2 = wi.Layout(width='95px', margin='0 20px 0 10px') 489 | lay3 = wi.Layout(width='55px', margin='0 20px 0 10px') 490 | 491 | self._conf40 = wi.HBox(( 492 | wi.HTML(value='SOLVER:
', 493 | layout=lay1), 494 | wi.Dropdown(options=['1 - APOPT', '2 - BPOPT', '3 - IPOPT'], 495 | value='1 - APOPT', 496 | layout=wi.Layout(width='100px')), 497 | wi.HTML(value='CVTYPE:
', 498 | layout=lay1), 499 | wi.Dropdown(options=['1 - Deadband', '2 - Trajectory'], 500 | value='1 - Deadband', 501 | layout=wi.Layout(width='130px'))), 502 | layout=wi.Layout(margin='5px 0 0 0') 503 | ) 504 | 505 | conf41 = wi.Button(description='TEMPERATURE 01', 506 | disabled=True, 507 | layout=wi.Layout(width='360px', margin='0 0 0 0')) 508 | 509 | self._conf42 = wi.HBox(( 510 | wi.HTML(value='' 511 | 'Deadband (K):
', 512 | layout=lay2), 513 | wi.FloatSlider(value=0.1, min=0.1, max=1.0, step=0.1, 514 | description='', style=style, 515 | layout=wi.Layout(width='250px'))), 516 | layout=wi.Layout(margin='5px 0 0 0') 517 | ) 518 | 519 | self._conf43 = wi.HBox(( 520 | wi.HTML(value='TAU:
', 521 | layout=lay2), 522 | wi.FloatSlider(value=10.0, min=1.0, max=100.0, step=1.0, 523 | description='', style=style, 524 | layout=wi.Layout(width='250px')))) 525 | 526 | box41 = wi.VBox((conf41, self._conf42, self._conf43), 527 | layout=wi.Layout(border='solid 2px gray', 528 | width='365px', 529 | margin='10px 0 0 0')) 530 | 531 | conf44 = wi.Button(description='TEMPERATURE 02', 532 | disabled=True, 533 | layout=wi.Layout(width='360px', margin='0 0 0 0')) 534 | 535 | self._conf45 = wi.HBox(( 536 | wi.HTML(value='' 537 | 'Deadband (K):
', 538 | layout=lay2), 539 | wi.FloatSlider(value=0.1, min=0.1, max=1.0, step=0.1, 540 | description='', style=style, 541 | layout=wi.Layout(width='250px'))), 542 | layout=wi.Layout(margin='5px 0 0 0') 543 | ) 544 | 545 | self._conf46 = wi.HBox(( 546 | wi.HTML(value='TAU:
', 547 | layout=lay2), 548 | wi.FloatSlider(value=10.0, min=1.0, max=100.0, step=1.0, 549 | description='', style=style, 550 | layout=wi.Layout(width='250px')))) 551 | 552 | box42 = wi.VBox((conf44, self._conf45, self._conf46), 553 | layout=wi.Layout(border='solid 2px gray', 554 | width='365px', 555 | margin='10px 0 0 0')) 556 | 557 | conf47 = wi.Button(description='HEATER 01', 558 | disabled=True, 559 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 560 | 561 | self._conf48 = wi.HBox(( 562 | wi.HTML(value='DMAX:
', 563 | layout=lay3), 564 | wi.FloatSlider(value=30.0, min=1.0, max=100.0, step=0.5, 565 | description='', style=style, 566 | layout=wi.Layout(width='250px'))), 567 | layout=wi.Layout(margin='5px 0 0 0') 568 | ) 569 | 570 | self._conf49 = wi.HBox(( 571 | wi.HTML(value='DCOST:
', 572 | layout=lay3), 573 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.1, 574 | description='', style=style, 575 | layout=wi.Layout(width='250px')))) 576 | 577 | box43 = wi.VBox((conf47, self._conf48, self._conf49), 578 | layout=wi.Layout(border='solid 2px gray', 579 | width='325px', 580 | margin='10px 0 0 10px')) 581 | 582 | conf410 = wi.Button(description='HEATER 02', 583 | disabled=True, 584 | layout=wi.Layout(width='320px', margin='0 0 0 0')) 585 | 586 | self._conf411 = wi.HBox(( 587 | wi.HTML(value='DMAX:
', 588 | layout=lay3), 589 | wi.FloatSlider(value=30.0, min=1.0, max=100.0, step=0.5, 590 | description='', style=style, 591 | layout=wi.Layout(width='250px'))), 592 | layout=wi.Layout(margin='5px 0 0 0') 593 | ) 594 | 595 | self._conf412 = wi.HBox(( 596 | wi.HTML(value='DCOST:
', 597 | layout=lay3), 598 | wi.FloatSlider(value=1.0, min=0.0, max=10.0, step=0.1, 599 | description='', style=style, 600 | layout=wi.Layout(width='250px')))) 601 | 602 | box44 = wi.VBox((conf410, self._conf411, self._conf412), 603 | layout=wi.Layout(border='solid 2px gray', 604 | width='325px', 605 | margin='10px 0 0 10px')) 606 | 607 | but41 = wi.Button(description='Apply', icon='check', 608 | layout=wi.Layout(width='100px', height='32px')) 609 | but41.on_click(self._conf_mpc) 610 | but42 = wi.Button(description='Reset', icon='refresh', 611 | layout=wi.Layout(width='100px', height='32px')) 612 | but42.on_click(self._reset_mpc) 613 | conf413 = wi.HBox((but41, but42), 614 | layout=wi.Layout(margin='10px 0 0 0')) 615 | 616 | ####################################################################### 617 | # CONFIGURATOR LAYOUT 618 | ####################################################################### 619 | tab = wi.Tab([wi.VBox((self._conf11, self._conf12, 620 | wi.Label(layout=wi.Layout(height='206px')), 621 | conf13)), 622 | wi.VBox((wi.HBox((box21, box22)), 623 | wi.Label(layout=wi.Layout(height='191px')), 624 | conf24)), 625 | wi.VBox((wi.HBox((box31, box32)), 626 | wi.Label(layout=wi.Layout(height='127px')), 627 | conf38)), 628 | wi.VBox((self._conf40, 629 | wi.HBox((box41, box43)), 630 | wi.HBox((box42, box44)), 631 | wi.Label(layout=wi.Layout(height='11px')), 632 | conf413))], 633 | layout=wi.Layout(width='800px', height='380px')) 634 | tab.set_title(0, 'General Options') 635 | tab.set_title(1, 'On-Off Options') 636 | tab.set_title(2, 'PID Options') 637 | tab.set_title(3, 'MPC Options') 638 | 639 | ####################################################################### 640 | # DISPLAY CONFIGURATOR 641 | ####################################################################### 642 | self._conf = tab 643 | 644 | def app(self): 645 | display(self._gui) 646 | 647 | def config(self): 648 | display(self._conf) 649 | 650 | def _conf_general(self, b): 651 | self._delta_t = self._conf11.children[1].value 652 | self._maxtime = int(500/self._delta_t) 653 | 654 | self._sleep = self._conf12.children[1].value 655 | 656 | def _reset_general(self, b): 657 | self._conf11.children[1].value = 4.0 658 | self._delta_t = self._conf11.children[1].value 659 | self._maxtime = int(500/self._delta_t) 660 | 661 | self._conf12.children[1].value = 0.5 662 | self._sleep = self._conf12.children[1].value 663 | 664 | def _conf_on_off(self, b): 665 | self._q1_dt_on_off = self._conf21.children[1].value 666 | 667 | self._q2_dt_on_off = self._conf23.children[1].value 668 | 669 | def _reset_on_off(self, b): 670 | self._conf21.children[1].value = 0.1 671 | self._q1_dt_on_off = self._conf21.children[1].value 672 | 673 | self._conf23.children[1].value = 0.1 674 | self._q2_dt_on_off = self._conf23.children[1].value 675 | 676 | def _conf_pid(self, b): 677 | self._pid1_gain = self._conf31.children[1].value 678 | self._pid1_reset = self._conf32.children[1].value 679 | self._pid1_rate = self._conf33.children[1].value 680 | 681 | self._pid2_gain = self._conf35.children[1].value 682 | self._pid2_reset = self._conf36.children[1].value 683 | self._pid2_rate = self._conf37.children[1].value 684 | 685 | def _reset_pid(self, b): 686 | self._conf31.children[1].value = 10.0 # gain 687 | self._conf32.children[1].value = 50.0 # reset 688 | self._conf33.children[1].value = 1.0 # rate 689 | self._pid1_gain = self._conf31.children[1].value 690 | self._pid1_reset = self._conf32.children[1].value 691 | self._pid1_rate = self._conf33.children[1].value 692 | 693 | self._conf35.children[1].value = 10.0 # gain 694 | self._conf36.children[1].value = 50.0 # reset 695 | self._conf37.children[1].value = 1.0 # rate 696 | self._pid2_gain = self._conf35.children[1].value 697 | self._pid2_reset = self._conf36.children[1].value 698 | self._pid2_rate = self._conf37.children[1].valu 699 | 700 | def _conf_mpc(self, b): 701 | self._SOLVER = self._conf40.children[1].value 702 | self._CVTYPE = self._conf40.children[3].value 703 | 704 | self._T1_dt = self._conf42.children[1].value 705 | self._T1_tau = self._conf43.children[1].value 706 | self._T2_dt = self._conf45.children[1].value 707 | self._T2_tau = self._conf46.children[1].value 708 | 709 | self._Q1_DMAX = self._conf48.children[1].value 710 | self._Q1_DCOST = self._conf49.children[1].value 711 | self._Q2_DMAX = self._conf411.children[1].value 712 | self._Q2_DCOST = self._conf412.children[1].value 713 | 714 | def _reset_mpc(self, b): 715 | self._conf40.children[1].value = '1 - APOPT' 716 | self._conf40.children[3].value = '1 - Deadband' 717 | 718 | self._conf42.children[1].value = 0.1 719 | self._conf43.children[1].value = 30. 720 | self._conf45.children[1].value = 0.1 721 | self._conf46.children[1].value = 30. 722 | 723 | self._conf48.children[1].value = 30. 724 | self._conf49.children[1].value = 1. 725 | self._conf411.children[1].value = 30. 726 | self._conf412.children[1].value = 1. 727 | 728 | self._SOLVER = self._conf40.children[1].value 729 | self._CVTYPE = self._conf40.children[3].value 730 | 731 | self._T1_dt = self._conf42.children[1].value 732 | self._T1_tau = self._conf43.children[1].value 733 | self._T2_dt = self._conf45.children[1].value 734 | self._T2_tau = self._conf46.children[1].value 735 | 736 | self._Q1_DMAX = self._conf48.children[1].value 737 | self._Q1_DCOST = self._conf49.children[1].value 738 | self._Q2_DMAX = self._conf411.children[1].value 739 | self._Q2_DCOST = self._conf412.children[1].value 740 | 741 | def _Q1_click(self, b): 742 | self._Q10 = self._wQ1.value 743 | 744 | def _Q2_click(self, b): 745 | self._Q20 = self._wQ2.value 746 | 747 | def _T1_click(self, b): 748 | self._T1_SP = self._wT1.value 749 | 750 | def _T2_click(self, b): 751 | self._T2_SP = self._wT2.value 752 | 753 | def _stop_click(self, b): 754 | self._flag = False 755 | self._mode.disabled = False 756 | 757 | def _play_click(self, b): 758 | if not self._flag: 759 | if self._mode.value == "Manual": 760 | self._flag = True 761 | self._mode.disabled = True 762 | thread = threading.Thread(target=self._work_man) 763 | thread.start() 764 | elif self._mode.value == "On-Off": 765 | self._flag = True 766 | self._mode.disabled = True 767 | thread = threading.Thread(target=self._work_on_off) 768 | thread.start() 769 | elif self._mode.value == "PID": 770 | self._flag = True 771 | self._mode.disabled = True 772 | thread = threading.Thread(target=self._work_pid) 773 | thread.start() 774 | elif self._mode.value == "MPC": 775 | self._flag = True 776 | self._mode.disabled = True 777 | thread = threading.Thread(target=self._work_mpc) 778 | thread.start() 779 | 780 | def _mode_switch(self, value): 781 | # Reinitialize parameters 782 | self._Th0 = np.array([293.15, 293.15]) 783 | self._Tc0 = np.array([293.15, 293.15]) 784 | self._T1_SP = 30 785 | self._T2_SP = 30 786 | self._Q10 = 0 787 | self._Q20 = 0 788 | 789 | # Reset figures 790 | self._T1_meas.x = [] 791 | self._T1_meas.y = [] 792 | self._T2_meas.x = [] 793 | self._T2_meas.y = [] 794 | 795 | self._T1_set_point.x = [] 796 | self._T1_set_point.y = [] 797 | self._T2_set_point.x = [] 798 | self._T2_set_point.y = [] 799 | 800 | self._u1.x = [] 801 | self._u1.y = [] 802 | self._u2.x = [] 803 | self._u2.y = [] 804 | 805 | # Reset controls 806 | self._PT1.value = self._Tc0[0]-273.15 807 | self._PT2.value = self._Tc0[1]-273.15 808 | self._wT1.value = self._T1_SP 809 | self._wT2.value = self._T2_SP 810 | self._wQ1.value = self._Q10 811 | self._wQ2.value = self._Q20 812 | 813 | if value['new'] == "Manual": 814 | self._wQ1.disabled = False 815 | self._tQ1.disabled = False 816 | self._bQ1.disabled = False 817 | self._wT1.disabled = True 818 | self._tT1.disabled = True 819 | self._bT1.disabled = True 820 | 821 | self._wQ2.disabled = False 822 | self._tQ2.disabled = False 823 | self._bQ2.disabled = False 824 | self._wT2.disabled = True 825 | self._tT2.disabled = True 826 | self._bT2.disabled = True 827 | 828 | else: 829 | self._wQ1.disabled = True 830 | self._tQ1.disabled = True 831 | self._bQ1.disabled = True 832 | self._wT1.disabled = False 833 | self._tT1.disabled = False 834 | self._bT1.disabled = False 835 | 836 | self._wQ2.disabled = True 837 | self._tQ2.disabled = True 838 | self._bQ2.disabled = True 839 | self._wT2.disabled = False 840 | self._tT2.disabled = False 841 | self._bT2.disabled = False 842 | 843 | ########################################################################### 844 | # _MODEL TO SIMULATE 845 | ########################################################################### 846 | def _heater(self, x, t, Q1, Q2): 847 | # Parameters 848 | U = 4.87519009 + (np.random.rand()-0.5) # variable convection 849 | alpha1 = 0.00640897365 850 | alpha2 = 0.00310952441 851 | 852 | Ta = 23 + 273.15 # K 853 | m = 4.0/1000.0 # kg 854 | Cp = 0.5 * 1000.0 # J/kg-K 855 | A = 10.0 / 100.0**2 # Area in m^2 856 | As = 2.0 / 100.0**2 # Area in m^2 857 | eps = 0.9 # Emissivity 858 | sigma = 5.67e-8 # Stefan-Boltzman 859 | 860 | # Temperature States 861 | Th1 = x[0] 862 | Th2 = x[1] 863 | 864 | # Heat Transfer Exchange Between 1 and 2 865 | conv12 = U*As*(Th2-Th1) 866 | rad12 = eps*sigma*As * (Th2**4 - Th1**4) 867 | 868 | # Nonlinear Energy Balances 869 | dTh1dt = (1.0/(m*Cp)) * \ 870 | (U*A*(Ta-Th1) + 871 | eps * sigma * A * (Ta**4 - Th1**4) + 872 | conv12 + rad12 + alpha1*Q1) 873 | dTh2dt = (1.0/(m*Cp)) * \ 874 | (U*A*(Ta-Th2) + 875 | eps * sigma * A * (Ta**4 - Th2**4) - 876 | conv12 - rad12 + alpha2*Q2) 877 | 878 | return [dTh1dt, dTh2dt] 879 | 880 | def _sensor(self, x, t, Th1, Th2): 881 | # Parameter 882 | tau = 17.7176964 883 | 884 | # Temperature States 885 | Tc1 = x[0] 886 | Tc2 = x[1] 887 | 888 | # lag equations to emulate conduction 889 | dTc1dt = (-Tc1 + Th1)/tau 890 | dTc2dt = (-Tc2 + Th2)/tau 891 | 892 | return [dTc1dt, dTc2dt] 893 | 894 | ########################################################################### 895 | # PID CONTROLLER 896 | ########################################################################### 897 | 898 | # inputs ----------------------------------- 899 | # sp = setpoint 900 | # pv = current temperature 901 | # pv_last = prior temperature 902 | # ierr = integral error 903 | # dt = time increment between measurements 904 | 905 | # outputs ---------------------------------- 906 | # op = output of the PID controller 907 | # I = integral contribution 908 | 909 | def _PID(self, sp, pv, pv_last, ierr, dt, Kc=10.0, tauI=50.0, tauD=1.0): 910 | # Default Parameters 911 | # Kc = 10.0 # K/%Heater 912 | # tauI = 50.0 # sec 913 | # tauD = 1.0 # sec 914 | 915 | # Parameters in terms of PID coefficients 916 | KP = Kc 917 | if tauI == 0: 918 | KI = 1e5 919 | else: 920 | KI = Kc/tauI 921 | KD = Kc*tauD 922 | 923 | # ubias for controller (initial heater) 924 | op0 = 0 925 | 926 | # upper and lower bounds on heater level 927 | ophi = 100 928 | oplo = 0 929 | 930 | # calculate the error 931 | error = sp-pv 932 | 933 | # calculate the integral error 934 | ierr = ierr + KI * error * dt 935 | 936 | # calculate the measurement derivative 937 | dpv = (pv - pv_last) / dt 938 | 939 | # calculate the PID output 940 | P = KP * error 941 | I = ierr 942 | D = -KD * dpv 943 | op = op0 + P + I + D 944 | 945 | # implement anti-reset windup 946 | if op < oplo or op > ophi: 947 | I = I - KI * error * dt 948 | # clip output 949 | op = max(oplo, min(ophi, op)) 950 | 951 | # return the controller output and PID terms 952 | return [op, I] 953 | 954 | ########################################################################### 955 | # MPC 956 | ########################################################################### 957 | def _MPC(self): 958 | 959 | m = GEKKO(remote=False) 960 | 961 | # 60 second time horizon, 4 sec cycle time, non-uniform 962 | m.time = [0, 4, 8, 12, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90] 963 | 964 | # Parameters 965 | m.U = m.FV(value=10) 966 | m.tau = m.FV(value=5) 967 | m.alpha1 = m.FV(value=0.01) # W / % heater 968 | m.alpha2 = m.FV(value=0.0075) # W / % heater 969 | 970 | # Manipulated variables 971 | m.Q1 = m.MV(value=0) 972 | m.Q1.STATUS = 1 # use to control temperature 973 | m.Q1.FSTATUS = 0 # no feedback measurement 974 | m.Q1.LOWER = 0.0 975 | m.Q1.UPPER = 100.0 976 | m.Q1.DMAX = 20.0 977 | m.Q1.COST = 0.0 978 | m.Q1.DCOST = 2.0 979 | 980 | m.Q2 = m.MV(value=0) 981 | m.Q2.STATUS = 1 # use to control temperature 982 | m.Q2.FSTATUS = 0 # no feedback measurement 983 | m.Q2.LOWER = 0.0 984 | m.Q2.UPPER = 100.0 985 | m.Q2.DMAX = 20.0 986 | m.Q2.COST = 0.0 987 | m.Q2.DCOST = 2.0 988 | 989 | # Controlled variable 990 | m.TC1 = m.CV(value=22) 991 | m.TC1.STATUS = 1 # minimize error with setpoint range 992 | m.TC1.FSTATUS = 1 # receive measurement 993 | m.TC1.TR_INIT = 1 # reference trajectory 994 | m.TC1.TAU = 10 # time constant for response 995 | 996 | # Controlled variable 997 | m.TC2 = m.CV(value=22) 998 | m.TC2.STATUS = 1 # minimize error with setpoint range 999 | m.TC2.FSTATUS = 1 # receive measurement 1000 | m.TC2.TR_INIT = 1 # reference trajectory 1001 | m.TC2.TAU = 10 # time constant for response 1002 | 1003 | # State variables 1004 | m.TH1 = m.SV(value=22) 1005 | m.TH2 = m.SV(value=22) 1006 | 1007 | m.Ta = m.Param(value=23.0+273.15) # K 1008 | m.mass = m.Param(value=4.0/1000.0) # kg 1009 | m.Cp = m.Param(value=0.5*1000.0) # J/kg-K 1010 | m.A = m.Param(value=10.0/100.0**2) # Area not between heaters in m^2 1011 | m.As = m.Param(value=2.0/100.0**2) # Area between heaters in m^2 1012 | m.eps = m.Param(value=0.9) # Emissivity 1013 | m.sigma = m.Const(5.67e-8) # Stefan-Boltzmann 1014 | 1015 | # Heater temperatures 1016 | m.T1i = m.Intermediate(m.TH1+273.15) 1017 | m.T2i = m.Intermediate(m.TH2+273.15) 1018 | 1019 | # Heat transfer between two heaters 1020 | m.Q_C12 = m.Intermediate(m.U*m.As*(m.T2i-m.T1i)) # Conv 1021 | m.Q_R12 = m.Intermediate(m.eps*m.sigma*m.As*(m.T2i**4-m.T1i**4)) # Rad 1022 | 1023 | # Semi-fundamental correlations (energy balances) 1024 | m.Equation(m.TH1.dt() == (1.0/(m.mass*m.Cp)) * 1025 | (m.U*m.A*(m.Ta-m.T1i) + 1026 | m.eps * m.sigma * m.A * 1027 | (m.Ta**4 - m.T1i**4) + m.Q_C12 + 1028 | m.Q_R12 + m.alpha1*m.Q1)) 1029 | 1030 | m.Equation(m.TH2.dt() == (1.0/(m.mass*m.Cp)) * 1031 | (m.U*m.A*(m.Ta-m.T2i) + 1032 | m.eps * m.sigma * m.A * 1033 | (m.Ta**4 - m.T2i**4) - m.Q_C12 - 1034 | m.Q_R12 + m.alpha2*m.Q2)) 1035 | 1036 | # Empirical correlations (lag equations to emulate conduction) 1037 | m.Equation(m.tau * m.TC1.dt() == -m.TC1 + m.TH1) 1038 | m.Equation(m.tau * m.TC2.dt() == -m.TC2 + m.TH2) 1039 | 1040 | # Global Options 1041 | m.options.IMODE = 6 # MPC 1042 | m.options.CV_TYPE = 1 # Objective type 1043 | m.options.NODES = 3 # Collocation nodes 1044 | m.options.SOLVER = 3 # 1=APOPT, 3=IPOPT 1045 | 1046 | return m 1047 | 1048 | ########################################################################### 1049 | # THREADING FUNCTION - OPEN LOOP 1050 | ########################################################################### 1051 | def _work_man(self): 1052 | # Paraters to start each cycle 1053 | Th0 = self._Th0 1054 | Tc0 = self._Tc0 1055 | 1056 | # arrays to store data 1057 | t = np.array([]) 1058 | Q1 = np.array([]) 1059 | Q2 = np.array([]) 1060 | T = np.array([[]]).reshape((0, 2)) 1061 | 1062 | t = np.append(t, np.array([0]), axis=0) 1063 | T = np.append(T, Tc0.reshape((1, 2)), axis=0) 1064 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1065 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1066 | 1067 | while self._flag: 1068 | 1069 | ts = [t[-1], t[-1]+self._delta_t] 1070 | y = odeint(self._heater, Th0, ts, args=(self._Q10, self._Q20)) 1071 | Th0 = y[-1] 1072 | z = odeint(self._sensor, Tc0, ts, args=(Th0[0], Th0[1])) 1073 | Tc0 = z[-1] 1074 | 1075 | # Measurement noise 1076 | Tc_noise = np.array([ 1077 | Tc0[0] + (np.random.rand()-0.5), 1078 | Tc0[1] + (np.random.rand()-0.5) 1079 | ]) 1080 | 1081 | if len(t) >= self._maxtime: 1082 | t = np.delete(t, 0, 0) 1083 | T = np.delete(T, 0, 0) 1084 | Q1 = np.delete(Q1, 0, 0) 1085 | Q2 = np.delete(Q2, 0, 0) 1086 | 1087 | t = np.append(t, np.array([ts[-1]]), axis=0) 1088 | T = np.append(T, Tc_noise.reshape((1, 2)), axis=0) 1089 | Q1 = np.append(Q1, np.array([self._Q10]), axis=0) 1090 | Q2 = np.append(Q2, np.array([self._Q20]), axis=0) 1091 | 1092 | self._T1_meas.x = t/60 1093 | self._T1_meas.y = T[:, 0] - 273.15 1094 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1095 | self._wT1.value = np.round(T[-1, 0]-273.15, 1) 1096 | 1097 | self._T2_meas.x = t/60 1098 | self._T2_meas.y = T[:, 1] - 273.15 1099 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1100 | self._wT2.value = np.round(T[-1, 1]-273.15, 1) 1101 | 1102 | self._u1.x = t/60 1103 | self._u1.y = Q1 1104 | 1105 | self._u2.x = t/60 1106 | self._u2.y = Q2 1107 | 1108 | time.sleep(self._sleep) 1109 | 1110 | ########################################################################### 1111 | # THREADING FUNCTION - ON-OFF 1112 | ########################################################################### 1113 | def _work_on_off(self): 1114 | # Paraters to start each cycle 1115 | Th0 = self._Th0 1116 | Tc0 = self._Tc0 1117 | Q10 = self._Q10 1118 | Q20 = self._Q20 1119 | 1120 | # arrays to store data 1121 | t = np.array([]) 1122 | Q1 = np.array([]) 1123 | Q2 = np.array([]) 1124 | T = np.array([[]]).reshape((0, 2)) 1125 | SP_T1 = np.array([]) 1126 | SP_T2 = np.array([]) 1127 | 1128 | t = np.append(t, np.array([0]), axis=0) 1129 | T = np.append(T, Tc0.reshape((1, 2)), axis=0) 1130 | Q1 = np.append(Q1, np.array([Q10]), axis=0) 1131 | Q2 = np.append(Q2, np.array([Q20]), axis=0) 1132 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1133 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1134 | 1135 | while self._flag: 1136 | 1137 | # apply ON/OFF controller 1138 | # heater 1 1139 | if (Tc0[0]-273.15) < (self._T1_SP - self._q1_dt_on_off): 1140 | Q10 = 100.0 1141 | elif (Tc0[0]-273.15) > (self._T1_SP + self._q1_dt_on_off): 1142 | Q10 = 0.0 1143 | # heater 2 1144 | if (Tc0[1]-273.15) < (self._T2_SP - self._q2_dt_on_off): 1145 | Q20 = 100.0 1146 | elif (Tc0[1]-273.15) > (self._T2_SP + self._q2_dt_on_off): 1147 | Q20 = 0.0 1148 | 1149 | ts = [t[-1], t[-1]+self._delta_t] 1150 | y = odeint(self._heater, Th0, ts, args=(Q10, Q20)) 1151 | Th0 = y[-1] 1152 | z = odeint(self._sensor, Tc0, ts, args=(Th0[0], Th0[1])) 1153 | Tc0 = z[-1] 1154 | 1155 | # Measurement noise 1156 | Tc_noise = np.array([ 1157 | Tc0[0] + (np.random.rand()-0.5), 1158 | Tc0[1] + (np.random.rand()-0.5) 1159 | ]) 1160 | 1161 | if len(t) >= self._maxtime: 1162 | t = np.delete(t, 0, 0) 1163 | T = np.delete(T, 0, 0) 1164 | Q1 = np.delete(Q1, 0, 0) 1165 | Q2 = np.delete(Q2, 0, 0) 1166 | SP_T1 = np.delete(SP_T1, 0, 0) 1167 | SP_T2 = np.delete(SP_T2, 0, 0) 1168 | 1169 | t = np.append(t, np.array([ts[-1]]), axis=0) 1170 | T = np.append(T, Tc_noise.reshape((1, 2)), axis=0) 1171 | Q1 = np.append(Q1, np.array([Q10]), axis=0) 1172 | Q2 = np.append(Q2, np.array([Q20]), axis=0) 1173 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1174 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1175 | 1176 | self._T1_meas.x = t/60 1177 | self._T1_meas.y = T[:, 0] - 273.15 1178 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1179 | 1180 | self._T1_set_point.x = t/60 1181 | self._T1_set_point.y = SP_T1 1182 | 1183 | self._T2_meas.x = t/60 1184 | self._T2_meas.y = T[:, 1] - 273.15 1185 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1186 | 1187 | self._T2_set_point.x = t/60 1188 | self._T2_set_point.y = SP_T2 1189 | 1190 | self._u1.x = t/60 1191 | self._u1.y = Q1 1192 | self._wQ1.value = np.round(Q1[-1], 1) 1193 | 1194 | self._u2.x = t/60 1195 | self._u2.y = Q2 1196 | self._wQ2.value = np.round(Q2[-1], 1) 1197 | 1198 | time.sleep(self._sleep) 1199 | 1200 | ########################################################################### 1201 | # THREADING FUNCTION - PID 1202 | ########################################################################### 1203 | def _work_pid(self): 1204 | # Paraters to start each cycle 1205 | Th0 = self._Th0 1206 | Tc0 = self._Tc0 1207 | Q10 = self._Q10 1208 | Q20 = self._Q20 1209 | 1210 | # arrays to store data 1211 | t = np.array([]) 1212 | Q1 = np.array([]) 1213 | Q2 = np.array([]) 1214 | T = np.array([[]]).reshape((0, 2)) 1215 | SP_T1 = np.array([]) 1216 | SP_T2 = np.array([]) 1217 | 1218 | t = np.append(t, np.array([0]), axis=0) 1219 | T = np.append(T, Tc0.reshape((1, 2)), axis=0) 1220 | Q1 = np.append(Q1, np.array([Q10]), axis=0) 1221 | Q2 = np.append(Q2, np.array([Q20]), axis=0) 1222 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1223 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1224 | 1225 | # Integral error 1226 | ierr1 = 0.0 1227 | ierr2 = 0.0 1228 | 1229 | while self._flag: 1230 | 1231 | ts = [t[-1], t[-1]+self._delta_t] 1232 | y = odeint(self._heater, Th0, ts, args=(Q10, Q20)) 1233 | Th0 = y[-1] 1234 | z = odeint(self._sensor, Tc0, ts, args=(Th0[0], Th0[1])) 1235 | Tc0 = z[-1] 1236 | 1237 | # Measurement noise 1238 | Tc_noise = np.array([ 1239 | Tc0[0] + (np.random.rand()-0.5), 1240 | Tc0[1] + (np.random.rand()-0.5) 1241 | ]) 1242 | 1243 | if len(t) >= self._maxtime: 1244 | t = np.delete(t, 0, 0) 1245 | T = np.delete(T, 0, 0) 1246 | Q1 = np.delete(Q1, 0, 0) 1247 | Q2 = np.delete(Q2, 0, 0) 1248 | SP_T1 = np.delete(SP_T1, 0, 0) 1249 | SP_T2 = np.delete(SP_T2, 0, 0) 1250 | 1251 | t = np.append(t, np.array([ts[-1]]), axis=0) 1252 | T = np.append(T, Tc_noise.reshape((1, 2)), axis=0) 1253 | Q1 = np.append(Q1, np.array([Q10]), axis=0) 1254 | Q2 = np.append(Q2, np.array([Q20]), axis=0) 1255 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1256 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1257 | 1258 | # Calculate PID output 1259 | [Q10, ierr1] = self._PID(self._T1_SP, T[-1, 0]-273.15, 1260 | T[-2, 0]-273.15, ierr1, self._delta_t, 1261 | self._pid1_gain, self._pid1_reset, 1262 | self._pid1_rate) 1263 | [Q20, ierr2] = self._PID(self._T2_SP, T[-1, 1]-273.15, 1264 | T[-2, 1]-273.15, ierr2, self._delta_t, 1265 | self._pid2_gain, self._pid2_reset, 1266 | self._pid2_rate) 1267 | 1268 | self._T1_meas.x = t/60 1269 | self._T1_meas.y = T[:, 0] - 273.15 1270 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1271 | 1272 | self._T1_set_point.x = t/60 1273 | self._T1_set_point.y = SP_T1 1274 | 1275 | self._T2_meas.x = t/60 1276 | self._T2_meas.y = T[:, 1] - 273.15 1277 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1278 | 1279 | self._T2_set_point.x = t/60 1280 | self._T2_set_point.y = SP_T2 1281 | 1282 | self._u1.x = t/60 1283 | self._u1.y = Q1 1284 | self._wQ1.value = np.round(Q1[-1], 1) 1285 | 1286 | self._u2.x = t/60 1287 | self._u2.y = Q2 1288 | self._wQ2.value = np.round(Q2[-1], 1) 1289 | 1290 | time.sleep(self._sleep) 1291 | 1292 | ########################################################################### 1293 | # THREADING FUNCTION - MPC 1294 | ########################################################################### 1295 | def _work_mpc(self): 1296 | Th0 = self._Th0 1297 | Tc0 = self._Tc0 1298 | Q10 = self._Q10 1299 | Q20 = self._Q20 1300 | 1301 | # arrays to store data 1302 | t = np.array([]) 1303 | Q1 = np.array([]) 1304 | Q2 = np.array([]) 1305 | T = np.array([[]]).reshape((0, 2)) 1306 | SP_T1 = np.array([]) 1307 | SP_T2 = np.array([]) 1308 | 1309 | t = np.append(t, np.array([0]), axis=0) 1310 | T = np.append(T, Tc0.reshape((1, 2)), axis=0) 1311 | Q1 = np.append(Q1, np.array([Q10]), axis=0) 1312 | Q2 = np.append(Q2, np.array([Q20]), axis=0) 1313 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1314 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1315 | 1316 | # Create MPC object 1317 | m = self._MPC() 1318 | 1319 | while self._flag: 1320 | # Change SOLVER 1321 | if self._SOLVER == '1 - APOPT': 1322 | m.options.SOLVER = 1 1323 | elif self._SOLVER == '2 - BPOPT': 1324 | m.options.SOLVER = 2 1325 | else: 1326 | m.options.SOLVER = 3 1327 | 1328 | # Change CVTYPE 1329 | if self._CVTYPE == '1 - Deadband': 1330 | m.options.CV_TYPE = 1 1331 | else: 1332 | m.options.CV_TYPE = 2 1333 | 1334 | # Add measurements to the MPC 1335 | m.TC1.MEAS = T[-1, 0] - 273.15 1336 | m.TC2.MEAS = T[-1, 1] - 273.15 1337 | 1338 | # Update Parameters 1339 | m.TC1.TAU = self._T1_tau 1340 | m.TC2.TAU = self._T2_tau 1341 | 1342 | m.Q1.DMAX = self._Q1_DMAX 1343 | m.Q1.DCOST = self._Q1_DCOST 1344 | m.Q2.DMAX = self._Q2_DMAX 1345 | m.Q2.DCOST = self._Q2_DCOST 1346 | 1347 | # Update prediction horizon 1348 | DT = self._delta_t 1349 | m.time = [ 1350 | 0, 1351 | DT, 1352 | DT*2, 1353 | DT*3, 1354 | DT*4, 1355 | DT*5, 1356 | DT*6, 1357 | DT*7, 1358 | DT*8, 1359 | DT*10, 1360 | DT*12, 1361 | DT*15, 1362 | DT*18, 1363 | DT*20, 1364 | DT*25] 1365 | 1366 | if m.options.CV_TYPE == 1: 1367 | # Input setpoint with deadband +/- DT 1368 | DT1 = self._T1_dt 1369 | m.TC1.SPHI = self._T1_SP + DT1 1370 | m.TC1.SPLO = self._T1_SP - DT1 1371 | 1372 | DT2 = self._T2_dt 1373 | m.TC2.SPHI = self._T2_SP + DT2 1374 | m.TC2.SPLO = self._T2_SP - DT2 1375 | else: 1376 | m.TC1.SP = self._T1_SP 1377 | m.TC2.SP = self._T2_SP 1378 | 1379 | try: 1380 | # Solve MPC 1381 | m.solve(disp=False) 1382 | # Check if successful solution 1383 | if (m.options.APPSTATUS == 1): 1384 | # retrieve new value 1385 | Q10 = m.Q1.NEWVAL 1386 | Q20 = m.Q2.NEWVAL 1387 | except: 1388 | # Keep previous value 1389 | pass 1390 | 1391 | ts = [t[-1], t[-1]+self._delta_t] 1392 | y = odeint(self._heater, Th0, ts, args=(Q10, Q20)) 1393 | Th0 = y[-1] 1394 | z = odeint(self._sensor, Tc0, ts, args=(Th0[0], Th0[1])) 1395 | Tc0 = z[-1] 1396 | 1397 | # Measurement noise 1398 | Tc_noise = np.array([ 1399 | Tc0[0] + (np.random.rand()-0.5), 1400 | Tc0[1] + (np.random.rand()-0.5) 1401 | ]) 1402 | 1403 | if len(t) >= self._maxtime: 1404 | t = np.delete(t, 0, 0) 1405 | T = np.delete(T, 0, 0) 1406 | Q1 = np.delete(Q1, 0, 0) 1407 | Q2 = np.delete(Q2, 0, 0) 1408 | SP_T1 = np.delete(SP_T1, 0, 0) 1409 | SP_T2 = np.delete(SP_T2, 0, 0) 1410 | 1411 | t = np.append(t, np.array([ts[-1]]), axis=0) 1412 | T = np.append(T, Tc_noise.reshape((1, 2)), axis=0) 1413 | Q1 = np.append(Q1, np.array([Q10]), axis=0) 1414 | Q2 = np.append(Q2, np.array([Q20]), axis=0) 1415 | SP_T1 = np.append(SP_T1, np.array([self._T1_SP]), axis=0) 1416 | SP_T2 = np.append(SP_T2, np.array([self._T2_SP]), axis=0) 1417 | 1418 | self._T1_meas.x = t/60 1419 | self._T1_meas.y = T[:, 0] - 273.15 1420 | self._PT1.value = np.round(T[-1, 0]-273.15, 1) 1421 | 1422 | self._T1_set_point.x = t/60 1423 | self._T1_set_point.y = SP_T1 1424 | 1425 | self._T2_meas.x = t/60 1426 | self._T2_meas.y = T[:, 1] - 273.15 1427 | self._PT2.value = np.round(T[-1, 1]-273.15, 1) 1428 | 1429 | self._T2_set_point.x = t/60 1430 | self._T2_set_point.y = SP_T2 1431 | 1432 | self._u1.x = t/60 1433 | self._u1.y = Q1 1434 | self._wQ1.value = np.round(Q1[-1], 1) 1435 | 1436 | self._u2.x = t/60 1437 | self._u2.y = Q2 1438 | self._wQ2.value = np.round(Q2[-1], 1) 1439 | 1440 | time.sleep(self._sleep) 1441 | -------------------------------------------------------------------------------- /demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import control_demo as cd\n", 10 | "demo = cd.GUI()" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "metadata": {}, 17 | "outputs": [ 18 | { 19 | "data": { 20 | "application/vnd.jupyter.widget-view+json": { 21 | "model_id": "fe376deb4aa944e2a79a3e6526a61aa4", 22 | "version_major": 2, 23 | "version_minor": 0 24 | }, 25 | "text/plain": [ 26 | "VBox(children=(HBox(children=(Button(button_style='success', description='Start', icon='play', layout=Layout(h…" 27 | ] 28 | }, 29 | "metadata": {}, 30 | "output_type": "display_data" 31 | } 32 | ], 33 | "source": [ 34 | "demo.app()" 35 | ] 36 | }, 37 | { 38 | "cell_type": "code", 39 | "execution_count": 3, 40 | "metadata": {}, 41 | "outputs": [ 42 | { 43 | "data": { 44 | "application/vnd.jupyter.widget-view+json": { 45 | "model_id": "6e0059279efb46d68684abaa273fbabe", 46 | "version_major": 2, 47 | "version_minor": 0 48 | }, 49 | "text/plain": [ 50 | "Tab(children=(VBox(children=(HBox(children=(HTML(value='Δt (s):
…" 51 | ] 52 | }, 53 | "metadata": {}, 54 | "output_type": "display_data" 55 | } 56 | ], 57 | "source": [ 58 | "demo.config()" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [] 67 | } 68 | ], 69 | "metadata": { 70 | "kernelspec": { 71 | "display_name": "Python 3.8.2 64-bit ('3.8.2': pyenv)", 72 | "language": "python", 73 | "name": "python38264bit382pyenvd1da7b4c3c7a4c70823b0e551f70a3b8" 74 | }, 75 | "language_info": { 76 | "codemirror_mode": { 77 | "name": "ipython", 78 | "version": 3 79 | }, 80 | "file_extension": ".py", 81 | "mimetype": "text/x-python", 82 | "name": "python", 83 | "nbconvert_exporter": "python", 84 | "pygments_lexer": "ipython3", 85 | "version": "3.8.2" 86 | } 87 | }, 88 | "nbformat": 4, 89 | "nbformat_minor": 4 90 | } 91 | -------------------------------------------------------------------------------- /img/APP.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evertoncolling/tclab_jupyter/c140653aff32625ec57250d3bb690ffee8651fcd/img/APP.PNG -------------------------------------------------------------------------------- /img/CONFIG.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evertoncolling/tclab_jupyter/c140653aff32625ec57250d3bb690ffee8651fcd/img/CONFIG.PNG -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "tclab_jupyter" 3 | version = "0.1.0" 4 | description = "A jupyter based application to explore different control techniques of a simple temperature plant" 5 | authors = ["Everton Colling