From dd5a824576ec9b5abbf14d21e24330e9cb5c562b Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Wed, 27 Jul 2011 03:47:11 -0400 Subject: [PATCH] Rewrite with a more modular structure. --- .gitignore | 1 + COPYING | 674 ++++++++++++++++++++++++++++++++ Makefile | 31 -- README | 188 +++++++++ examples/temp_monitor.py | 45 +++ setup.py | 35 ++ tempcontrol/__init__.py | 51 +++ tempcontrol/backend/__init__.py | 197 ++++++++++ tempcontrol/backend/melcor.py | 655 +++++++++++++++++++++++++++++++ tempcontrol/backend/test.py | 240 ++++++++++++ tempcontrol/controller.py | 512 ++++++++++++++++++++++++ tempcontrol/test.py | 139 +++++++ temperature.py | 646 ------------------------------ 13 files changed, 2737 insertions(+), 677 deletions(-) create mode 100644 .gitignore create mode 100644 COPYING delete mode 100644 Makefile create mode 100644 README create mode 100644 examples/temp_monitor.py create mode 100644 setup.py create mode 100644 tempcontrol/__init__.py create mode 100644 tempcontrol/backend/__init__.py create mode 100644 tempcontrol/backend/melcor.py create mode 100644 tempcontrol/backend/test.py create mode 100644 tempcontrol/controller.py create mode 100644 tempcontrol/test.py delete mode 100644 temperature.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile deleted file mode 100644 index 3ac1df8..0000000 --- a/Makefile +++ /dev/null @@ -1,31 +0,0 @@ -PROG_NAME = temperature -VERSION = 0.1 -PYTHON_SCRIPTS = temperature.py -OTHER_FILES = README Makefile -DIST_FILES = $(PYTHON_SCRIPTS) $(OTHER_FILES) -DIST_DIR = $(PROG_NAME)-$(VERSION) -DIST_NAME = $(PROG_NAME)-$(VERSION).tar.gz - -PYTHON_DIR = $(HOME)/.python - -GENERATED_FILES = $(DIST_NAME) - -all : # all scripts, nothing to compile - -clean : - rm -f $(GENERATED_FILES) - -install : uninstall - cp $(PYTHON_SCRIPTS) $(PYTHON_DIR) - -uninstall : - rm -f $(PYTHON_SCRIPTS:%=$(PYTHON_DIR)/%) - -dist : - mkdir $(DIST_DIR) - cp $(DIST_FILES) $(DIST_DIR) - tar -chozf $(DIST_NAME) $(DIST_DIR) - rm -rf $(DIST_DIR) - -check : - python ./temperature.py diff --git a/README b/README new file mode 100644 index 0000000..2c52e9f --- /dev/null +++ b/README @@ -0,0 +1,188 @@ +This package provides an object-oriented interface for temperature +monitoring and PID_ control. The idea is that experimentalists +interested in temperature controlled experiments should not need to +learn the inner workings of their PID controller before they can +perform simple temperature control tasks. + +Module structure +================ + +This package provides both a high level controller that uses +per-device backend drivers. The controller handles auto-tuning the +PID feedback parameters and changing system temperatures, while the +backends communicate setpoint changes, temperature read requests, +etc. to the temperature control device. + +Backends +-------- + +The only physicsal backend that is supported at the moment is a Melcor +Series MTCA Thermoelectric Cooler Controller, which we communicate +with via Modbus_ packets over a `serial port`_. That's all I needed +for my experiments, but I tried to write a framework that was flexible +enough to swap in other backends. By subclassing `Backend` for your +particular device, you can take advantage of the high-level +`Controller` code that's already written. + +Melcor +~~~~~~ + +Companies don't stay in business forever, but lab equipment does ;). +Our controller is still going strong since 1999, but Melcor has moved +around. According to their `2005 announcement`__ the Laird Group PLC +purchased Melcor from Fedders Corporation, and by 2009 (according to +the `Internet Archive Wayback Machine`__) they phased out the old +website at `melcor.com `_ in favor of `their own +thermal site`__, and it looks like there is no longer support for the +older MTCA controllers. There seem to be a number of them on eBay_ +though ;). + +__ `Laird announcement`_ +__ wayback_ +__ `Laird thermal`_ + +TestBackend +~~~~~~~~~~~ + +To get a feel for driving a PID system, check out the `TestBackend`. +For example, you can experiment with different feedback terms and dead +times to understand why you're getting instability or other control +effects. Here's an example that shows a reasonable approach with a +bit of integrator overshoot:: + + >>> from tempcontrol.backend.test import TestBackend + >>> from time import sleep + >>> from matplotlib import pyplot + >>> from numpy import loadtxt + >>> log_file = 'pid.log' + >>> log_stream = open('pid.log', 'w') + >>> b = TestBackend(log_stream=log_stream) + >>> b.set_max_current(0.6) + >>> b.set_heating_gains(propband=2, integral=.1) + >>> b.set_cooling_gains(propband=2, integral=.1) + >>> b.set_setpoint(25) + >>> sleep(120) + >>> t.cleanup() + >>> log_stream.close() + >>> header = open(log_file, 'r').readline() + >>> label = header.strip('#\n').split('\t') + >>> data = loadtxt('pid.log') + >>> pyplot.hold(True) + >>> for i in range(1, len(label)): + ... if i in [1, 3, 5]: + ... if i: + ... pyplot.legend(loc='best') # add legend to previous subplot + ... pyplot.subplot(3, 1, (i-1)/2 + 1) + ... pyplot.plot(data[:,0], data[:,i], '.', label=label[i]) + >>> pyplot.legend(loc='best') + >>> pyplot.show() + +Of course, you can use whatever plotting program you like to graph the +values stored to `pid.log`. Matplotlib_ and NumPy_ are just +convenient Python-based packages. + +Installation +============ + +Packages +-------- + +Gentoo +~~~~~~ + +I've packaged `tempcontrol` for Gentoo_. You need layman_ and my `wtk +overlay`_. Install with:: + + # emerge -av app-portage/layman + # layman --add wtk + # emerge -av dev-python/tempcontrol + +Dependencies +------------ + +If you're installing by hand or packaging `tempcontrol` for another +distribution, you'll need the following dependencies: + +========= ===================== ================ ========================== +Package Purpose Debian_ Gentoo_ +========= ===================== ================ ========================== +pymodbus_ Modbus stack python-modbus dev-python/twisted +pySerial_ serial comminication python-serial dev-python/pyserial +nose_ testing python-nose dev-python/nose +========= ===================== ================ ========================== + +Actually, `pymodbus` may (depending on your packaging system) depend +on `pySerial`_ via Twisted_, so `pymodbus` alone may be enough to get +you going. + +The Debian package for `pymodbus` has not been accepted yet. `Debian +bug #578120`__ tracks the progress of the prospective package, but it +seems to have stalled out at the moment. + +__ db578120_ + +Installing by hand +------------------ + +Tempcontrol is available as a Git_ repository:: + + $ git clone http://www.physics.drexel.edu/~wking/code/git/tempcontrol.git + +See the homepage_ for details. To install the checkout, run the +standard:: + + $ python setup.py install + +Usage +===== + +See the examples in the `examples` directory. + +Testing +======= + +Run the test suite with:: + + $ nosetests --with-doctest --doctest-tests tempcontrol + +Note that you should have your temperature control device connected to +your computer before running this command, as backend tests require a +connected backend. + +Licence +======= + +This project is distributed under the `GNU General Public License +Version 3`_ or greater. + +Author +====== + +W. Trevor King +wking@drexel.edu +Copyright 2008-2011 + + +.. _PID: http://en.wikipedia.org/wiki/PID_controller +.. _Modbus: http://en.wikipedia.org/wiki/Modbus +.. _serial port: http://en.wikipedia.org/wiki/Serial_port +.. _Matplotlib: http://matplotlib.sourceforge.net/ +.. _NumPy: http://numpy.scipy.org/ +.. _Laird announcement: http://www.lairdtech.com/NewsItem.aspx?id=953 +.. _wayback: http://web.archive.org/web/20090204201524/http://melcor.com/ +.. _Laird thermal: http://lairdtech.thomasnet.com/category/thermal-management-solutions/ +.. _eBay: http://www.ebay.com/ +.. _layman: http://layman.sourceforge.net/ +.. _wtk overlay: + http://www.physics.drexel.edu/~wking/unfolding-disasters/posts/Gentoo_overlay +.. _Debian: http://www.debian.org/ +.. _Gentoo: http://www.gentoo.org/ +.. _pymodbus: http://code.google.com/p/pymodbus/ +.. _pySerial: http://pyserial.sourceforge.net/ +.. _Twisted: http://twistedmatrix.com/trac/ +.. _db578120: http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=578120 +.. _nose: http://somethingaboutorange.com/mrl/projects/nose/ +.. _Git: http://git-scm.com/ +.. _homepage: + http://www.physics.drexel.edu/~wking/unfolding-disasters/posts/tempcontrol/ +.. _GNU General Public License Version 3: http://www.gnu.org/licenses/gpl.txt diff --git a/examples/temp_monitor.py b/examples/temp_monitor.py new file mode 100644 index 0000000..f6b075f --- /dev/null +++ b/examples/temp_monitor.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# Copyright (C) 2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +"""Log control and ambient temperature every 10 seconds. + +usage: python temp_monitor.py +""" + +import time + +from tempcontrol.backend import get_backend + + +b = get_backend('melcor')() +period = 10 + +with open('temp_monitor.log', 'a') as f: + last = time.time() + last -= last % period + next_time = last + period + while True: + time.sleep(next_time - time.time()) + tstr = time.strftime('%Y-%m-%d %H:%M:%S') + temp = str(b.get_temp()) + ambient = str(b.get_ambient_temp()) + f.write('\t'.join([tstr, temp, ambient]) + '\n') + f.flush() + print('\t'.join([tstr, temp, ambient])) + next_time += period diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..93c9671 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +"""A modular temperature control library. +""" + +from distutils.core import setup +import os.path + +from tempcontrol import __version__ + + +_this_dir = os.path.dirname(__file__) +base_url = 'http://physics.drexel.edu/~wking' + +setup(name='tempcontrol', + version=__version__, + maintainer='W. Trevor King', + maintainer_email='wking@drexel.edu', + url = '{}/unfolding-disasters/posts/tempcontrol'.format(base_url), + download_url = '{}/code/python/tempcontrol-{}.tar.gz'.format( + base_url, __version__), + license = 'GNU General Public License (GPL)', + platforms = ['all'], + description = __doc__, + long_description = open(os.path.join(_this_dir, 'README'), 'r').read(), + packages=['tempcontrol', 'tempcontrol.backend'], + classifiers = [ + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'Intended Audience :: Science/Research', + 'Operating System :: OS Independent', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Programming Language :: Python', + 'Topic :: Scientific/Engineering', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], + ) diff --git a/tempcontrol/__init__.py b/tempcontrol/__init__.py new file mode 100644 index 0000000..3391c48 --- /dev/null +++ b/tempcontrol/__init__.py @@ -0,0 +1,51 @@ +# Copyright (C) 2008-2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +import logging as _logging + + +__version__ = '0.3' + + +LOG = _logging.getLogger('tempcontrol') +"Temperature-control logger" + +#LOG.setLevel(_logging.WARN) +LOG.setLevel(_logging.DEBUG) +_formatter = _logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +_stream_handler = _logging.StreamHandler() +_stream_handler.setLevel(_logging.DEBUG) +_stream_handler.setFormatter(_formatter) +LOG.addHandler(_stream_handler) + +_syslog_handler = None + + +def _set_handler(name='stream'): + if name == 'syslog': + if not _syslog_handler: + _syslog_handler = _logging_handlers.SysLogHandler() + _syslog_handler.setLevel(_logging.DEBUG) + LOG.handlers = [_syslog_handler] + elif name == 'stream': + LOG.handlers = [_stream_handler] + else: + raise ValueError(name) + LOG.info('setup logging handler: %s' % name) diff --git a/tempcontrol/backend/__init__.py b/tempcontrol/backend/__init__.py new file mode 100644 index 0000000..36afd63 --- /dev/null +++ b/tempcontrol/backend/__init__.py @@ -0,0 +1,197 @@ +# Copyright (C) 2008-2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +"""Assorted backends for interfacing with your particular hardware. +""" + + +def _import_by_name(modname): + """ + >>> mod = _import_by_name('tempcontrol.backend.melcor') + >>> 'MelcorBackend' in dir(mod) + True + >>> _import_by_name('tempcontrol.backend.highly_unlikely') + Traceback (most recent call last): + ... + ImportError: No module named highly_unlikely + """ + module = __import__(modname) + components = modname.split('.') + for comp in components[1:]: + module = getattr(module, comp) + return module + +def get_backend(name): + n = '%s.%s' % (__name__, name) + mod = _import_by_name(n) + for attr in dir(mod): + obj = getattr(mod, attr) + try: + if obj != Backend and issubclass(obj, Backend): + return obj + except TypeError: + pass + raise ValueError(name) + + +class Backend (object): + """Temperature control backend + + There are several common forms for a PID control formula. For the + purpose of setting heating and cooling gains (`.get_*_gains()` and + `.set_*_gains()`), we'll use the standard form:: + + MV(t) = K_p ( e(t) + 1/T_i \int_0^t e(\tau) d\tau + T_d de(t)/dt ) + + where `e(t) = SP - PV` is the error function, MV is the + manipulated variable, SP is the setpoint, and PV is the process + variable. + + In this formulation, the parameter units will be: + + * K_p: MV units / PV units (e.g. amp/K) + * T_i, T_d: time (e.g. seconds) + """ + def __init__(self): + self._max_current = None + + @staticmethod + def _convert_F_to_C(F): + return (F - 32)/1.8 + + @staticmethod + def _convert_C_to_F(C): + return C*1.8 + 32 + + def cleanup(self): + "Release resources and disconnect from any hardware." + pass + + def get_temp(self): + "Return the current process temperature in degrees Celsius" + raise NotImplementedError() + + def get_ambient_temp(self): + "Return room temperature in degrees Celsius" + raise NotImplementedError() + + def set_max_current(self, max): + "Set the max current in Amps" + raise NotImplementedError() + + def get_max_current(self): + "Get the max current in Amps" + raise NotImplementedError() + + def get_current(self): + """Return the calculated control current in Amps" + + The returned current is not the actual current, but the + current that the temperature controller calculates it should + generate. If the voltage required to generate that current + exceeds the controller's max voltage (15 V on mine), then the + physical current will be less than the value returned here. + """ + raise NotImplementedError() + + def get_modes(self): + "Return a list of control modes supported by this backend" + raise NotImplementedError() + + def get_mode(self): + "Return the current control mode" + raise NotImplementedError() + + def set_mode(self, mode): + "Set the current control mode" + raise NotImplementedError + + def dump_configuration(self): + """ + """ + raise NotImplementedError() + + def restore_configuration(self): + """ + """ + raise NotImplementedError() + + +class ManualMixin (object): + def set_current(self, current): + """Set the desired control current in Amps + """ + raise NotImplementedError() + + +class PIDMixin (object): + def set_setpoint(self, setpoint): + "Set the temperature setpoint in degrees Celsius" + raise NotImplementedError() + + def get_setpoint(self, setpoint): + "Get the temperature setpoint in degrees Celsius" + raise NotImplementedError() + + def get_cooling_gains(self): + """...""" + raise NotImplementedError() + + def set_cooling_gains(self, proportional=None, integral=None, + derivative=None): + """ + ... + """ + raise NotImplementedError() + + def get_heating_gains(self): + """...""" + raise NotImplementedError() + + def set_heating_gains(self, proportional=None, integral=None, + derivative=None): + """ + ... + """ + raise NotImplementedError() + + def get_feedback_terms(self): + """Experimental + """ + raise NotImplementedError() + + def clear_integral_term(self): + """Reset the integral feedback turn (removing integrator windup) + + Because the proportional term provides no control signal when + the system exactly matches the setpoint, a P-only algorithm + will tend to "droop" off the setpoint. The equlibrium + position is one where the droop-generated P term balances the + systems temperature leakage. To correct for this, we add the + integral feedback term, which adjusts the control signal to + minimize long-term differences between the output and setpoint. + + One issue with the integral term is "integral windup". When + the signal spends a significant time away from the setpoint + (e.g. during a long ramp up to operating temperature), the + integral term can grow very large, causing overshoot once the + output reaches the setpoint. To allow our controller to avoid + this, this method manually clears the intergal term for the + backend. + """ + raise NotImplementedError() diff --git a/tempcontrol/backend/melcor.py b/tempcontrol/backend/melcor.py new file mode 100644 index 0000000..2f4f634 --- /dev/null +++ b/tempcontrol/backend/melcor.py @@ -0,0 +1,655 @@ +# Copyright (C) 2008-2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +import struct as _struct + +import serial as _serial + +from pymodbus.client.sync import ModbusSerialClient as _ModbusSerialClient + +from .. import LOG as _LOG +from . import Backend as _Backend +from . import ManualMixin as _ManualMixin +from . import PIDMixin as _PIDMixin + + +class Register (object): + def __init__(self, name, value, direction='rw', reference=None, help=None): + self.name = name + self.value = value + self.direction = direction + self.reference = reference + self.help = help + self.needs_decimal = False + + def __str__(self): + return '<%s %s (%d)>' % (self.__class__.__name__, self.name, self.value) + + def encode(self, value, **kwargs): + return value + + def decode(self, value, **kwargs): + return value + + +class ChoiceRegister (Register): + def __init__(self, *args, **kwargs): + self.choices = kwargs.pop('choices') + super(ChoiceRegister, self).__init__(*args, **kwargs) + + def encode(self, value, **kwargs): + for key,v in self.choices.items(): + if v == value: + return key + raise ValueError(value) + + def decode(self, value, **kwargs): + try: + return self.choices[value] + except KeyError: + _LOG.error('unrecognized value %s for %s' % (value, self.name)) + + +class FloatRegister (Register): + def __init__(self, *args, **kwargs): + self.decimal = kwargs.pop('decimal', None) + self.decimal_offset = kwargs.pop('decimal_offset', None) + super(FloatRegister, self).__init__(*args, **kwargs) + self.needs_decimal = not self.decimal + + @staticmethod + def _float2melcor(float, decimal=None): + """Convert a Python float into Melcor's two's-compliment representation + + >>> m = FloatRegister._float2melcor(-3.5, decimal=10.0) + >>> m + 65501 + >>> FloatRegister._melcor2float(m, decimal=10.0) + -3.5 + """ + return _struct.unpack('H', _struct.pack('h', int(float * decimal)))[0] + + @staticmethod + def _melcor2float(melcor, decimal=None): + """Convert Melcor's two's compliment representation to a Python float + + >>> FloatRegister._melcor2float(65501, decimal=10.0) + -3.5 + """ + return _struct.unpack('h', _struct.pack('H', melcor))[0] / decimal + + def encode(self, value, **kwargs): + if self.decimal: + decimal = self.decimal + elif self.decimal_offset: + decimal *= self.decimal_offset + return self._float2melcor(value, decimal) + + def decode(self, value, decimal=None): + if self.decimal: + decimal = self.decimal + elif self.decimal_offset: + decimal *= self.decimal_offset + return self._melcor2float(value, decimal) + + +class BoundedFloatRegister (FloatRegister): + def __init__(self, *args, **kwargs): + self.min = kwargs.pop('min', None) + self.max = kwargs.pop('max', None) + super(BoundedFloatRegister, self).__init__(*args, **kwargs) + + def encode(self, value, **kwargs): + if value < self.min or value > self.max: + raise ValueError('{} out of range [{}, {}] for {}'.format( + value, self.min, self.max, self)) + return super(BoundedFloatRegister, self).encode(value, **kwargs) + + def decode(self, value, **kwargs): + return super(BoundedFloatRegister, self).decode(value, **kwargs) + + +class MelcorBackend (_Backend, _ManualMixin, _PIDMixin): + """Temperature control backend for a Melcor MTCA Temperature Controller + """ + # Relative register addresses from back page of Melcor Manual. + # Then I went through Chapter 6 tables looking for missing + # registers. References are from Series MTCA Thermoelectric + # Cooler Controller Instruction Manual, Revision 5.121900. + _custom_prompt_kwargs = { + 'reference': '5.2, 6.20', + 'help': 'Setup a custom menu', + 'choices': { + 0: 'none', + 1: 'process 2', + 2: 'percent output', + 3: 'ramping set point', + 4: 'event input status', + 5: 'operation mode', + 6: 'auto-tune', + 7: 'auto-tune set point', + 8: 'set point 2', + 9: 'event set point', + 10: 'local or remote calibration mode', + 11: 'calibration offset', + 12: 'propband 1', + 13: 'integral 1', + 14: 'derivative 1', + 15: 'reset 1', + 16: 'rate 1', + 17: 'cycle time 1', + 18: 'dead band 1', + 19: 'propband 2', + 20: 'integral 2', + 21: 'derivative 2', + 22: 'reset 2', + 23: 'rate 2', + 24: 'cycle time 2', + 25: 'dead band 2', + 26: 'alarm 2 high', + 27: 'alarm 2 low', + 28: 'alarm 3 high', + 29: 'alarm 3 low', + 30: 'alarm 4 high', + 31: 'alarm 4 low', + 32: 'proportional term', + 33: 'integral term', + 34: 'derivative term', + 35: 'hysteresis 1', + 36: 'hysteresis 2', + 37: 'alarm hysteresis 2', + 38: 'alarm hysteresis 3', + 39: 'alarm hysteresis 4', + 40: 'set point 1', + }, + } + _registers = [ + Register('MODEL_NUMBER', 0, direction='r', reference='6.22'), + Register('SERIAL_NUMBER_1', 1, direction='r', reference='6.22', help='first 4 digits'), + Register('SERIAL_NUMBER_2', 2, direction='r', reference='6.22', help='last 4 digits'), + Register('SOFTWARE_ID_NUMBER', 3, direction='r', reference='6.22'), + Register('SOFTWARE_REVISION', 4, direction='r', reference='6.22'), + Register('DATE_OF_MANUFACTURE', 5, direction='r', reference='6.22', help='WEEK:YEAR (WWYY)'), + ChoiceRegister('INPUT_2_HARDWARE_ENABLED', 9, direction='r', reference='1.2, 6.22', choices={ + 0: 'none', 5: 'process event'}, help='INPUT_2 option installed'), + ChoiceRegister('OUTPUT_1_HARDWARE', 16, direction='r', reference='6.23', choices={ + 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}), + ChoiceRegister('OUTPUT_2_HARDWARE', 17, direction='r', reference='6.23', choices={ + 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}), + ChoiceRegister('OUTPUT_3_HARDWARE', 18, direction='r', reference='6.23', choices={ + 0: 'none', 1: 'relay'}), + ChoiceRegister('OUTPUT_4_HARDWARE', 19, direction='r', reference='5.9, 6.23', choices={ + 0: 'none', 1: 'relay', 4: 'process', 6: '485', 7: '232'}, + help='Retransmit option installed'), + Register('DISABLE_NONVOLATILE_MEM', 24, reference=''), + FloatRegister('PROCESS_1', 100, direction='r', reference='6.3', help='Current temp (input to INPUT_1) (mdbl)'), + Register('ERROR_1', 101, reference=''), + Register('PERCENT_OUTPUT', 103, direction='r', reference='5.4, 6.4', help="% of controller's rated maximum power/current"), + Register('ACTUAL_2', 104, reference=''), + FloatRegister('PROCESS_2', 105, direction='r', reference='6.4', help='Value of signal input to INPUT_2'), + Register('ALARM_2_STATUS', 106, reference=''), + Register('ALARM_3_STATUS', 110, reference=''), + Register('ALARM_4_STATUS', 114, reference=''), + Register('OPERATION_MODE', 200, reference='?'), + ChoiceRegister('EVENT_INPUT_STATUS', 201, direction='r', reference='6.4', choices={ + 1:True, 0:False}, help='Whether EVENT_FUNCTION satisfies EVENT_CONDITION'), + FloatRegister('REMOTE_SET_POINT', 202, direction='r', reference='6.3', help='Or event set point'), + Register('RAMPING_SET_POINT', 203, direction='r', reference='6.4', help='Active if RAMPING_MODE not set to OFF'), + # NOTE: sometimes the *_TERM_1 registers blib to 10x the predicted value. I don't know why yet... + FloatRegister('PID_POWER_1', 204, reference='Not in manual', help='Calculated output current %, active when Factory->Diagnostic->Troubleshooting == 1, but no modbus register for Troubleshooting (6.24).', decimal=10.), + FloatRegister('PROP_TERM_1', 205, reference='Not in manual', help='(Tset-Tcur)/Tprop see temperature.tempControl.getFeedbackTerms(), active when Troubleshooting == 1.', decimal=1.), + FloatRegister('INTEGRAL_TERM_1', 206, reference='', decimal=1.), + FloatRegister('DERIVATIVE_TERM_1', 207, reference='', decimal=1.), + Register('SYSTEM_ERROR', 209, reference=''), + Register('OPEN_LOOP_ERROR', 210, reference=''), + FloatRegister('SET_POINT_1', 300, reference='5.7 6.3', help='Set-point for INPUT_1'), + ChoiceRegister('AUTO_MANUAL_OP_MODE', 301, direction='r', reference='6.4', help='Select control mode', choices={0: 'PID', 1: 'manual'}), + Register('AUTO_TUNE_SETPOINT', 304, reference='6.5', help='Set auto tune setpoint as % of current set point (default 90%)'), + ChoiceRegister('AUTO_TUNE_START_1', 305, reference='6.5', help='Initiate or cancel auto-tune. Active if AUTO_MANUAL_OP_MODE is Auto (PID)', choices = {0: 'off or cancel', 1: 'initiate', 2: 'set only PID 1', 3: 'set only PID2'}), + FloatRegister('EVENT_SET_POINT_1', 306, reference='6.2', decimal=1.), + FloatRegister('BOOST_SET_POINT_1', 309, reference='1.2', help='Optional, on back plate'), + Register('MANUAL_SET_POINT', 310, reference='6.3', help='If AUTO_MANUAL_OP_MODE is MANUAL (manual)'), + Register('CLEAR_INPUT_ERRORS', 311, reference=''), + ChoiceRegister('LOCAL_REMOTE_1', 316, reference='5.9, 6.5', choices={ + 0: 'local', 1: 'remote'}, help='Selects active setpoint. Active if INPUT_2 is not OFF or EVENT'), + FloatRegister('SET_POINT_2', 319, reference='6.5', help='?boost setpoint? Active if both output 1 and output 2 are set to HEAT, or both are set to COOL, or if INPUT_2 is set to EVENT and EVENT_FUNCTION to SP'), + FloatRegister('ALARM_2_LOW', 321, reference='5.18, 6.2, 6.8'), + FloatRegister('ALARM_2_HIGH', 322, reference='5.18, 6.2, 6.8'), + Register('CLEAR_ALARMS', 331, reference=''), + Register('SILENCE_ALARMS', 332, reference=''), + FloatRegister('ALARM_3_LOW', 340, reference='5.18, 6.2, 6.9'), + FloatRegister('ALARM_3_HIGH', 341, reference='5.18, 6.2, 6.9'), + BoundedFloatRegister('PROPBAND_1', 500, reference='6.2, 6.5', help='Width of proportional band in PID control(mdbl)', min=0, max=9999), + BoundedFloatRegister('INTEGRAL_1', 501, reference='6.6', help='Set integral time in minutes for output 1', decimal=100., min=0, max=99.99), + BoundedFloatRegister('RESET_1', 502, reference='6.6', help='Set reset time in repeats per minute for output 1 if UNITS_TYPE set to US', decimal=100., min=0, max=99.99), + BoundedFloatRegister('DERIVATIVE_1', 503, reference='6.6', help='Set derivative time in minutes', decimal=100., min=0, max=9.99), + BoundedFloatRegister('RATE_1', 504, reference='6.6', decimal=100., min=0, max=9.99), + BoundedFloatRegister('DEAD_BAND_1', 505, reference='6.2, 6.7', min=0, max=9999), + FloatRegister('CYCLE_TIME_1', 506, reference='6.6', help='Valid range depends on output type. Relay: 5.0 to 60.0, solid state: 0.1 to 60.0. Not worth the extra call to automate this check.', decimal=10.), + BoundedFloatRegister('HYSTERESIS_1', 507, reference='6.2, 6.6', min=1, max=9999), + ChoiceRegister('BURST_1', 509, reference='5.16, 6.6', choices={ + 0: 'no', 1: 'yes'}), + BoundedFloatRegister('PROPBAND_2', 510, reference='6.2, 6.7', min=0, max=9999), + BoundedFloatRegister('INTEGRAL_2', 511, reference='6.7', decimal=100., min=0, max=99.99), + BoundedFloatRegister('RESET_2', 512, reference='6.7', decimal=100., min=0, max=99.99), + BoundedFloatRegister('DERIVATIVE_2', 513, reference='6.7', decimal=100., min=0, max=9.99), + BoundedFloatRegister('RATE_2', 514, reference='6.7', decimal=100., min=0, max=9.99), + BoundedFloatRegister('DEAD_BAND_2', 515, reference='6.2, 6.8', min=0, max=9999), + FloatRegister('CYCLE_TIME_2', 516, reference='6.8', help='Valid range depends on output type. Relay: 5.0 to 60.0, solid state: 0.1 to 60.0. Not worth the extra call to automate this check.', decimal=10.), + BoundedFloatRegister('HYSTERESIS_2', 517, reference='6.2, 6.8', min=1, max=9999), + ChoiceRegister('BURST_2', 519, reference='5.16, 6.7', choices={ + 0: 'no', 1: 'yes'}), + Register('SENSOR_TYPE_1', 600, reference='5.7', help='Sensor used for INPUT_1'), + Register('INPUT_1', 601, reference='5.7', help='Temperature measurement'), + FloatRegister('RANGE_LOW_1', 602, reference='5.7, 6.2, 6.11', help='Minimum SET_POINT_1'), + FloatRegister('RANGE_HIGH_1', 603, reference='5.7, 6.2, 6.11', help='Maximum SET_POINT_1'), + BoundedFloatRegister('INPUT_SOFTWARE_FILTER_1', 604, reference='5.6, 6.2, 6.11, ', help='Averaging to smooth INPUT_1 (positive only affect monitor values, negative affect both monitor and control)', decimal=10., min=-60, max=60), + FloatRegister('CALIBRATION_OFFSET_1', 605, reference='5.5, 6.2, 6.5', help='Offset added to INPUT_1'), + ChoiceRegister('DECIMAL_1', 606, reference='6.11', choices={ + 0: 1., 1: 10., 2: 1., 3: 10., 4: 100., 5: 1000.}), + ChoiceRegister('INPUT_ERROR_LATCHING', 607, reference='6.18', choices={ + 0: 'latching', 1: 'no latching'}), + ChoiceRegister('INPUT_2', 611, reference='5.8, 6.11', choices={ + 0: 'off', 1: 'event', 2: '4-20mA', 3: '0-20mA', 4: '0-5V dc', 5: '1-5V dc', 6: '0-10V dc'}, + help='For external control'), + FloatRegister('RANGE_LOW_2', 612, reference='5.9, 6.2, 6.12', help='Minimum INPUT_2 signal'), + FloatRegister('RANGE_HIGH_2', 613, reference='5.9, 6.2, 6.12', help='Maximum INPUT_2 signal'), + FloatRegister('CALIBRATION_OFFSET_2', 615, reference='5.5,, 6.2, 6.12', help='Offset added to INPUT_2'), + ChoiceRegister('OUTPUT_1', 700, reference='6.13', choices={ + 0: 'heat', 1: 'cool'}), + ChoiceRegister('PROCESS_1_TYPE', 701, reference='6.13', choices={ + 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'}), + Register('HIGH_LIMIT_SET_POINT', 702, reference=''), + FloatRegister('POWER_LIMIT_SET_POINT', 713, reference='5.4, 6.2, 6.19', help='Temperature set point for power limits'), + FloatRegister('HIGH_POWER_LIMIT_ABOVE', 714, reference='5.4', help='% limit when above PLSP'), + FloatRegister('HIGH_POWER_LIMIT_BELOW', 715, reference='5.4', help='% limit when below PLSP'), + ChoiceRegister('OUTPUT_2', 717, reference='6.13', choices={ + 0: 'off', 1: 'heat', 2: 'cool', 3: 'alarm'}), + ChoiceRegister('PROCESS_2_TYPE', 718, reference='6.13', choices={ + 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'}, + help='The manual claims: (0: 4-20mA, 1: 0-20mA, 2: 0-10V dc, 3: 0-5V dc, 4: 1-5V dc), but I think it has the same sttings as PROCESS_1_TYPE, because that matches the results I expect when setting PROCESS_2_TYPE from software while watching the relevant display menu'), + ChoiceRegister('ALARM_2_TYPE', 719, reference='5.19, 6.13', choices={ + 0: 'process', 1: 'deviation'}, help='Select alarm type. A process alarm responds when the temperature leaves a fixed range. A deviation alarm responds when the temperature deviates from the set point by a set number of degrees'), + FloatRegister('ALARM_HYSTERESIS_2', 720, reference='5.18, 6.2, 6.13', help='Set the switching histeresis for the alarm output. This defines a band on the inside of the alarm set point. When the process temperature is in this band, the alarm state will not change.'), + ChoiceRegister('LATCHING_2', 721, reference='5.19, 6.14', choices={ + 0: 'no', 1: 'yes'}), + ChoiceRegister('SILENCING_2', 722, reference='5.20, 6.14', choices={ + 0: 'no', 1: 'yes'}), + ChoiceRegister('ALARM_ACTIVE_SIDES_2' , 723, reference='6.14', choices={ + 0: 'both', 1: 'high', 2: 'low'}, + help='Select which side or sides the alarm setpoints can be programmed for'), + ChoiceRegister('ALARM_LOGIC_2', 724, reference='6.14', choices={ + 0: 'de-energize', 1: 'energize'}, + help='Select alarm 2 output condition in the alarm state. De-energizing is the failsafe behaviour.'), + ChoiceRegister('ALARM_ANNUNCIATION_2', 725, reference='6.14', choices={ + 0: 'no', 1: 'yes'}), + ChoiceRegister('OUTPUT_3', 734, reference='6.15', choices={ + 0: 'off', 1: 'alarm'}), + ChoiceRegister('ALARM_3_TYPE', 736, reference='5.19, 6.15', choices={ + 0: 'process', 1: 'deviation'}, help='Select alarm type. A process alarm responds when the temperature leaves a fixed range. A deviation alarm responds when the temperature deviates from the set point by a set number of degrees'), + FloatRegister('ALARM_HYSTERESIS_3', 737, reference='5.18, 6.2, 6.15', help='Set the switching histeresis for the alarm output. This defines a band on the inside of the alarm set point. When the process temperature is in this band, the alarm state will not change.'), + ChoiceRegister('LATCHING_3', 738, reference='5.19, 6.15', choices={ + 0: 'no', 1: 'yes'}), + ChoiceRegister('SILENCING_3', 739, reference='5.20, 6.15', choices={ + 0: 'no', 1: 'yes'}), + ChoiceRegister('ALARM_ACTIVE_SIDES_3', 740, reference='6.15', choices={ + 0: 'both', 1: 'high', 3: 'low'}, + help='Select which side or sides the alarm setpoints can be programmed for'), + ChoiceRegister('ALARM_LOGIC_3', 741, reference='6.16', choices={ + 0: 'de-energize', 1: 'energize'}, + help='Select alarm 3 output condition in the alarm state. De-energizing is the failsafe behaviour.'), + ChoiceRegister('ALARM_ANNUNCIATION_2', 742, reference='6.16', choices={ + 0: 'no', 1: 'yes'}), + ChoiceRegister('UNITS_TYPE', 900, reference='6.18', choices={ + 1: 'US, use reset and rate', 2: 'SI, use integral and derivative'}), + ChoiceRegister('C_OR_F', 901, reference='6.18', choices={ + 0: 'fahrenheit', 1: 'celsius'}), + ChoiceRegister('FAILURE_MODE', 902, reference='?.?, 6.18', choices={ + 0: 'bumpless', 1: 'manual', 2: 'off'}), + Register('MANUAL_DEFAULT_POWER', 903, reference='6.19'), + ChoiceRegister('OPEN_LOOP_DETECT', 904, reference='5.21, 6.19', choices={ + 0: 'on', 1: 'off'}), + ChoiceRegister('EVENT_FUNCTION', 1060, reference='5.8, 6.12', choices={ + 0: 'none', + 1: 'switch to event set point', + 2: 'turn off control outputs and disable alarms', + 3: 'turn off control outputs', + 4: 'lock keyboard', + 5: 'switch to manual mode', + 6: 'initiate an auto-tune', + 7: 'clear alarm', + 8: 'lock everything except primary set point', + }, + help='Selects response to INPUT_2'), + ChoiceRegister('EVENT_CONDITION', 1061, direction='r', reference='5.8, 6.12', choices={ + 0: 'low', 1: 'high', 2: 'rise', 3: 'fall'}, + help='What behavior triggers Events'), + ChoiceRegister('RAMPING_MODE', 1100, reference='6.19', choices={ + 0: 'off', 1: 'startup only', 2: 'startup or setpoint change'}), + Register('RAMP_RATE', 1101, reference=''), + ChoiceRegister('RAMP_SCALE', 1102, reference='6.19', choices={ + 0: 'minute', 1: 'hour'}), + Register('SET_POINT_MENU_LOCK', 1300, reference='6.21'), + Register('OPERATIONS_PAGE_MENU_LOCK', 1301, reference=''), + Register('SETUP_PAGE_LOCK', 1302, reference=''), + Register('CUSTOM_MENU_LOCK', 1304, reference=''), + Register('CALIBRATION_MENU_LOCK', 1305, reference=''), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_1', 1400, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_2', 1401, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_3', 1402, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_4', 1403, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_5', 1404, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_6', 1405, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_7', 1406, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_8', 1407, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_9', 1408, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_10', 1409, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_11', 1410, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_12', 1411, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_13', 1412, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_14', 1413, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_15', 1414, **_custom_prompt_kwargs), + ChoiceRegister('CUSTOM_PROMPT_NUMBER_16', 1415, **_custom_prompt_kwargs), + FloatRegister('AMBIENT_TEMPERATURE', 1500, direction='r', reference='6.23', help='Always in deg F, regardless of C_OR_F', decimal=10.), + Register('AMBIENT_A_D_COUNTS', 1501, direction='r', reference='6.23'), + Register('CHANNEL_1_A_D_COUNTS', 1504, direction='r', reference='6.24'), + Register('CHANNEL_2_A_D_COUNTS', 1505, direction='r', reference='6.24'), + ChoiceRegister('TEST_DISPLAY', 1513, reference='6.23', choices={ + 0: 'off', 1: 'on'}, help='Cyclic display test'), + ChoiceRegister('TEST_OUTPUT', 1514, reference='6.23', choices={ + 0: 'none', 1: 'output 1', 2: 'outptut 2', 3: 'output 3', + 4: 'output 4', 5: 'all outputs'}, + help='Turns onn specific output'), + Register('LINE_FREQUENCY', 1515, direction='r', reference='6.24', help='AC line freq in Hz'), + ChoiceRegister('RESTORE_FACTORY_CALIBRATION', 1601, direction='w', reference='6.24', choices={ + 0: 'no', 1: 'yes'}), + Register('DEFAULT_SETTINGS', 1602, direction='w', reference='6.24'), + ChoiceRegister('OVERLOADED_CALIBRATION_1', 1603, direction='w', reference='6.24, 6.25', choices={ + 0: 'no', + 1: 'thermocouple, 0mV', + 2: 'thermocouple, 50mV', + 3: 'thermocouple, 32deg', + 4: 'ground', + 5: 'lead resistance', + 6: 'RTD, 15 Ohms', # RTD = Resistance Temp. Detector + 7: 'RTD, 380 Ohms', + 8: 'process 1, 0V', + 9: 'process 1, 10V', + 10: 'process 1, 4mA', + 11: 'process 1, 20mA', + }), + Register('OUTPUT_CALIBRATION_1_4MA', 1604, direction='w', reference='6.26'), + Register('OUTPUT_CALIBRATION_1_20MA', 1605, direction='w', reference='6.26'), + Register('OUTPUT_CALIBRATION_1_1V', 1606, direction='w', reference='6.26'), + Register('OUTPUT_CALIBRATION_1_10V', 1607, direction='w', reference='6.27'), + ChoiceRegister('OVERLOADED_CALIBRATION_2', 1608, direction='w', reference='6.26', choices={ + 0: 'no', + 1: 'process 2, 0V', + 2: 'process 2, 10V', + 3: 'process 2, 4mA', + 4: 'process 2, 20mA', + }), + Register('OUTPUT_CALIBRATION_2_4MA', 1609, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_2_20MA', 1610, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_2_1V', 1611, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_2_10V', 1612, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_4_4MA', 1619, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_4_20MA', 1620, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_4_1V', 1621, direction='w', reference='6.27'), + Register('OUTPUT_CALIBRATION_4_10V', 1622, direction='w', reference='6.27'), + FloatRegister('HIGH_RESOLUTION', 1707, direction='r', reference='6.23', help='High resolution input value', decimal_offset=10.), + ] + del(_custom_prompt_kwargs) + _register = dict((r.name, r) for r in _registers) + + def __init__(self, controller=1, device='/dev/ttyS0', baudrate=9600): + """ + controller : MTCA controller ID + device : serial port you're using to connect to the controller + baudrate : baud rate for which you've configured your controller + """ + # the rated max current from controller specs + self._spec_max_current = 4.0 # Amps + + self._controller = controller + + # from the Melcor Manual, A.4 (p96), messages should be coded + # in eight-bit bytes, with no parity bit, and one stop bit + # (8N1). + self._client = _ModbusSerialClient( + method='rtu', + port=device, # '/dev/ttyS0' or 0 + bytesize=_serial.EIGHTBITS, + parity=_serial.PARITY_NONE, + stopbits=_serial.STOPBITS_ONE, + baudrate=baudrate, + timeout=0.5, + ) + + self._decimal = None + + def _read(self, register_name): + register = self._register[register_name] + if 'r' not in register.direction: + raise ValueError(register_name) + if register.needs_decimal and not self._decimal: + self._decimal = self._get_decimal() + rc = self._client.read_holding_registers( + address=register.value, count=1, unit=self._controller) + assert rc.function_code < 0x80 + value = rc.registers[0] + v = register.decode(value, decimal=self._decimal) + _LOG.info('read %s: %s %s (%s)' % (register_name, rc, v, rc.registers)) + return v + + def _write(self, register_name, value): + register = self._register[register_name] + if 'w' not in register.direction: + raise ValueError(register_name) + if register.needs_decimal and not self._decimal: + self._decimal = self._get_decimal() + v = register.encode(value, decimal=self._decimal) + _LOG.info('write %s: %s (%s)' % (register_name, v, value)) + rc = self._client.write_register( + address=register.value, value=v, unit=self._controller) + assert rc.function_code < 0x80 + + def _get_decimal(self): + return self._read('DECIMAL_1') + + # Support for Backend methods + + def get_temp(self): + return self._read('HIGH_RESOLUTION') + + def get_ambient_temp(self): + return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE')) + + def set_max_current(self, max): + """Set the max current in Amps + + 0.2 A is the default max current since it seems ok to use + without fluid cooled heatsink. If you are cooling the + heatsink, use 1.0 A, which seems safely below the peltier's + 1.2 A limit. + + Note to Melcor enthusiasts: this method set's both the 'above' + and 'below' limits. + """ + max_percent = max / self._spec_max_current * 100 + self._write('HIGH_POWER_LIMIT_ABOVE', max_percent) + self._write('HIGH_POWER_LIMIT_BELOW', max_percent) + self._max_current = max + + def get_max_current(self): + percent = self._read('HIGH_POWER_LIMIT_ABOVE') + above = percent/100. * self._spec_max_current + percent = self._read('HIGH_POWER_LIMIT_BELOW') + below = percent/100. * self._spec_max_current + #setpoint = self._read('POWER_LIMIT_SET_POINT') + assert above == below, 'Backend() only expects a single power limit' + self._max_current = above + return above + + def get_current(self): + pout = self._read('PERCENT_OUTPUT') + cur = self._spec_max_current * pout / 100.0 + return cur + + def get_modes(self): + register = self._register['AUTO_MANUAL_OP_MODE'] + return sorted(register.choices.values()) + + def get_mode(self): + return self._read('AUTO_MANUAL_OP_MODE') + + def set_mode(self, mode): + self._write('AUTO_MANUAL_OP_MODE', mode) + + def dump_configuration(self): + for register in self._registers: + if 'r' in register.direction: + value = self._read(register.name) + print('%s\t%s' % (register.name, value)) + + # ManualMixin methods + + def set_current(self, current): + if current > self._spec_max_current: + raise ValueError('current {} exceeds spec maximum {}'.format( + current, self._spec_max_current)) + pout = current / self._spec_max_current * 100.0 + self._write('REG_MANUAL_SET_POINT', pout) + + # PIDMixin methods + + def set_setpoint(self, setpoint): + self._write('SET_POINT_1', setpoint) + + def get_setpoint(self): + return self._read('SET_POINT_1') + + def _set_gains(self, output, proportional=None, integral=None, + derivative=None): + """ + (output, proportional, integral, derivative, dead_band) -> None + output : 1 (cooling) or 2 (heating) + proportional : propotional gain band in amps per degrees C + integral : integral weight in minutes (0.00 to 99.99) + derivative : derivative weight in minutes (? to ?) + + Don't use derivative, dead time. + Cycle time? + Histerysis? + Burst? + + See 5.10 and the pages afterwards in the manual for Melcor's + explanation. The integral with respect to t' is actually only + from the time that T_samp has been with T_prop of T_set (not + -inf), and + """ + if proportional is not None: + max_current = self.get_max_current() + propband = max_current/proportional + propband_name = 'PROPBAND_%d' % output + register = self._register[propband_name] + if propband > register.max: + # round down, to support bang-bang experiments + _LOG.warn( + 'limiting propband %d to maximum: {:n} -> {:n} C'.format( + propband, register.max)) + propband = register.max + self._write(propband_name, propband) + if integral is not None: + self._write('INTEGRAL_%d' % output, integral) + if derivative is not None: + self._write('DERIVATIVE_%d' % output, derivative) + + def _get_gains(self, output): + propband = self._read('PROPBAND_%d' % output) + integral = self._read('INTEGRAL_%d' % output) + derivative = self._read('DERIVATIVE_%d' % output) + max_current = self.get_max_current() + proportional = max_current/propband + return (proportional, integral, derivative) + + def set_cooling_gains(self, proportional=None, integral=None, + derivative=None): + self._set_gains( + output=1, proportional=proportional, integral=integral, + derivative=derivative) + + def get_cooling_gains(self): + return self._get_gains(output=1) + + def set_heating_gains(self, proportional=None, integral=None, + derivative=None): + self._set_gains( + output=2, proportional=proportional, integral=integral, + derivative=derivative) + + def get_heating_gains(self): + return self._get_gains(output=2) + + def get_feedback_terms(self): + """ + """ + pid = int(self._read('PID_POWER_1')) + prop = int(self._read('PROP_TERM_1')) + ntgrl = int(self._read('INTEGRAL_TERM_1')) + deriv = int(self._read('DERIVATIVE_TERM_1')) + return (pid, prop, ntgrl, deriv) + + def clear_integral_term(self): + # The controller resets the integral term when the temperature + # is outside the propbands + _LOG.debug('clearing integral term') + cp,ci,cd = self.get_cooling_gains() + hp,hi,hd = self.get_heating_gains() + sp = self.get_setpoint() + small_temp_range = 0.1 + max_current = self.get_max_current() + p = max_current / small_temp_range + self.set_cooling_gains(proportional=p) + self.set_heating_gains(proportional=p) + while True: + _LOG.debug('waiting for an out-of-propband temperature') + if abs(self.get_temp() - sp) > small_temp_range: + break # we're out of the propband, I-term resets + self.set_cooling_gains(proportional=cp) + self.set_heating_gains(proportional=hp) + _LOG.debug('integral term cleared') + + # utility methods + + def sanity_check(self): + "Check that some key registers have the values we expect" + self._sanity_check('UNITS_TYPE', 'SI, use integral and derivative') + self._sanity_check('C_OR_F', 'celsius') + self._sanity_check('FAILURE_MODE', 'off') + self._sanity_check('RAMPING_MODE', 'off') + self._sanity_check('OUTPUT_1', 'cool') + self._sanity_check('OUTPUT_2', 'heat') + self._sanity_check('AUTO_MANUAL_OP_MODE', 'PID') + + def _sanity_check(self, register_name, expected_value): + value = self._read(register_name) + if value != expected_value : + _LOG.error('invalid value %s for %s (expected %s)' + % (value, register_name, expected_value)) + raise ValueError(value) diff --git a/tempcontrol/backend/test.py b/tempcontrol/backend/test.py new file mode 100644 index 0000000..0c627b0 --- /dev/null +++ b/tempcontrol/backend/test.py @@ -0,0 +1,240 @@ +# Copyright (C) 2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +import threading as _threading +import time as _time + +from .. import LOG as _LOG +from . import Backend as _Backend +from . import ManualMixin as _ManualMixin +from . import PIDMixin as _PIDMixin + + +class TestBackend (_Backend, _ManualMixin, _PIDMixin): + """Test backend for demonstrating `Controller` function + + The underlying temperature decay model is exponential, which is + often refered to as "Newton's law of cooling" + + dT/dt = h (Tbath - T) + + where `h` is the transfer coefficient (with some scaling terms + brushed under the rug). To make the system more realistic, I've + also added a dead time, so temperatures returned by `.get_temp()` + actually correspond to the system temperature `dead_time` seconds + before the measurement was taken. Finally, there's a + `drive_coefficient` `d`, which gives the rate of temperature + change due to a applied driving current `I`, so + + dT(y)/dt = h (Tbath(t) - T(t)) + d I(t-L) + + This model is often refered to as a FOPDT (first-order plus dead + time) or KLT (K: static gain, L: time delay, T: time constant/lag) + model. Translating our model above into process-control jargon: + + * Process variable y(t) corresponds to our T(t). + * Manipulated variable u(t) corresponds to our I(t). + * Process gain dy/du (often denoted K_p). For our parameters + above, K_p = dy/du = dT/dI = d/h. + * Process time constant (aka lag, often denoted tau or T; the + exponential decay timescale). For our parameters above, + tau = 1/h. + * Dead time (often denoted L or theta; the time delay between a + change system and that change being reflected in the process + variable). For our parameters above, L = dead_time. + + The response function for a FOPDT process is + + G(s) = K_p e^{-Ls} / (1 + T s) + + For interesting experimental evidence of exponential cooling, see + Kaliszan et al., "Verification of the exponential model of body + temperature decrease after death in pigs". + doi: 10.1113/expphysiol.2005.030551 + http://ep.physoc.org/content/90/5/727.long + September 1, 2005 Experimental Physiology, 90, 727-738. + """ + def __init__(self, bath=20, transfer_coefficient=0.1, + drive_coefficient=1., max_current=1., dead_time=1., + process_period=0.01, log_stream=None): + """ + bath : float + bath (ambient) temperature in degrees Celsius + transfer_coefficient : float + between the system and the bath, in inverse seconds + drive_coefficient : float + for the applied current, in degrees Celsius per amp + max_current : float + maximum current in amps + dead_time : float + time lag in seconds between an internal system temperature + and the corresponding `.get_temp()` reading + process_period : float + time in seconds between process-thread temperature updates + """ + self._bath = bath + self._transfer_coefficient = transfer_coefficient + self._drive_coefficient = drive_coefficient + self._max_current = max_current + self._dead_periods = int(dead_time/process_period) + self._process_period = process_period + self._log_stream = log_stream + self._setpoint = 0 + self._i_term = self._d_term = 0 + self._p_cool = self._d_cool = 0 + self._p_heat = self._d_heat = 0 + self._i_cool = self._i_heat = float('inf') + self._manual_current = 0 + self._mode = 'PID' + self._temperatures = [bath]*(self._dead_periods+1) + self._start_process_thread() + + def cleanup(self): + self._stop_process_thread() + + def _start_process_thread(self): + self._stop_process = False + self._process_thread = _threading.Thread( + target=self._run_process, name='process') + self._process_thread.start() + + def _stop_process_thread(self): + self._stop_process = True + self._process_thread.join() + + def _run_process(self): + if self._log_stream: + line = '\t'.join(( + 'time', 'setpoint', 'process temperature', + 'measured temperature', 'dT_bath', 'dT_drive', 'current', + 'intergal', 'derivative')) + self._log_stream.write('#{}\n'.format(line)) + dt = self._process_period + next_time = _time.time() + dt + while not self._stop_process: + T = self._temperatures[-1] + dT_bath = self._transfer_coefficient * (self._bath - T) + current = self.get_current(_increment_i_term=True) + dT_drive = self._drive_coefficient * current + if self._log_stream: + line = '\t'.join(str(x) for x in ( + _time.time(), self._setpoint, T, self.get_temp(), dT_bath*dt, + dT_drive*dt, current, self._i_term, self._d_term)) + self._log_stream.write(line + '\n') + T += (dT_bath + dT_drive) * dt + self._temperatures.pop(0) + self._temperatures.append(T) + s = next_time - _time.time() + if s > 0: + _time.sleep(s) + next_time += dt + + def _limited_current(self, current): + if current > self._max_current: + #_LOG.debug('limiting current to maximum: {:n} -> {:n} amps'.format( + # current, self._max_current)) + return self._max_current + elif current < -self._max_current: + #_LOG.debug('limiting current to maximum: {:n} -> {:n} amps'.format( + # current, -self._max_current)) + return -self._max_current + return current + + def get_temp(self): + return self._temperatures[1] + + def get_ambient_temp(self): + return self._bath + + def set_max_current(self, max): + self._max_current = max + + def get_max_current(self): + return self._max_current + + def get_current(self, _increment_i_term=True): + if self._mode == 'manual': + return self._manual_current + elif self._mode == 'PID': + T_pref,T = self._temperatures[:2] + dT_s = (self._setpoint - T) + if T > self._setpoint: + p,i,d = self._p_cool, self._i_cool, self._d_cool + else: + p,i,d = self._p_heat, self._i_heat, self._d_heat + dT_t = T - T_pref + dt = self._process_period + if _increment_i_term is True: + self._i_term += dT_s * dt + self._d_term = -dT_t / dt # = de(t)/dt with constant setpoint + return self._limited_current( + p*(dT_s + self._i_term/i + d*self._d_term)) + raise ValueError(self._mode) + + def get_modes(self): + return ['manual', 'PID'] + + def get_mode(self): + return self._mode + + def set_mode(self, mode): + self._mode = mode + + # ManualMixin methods + + def set_current(self, current): + self._manual_current = self._limited_current(current) + + # PIDMixin methods + + def set_setpoint(self, setpoint): + self._setpoint = setpoint + + def get_setpoint(self): + return self._setpoint + + def set_cooling_gains(self, proportional=None, integral=None, + derivative=None): + if proportional is not None: + self._p_cool = proportional + if integral is not None: + self._i_cool = integral + if derivative is not None: + self._d_cool = derivative + + def get_cooling_gains(self): + return (self._p_cool, self._i_cool, self._d_cool) + + def set_heating_gains(self, proportional=None, integral=None, + derivative=None): + if proportional is not None: + self._p_heat = proportional + if integral is not None: + self._i_heat = integral + if derivative is not None: + self._d_heat = derivative + + def get_heating_gains(self): + return (self._p_heat, self._i_heat, self._d_heat) + + def get_feedback_terms(self): + return (self.get_current(), self._setpoint - self.get_temp(), + self._i_term, self._d_term) + + def clear_integral_term(self): + self._i_term = 0 diff --git a/tempcontrol/controller.py b/tempcontrol/controller.py new file mode 100644 index 0000000..10400f1 --- /dev/null +++ b/tempcontrol/controller.py @@ -0,0 +1,512 @@ +# Copyright (C) 2008-2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +import time as _time + +from aubio.aubioclass import pitch as _pitch +from aubio.aubioclass import fvec as _fvec +from numpy import array as _array +from numpy import exp as _exp +from numpy import linspace as _linspace +from numpy import log as _log +from scipy.interpolate import interp1d as _interp1d + +from hooke.util.fit import ModelFitter as _ModelFitter + +from . import LOG as _LOG + + +class Controller (object): + """PID temperature control frontend. + + backend: tempcontrol.backend.Backend instance + backend driving your particular harware + setpoint: float + initial setpoint in degrees Celsius + min: float + minimum temperature in degrees Celsius (for sanity checks) + max: float + maximum temperature in degrees Celsius (for sanity checks) + """ + def __init__(self, backend, setpoint=20.0, min=5.0, max=50.0): + self._backend = backend + self._setpoint = setpoint + self._min = min + self._max = max + + # basic user interface methods + + def get_temp(self): + """Return the current process temperature in degrees Celsius + + We should expose this to users, so they don't need to go + mucking about in `._backend`. + """ + return self._backend.get_temp() + + def set_temp(self, setpoint, **kwargs): + """Change setpoint to `setpoint` and wait for stability + """ + self._backend.set_setpoint(setpoint) + self.wait_for_stability(setpoint=setpoint, **kwargs) + + def wait_for_stability(self, setpoint, tolerance=0.3, time=10., + timeout=-1, sleep_time=0.1, return_data=False): + """Wait until the temperature is sufficiently stable + + setpoint : float + target temperature in degrees C + tolerance : float + maximum allowed deviation from `setpoint` in dregrees C + time : float + time the temperature must remain in the allowed region + before the signal is delared "stable" + timeout : float + maximum time to wait for stability. Set to -1 to never + timeout. + sleep_time : float + time in seconds to sleep between reads to avoid an + overly-busy loop + return_data : boolean + if true, also return a list of `(timestamp, temp)` tuples + read while waiting + + Read the temperature every `sleep_time` seconds until the + temperature has remained within `tolerance` of `setpoint` for + `time`. If the stability criteria are met, return `True` + (stable). If `timeout` seconds pass before the criteria are + met, return `False` (not stable). + """ + _LOG.debug(('wait until the temperature is stable at {:n} +/- {:n} C ' + 'for {:n} seconds').format(setpoint, tolerance, time)) + stable = False + if return_data: + data = [] + start_time = _time.time() + stable_time = start_time + time + if timeout < 0: + timeout_time = None + else: + timeout_time = start_time + timeout + while True: + T = self.get_temp() + in_range = abs(T - setpoint) < tolerance + t = _time.time() + if return_data: + data.append((t, T)) + if in_range: + if t >= stable_time: + _LOG.debug('temperature is stable') + stable = True + break # in range for long enough + else: + stable_time = t + time # reset target time + if timeout_time and t > timeout_time: + break # timeout + _time.sleep(sleep_time) + if return_data: + return (stable, data) + return stable + + def is_stable(self, setpoint, time, **kwargs): + return self.wait_for_stability( + setpoint=setpoint, time=time, timeout=time, **kwargs) + + def estimate_temperature_sensitivity(self, num_temps=10, sleep_time=0.1, + max_repeats=10): + temps = [] + last_temp = None + repeats = 0 + while True: + temp = self.get_temp() + if repeats == max_repeats: + last_temp = None + if temp == last_temp: + repeats += 1 + else: + temps.append(temp) + if len(temps) > num_temps: + break + repeats = 0 + last_temp = temp + _time.sleep(sleep_time) + temps = _array(temps) + return temps.std() + + # debugging methods + + def check_feedback_terms(self): + """Check a backend's interpretation of its PID feedback terms. + + Some backends provide an interface to read out their PID + feedback terms, but the interface is not always well + documented. This method reads out the terms, and compares + them with our own calculations (when possible) to test the + backend's interpretation. + """ + c = self._backend.get_current() + pid,prop,ntgrl,deriv = self._backend.get_feedback_terms() + T = self.get_temp() + Tset = self._backend.get_setpoint() + if T > Tset: # cooling + p,i,d = self._backend.get_cooling_gains() + else: # heating + p,i,d = self._backend.get_heating_gains() + _LOG.info(('pid(read) {:n} =? sum(calc from terms) {:n} ' + '=? cur(read) {:n} A').format(pid, prop+ntgrl+deriv, c)) + _LOG.info('read: p {:n}, i {:n}, d {:n}'.format(p,i,d)) + _LOG.info('calc: p {:n}'.format(p*(Tset-T))) + + # tuning experiments and data processing + + def get_step_response(self, current_a, current_b, + sleep_time=0.1, stable_time=10., **kwargs): + "Measure a step response for later analysis" + _LOG.debug('measure step response') + if 'time' in kwargs: + raise ValueError(kwargs) + kwargs['time'] = stable_time + kwargs['sleep_time'] = sleep_time + mode = self._backend.get_mode() + if mode == 'manual': + manual_current = self._backend.get_current() + else: + self._backend.set_mode('manual') + _LOG.debug('set first current and wait for stability') + self._backend.set_current(current_a) + temp_a = self.get_temp() + while not self.is_stable(temp_a, **kwargs): + temp_a = self.get_temp() + _LOG.debug('stabilized at {:n} C with {:n} amps'.format( + temp_a, current_a)) + _LOG.debug('set second current and wait for stability') + data = [] + start_time = _time.time() + self._backend.set_current(current_b) + temp_b = temp_a + while True: + stable,d = self.is_stable(temp_b, return_data=True, **kwargs) + data.extend(d) + temp_b = self.get_temp() + if stable: + break + _LOG.debug('stabilized at {:n} C with {:n} amps'.format( + temp_b, current_b)) + if mode == 'manual': + self._backend.set_current(manual_current) + else: + self._backend.set_mode(mode) + return data + + @staticmethod + def analyze_step_response(step_response, current_shift): + rates = [(Tb-Ta)/(tb-ta) for ((ta,Ta),(tb,Tb)) + in zip(step_response, step_response[1:])] + # TODO: averaging filter? + max_rate_i = max_rate = 0 + for i,rate in enumerate(rates): + if abs(rate) > max_rate: # handle steps in both directions + max_rate_i = i + max_rate = abs(rate) + max_rate_time,max_rate_temp = step_response[max_rate_i] # TODO: avg i and i+1? + time_a,temp_a = step_response[0] + max_rate_time -= time_a + dead_time = max_rate_time - (max_rate_temp - temp_a) / max_rate + t_data = _array([t for t,T in step_response[max_rate_i:]]) + T_data = _array([T for t,T in step_response[max_rate_i:]]) + model = ExponentialModel(T_data, info={'x data (s)': t_data}) + tau,T0,T8 = model.fit() + gain = (T8 - temp_a) / current_shift + return (gain, dead_time, tau, max_rate) + + def get_bang_bang_response(self, dead_band=0.8, num_oscillations=10, + max_dead_band_time=30, sleep_time=0.1): + orig_cool_gains = self._backend.get_cooling_gains() + orig_heat_gains = self._backend.get_heating_gains() + _LOG.debug('measure bang-bang response') + mode = self._backend.get_mode() + if mode != 'PID': + self._backend.set_mode('PID') + i=0 + setpoint = self._backend.get_setpoint() + self._backend.set_cooling_gains(float('inf'), float('inf'), 0) + self._backend.set_heating_gains(float('inf'), float('inf'), 0) + start_time = _time.time() + temp = self.get_temp() + heat_first = self._is_heating( + temp=temp, setpoint=setpoint, dead_band=dead_band) + _LOG.debug('wait to exit dead band') + t = start_time + while heat_first is None: + if t - start_time > max_dead_band_time: + msg = 'still in dead band after after {:n} seconds'.format( + max_dead_band_time) + _LOG.error(msg) + raise ValueError(msg) + _time.sleep(sleep_time) + t = _time.time() + temp = t.get_temp() + heat_first = self._is_heating( + temp=temp, setpoint=setpoint, dead_band=dead_band) + _LOG.debug('read {:d} oscillations'.format(num_oscillations)) + data = [] + heating = heat_first + while i < num_oscillations*2 + 1: + t = _time.time() + temp = self.get_temp() + # drop first half cycle (possibly includes ramp to setpoint) + if i > 0: + data.append((t, temp)) + is_heating = self._is_heating( + temp=temp, setpoint=setpoint, dead_band=dead_band) + if heating is True and is_heating is False: + _LOG.debug('transition to cooling (i={:d})'.format(i)) + heating = False + i += 1 + elif heating is False and is_heating is True: + _LOG.debug('transition to heating (i={:d})'.format(i)) + heating = True + i += 1 + _time.sleep(sleep_time) + self._backend.set_cooling_gains(*orig_cool_gains) + self._backend.set_heating_gains(*orig_heat_gains) + if mode != 'PID': + self._backend.set_mode(mode) + return data + + @staticmethod + def analyze_bang_bang_response(bang_bang_response): + t_data = _array([t for t,T in bang_bang_response]) + T_data = _array([T for t,T in bang_bang_response]) + amp = (T_data.max() - T_data.min()) / 2 + freq = Controller._get_frequency(x_data=t_data, y_data=T_data) + period = 1./freq + return (amp, period) + + def get_ultimate_cycle_response(self, proportional, period): + orig_cool_gains = self._backend.get_cooling_gains() + orig_heat_gains = self._backend.get_heating_gains() + _LOG.debug('measure ultimate cycle response') + mode = self._backend.get_mode() + if mode != 'PID': + self._backend.set_mode('PID') + # TODO... + self._backend.set_cooling_gains(*orig_cool_gains) + self._backend.set_heating_gains(*orig_heat_gains) + if mode != 'PID': + self._backend.set_mode(mode) + return data + + @staticmethod + def analyze_ultimate_cycle_response(ultimate_cycle_response): + amp,period = Controller.analyze_bang_bang_response( + ultimate_cycle_response) + return period + + # tuning rules + + @staticmethod + def ziegler_nichols_step_response(gain, dead_time, tau, mode='PID'): + r = dead_time / tau + if r < 0.1 or r > 1: + _LOG.warn(('Ziegler-Nichols not a good idea when ' + 'dead-time/tau = {:n}').format(r)) + pkern = tau/(gain*dead_time) + if mode == 'P': + return (pkern, float('inf'), 0) + elif mode == 'PI': + return (0.9*pkern, 3.3*dead_time, 0) + elif mode == 'PID': + return (1.2*pkern, 2*dead_time, dead_time/2.) + raise ValueError(mode) + + def ziegler_nichols_bang_bang_response(self, amplitude, period, + max_current=None, mode='PID'): + if max_current is None: + max_current = self._backend.get_max_current() + return self._ziegler_nichols_bang_bang_response( + amplitude, period, max_current=max_current, mode=mode) + + @staticmethod + def _ziegler_nichols_bang_bang_response(amplitude, period, + max_current, mode='PID'): + """ + amplitude : float + center-to-peak amplitude (in K) of bang-bang oscillation + period : float + period (in seconds) of the critical oscillation + max_current : float + "bang" current (in amps) + """ + proportional = float(max_current)/amplitude + period = float(period) + if mode == 'P': + return (proportional/2, float('inf'), 0) + elif mode == 'PI': + return (proportional/3, 2*period, 0) + elif mode == 'PID': + return (proportional/2, period, period/4) + raise ValueError(mode) + + def ziegler_nichols_ultimate_cycle_response(self, proportional, period): + """ + proportional : float + critical P-only gain (ultimate gain, for sustained oscillation) + period : float + period (in seconds) of the critical oscillation + + Microstar Laboratories has a `nice analysis`_ on ZN + limitations, which points out that ZN-tuning assumes your + system has the FOPDT transfer function (see `TestBackend` for + details). + + .. _nice analysis: http://www.mstarlabs.com/control/znrule.html + """ + if mode == 'P': + return (0.50*proportional, float('inf'), 0) + elif mode == 'PI': + return (0.45*proportional, period/1.2, 0) + elif mode == 'PID': + return (0.60*proportional, period/2, period/8) + raise ValueError(mode) + + @staticmethod + def cohen_coon_step_response(gain, dead_time, tau, mode='PID'): + r = dead_time / tau + pkern = tau/(gain*dead_time) + if mode == 'P': + return (pkern*(1+r/3.), float('inf'), 0) + elif mode == 'PI': + return (pkern*(0.9+r/12.), (30.+3*r)/(9+20*r)*dead_time, 0) + elif mode == 'PD': # double check + return (1.24*pkern*(1+0.13*tf), float('inf'), + (0.27-0.36*t)/(1-0.87*t)*dead_time) + elif mode == 'PID': + return (pkern*(4./3+r/4.), (32.-6*r)/(13.-8*r)*dead_time, + 4/(11.+2*r)*dead_time) + raise ValueError(mode) + + @staticmethod + def wang_juang_chan_step_response(gain, dead_time, tau, mode='PID'): + """Wang-Juang-Chan tuning + """ + K,L,T = (gain, dead_time, tau) + if mode == 'PID': + return ((0.7303+0.5307*T/L)*(T+0.5*L)/(K*(T+L)), + T + 0.5*L, + 0.5*L*T / (T + 0.5*L)) + raise ValueError(mode) + + # utility methods + + def _wait_until_close(self, setpoint, tolerance=0.3, sleep_time=0.1): + while abs(self.get_temp() - setpoint) > tolerance: + _time.sleep(sleep_time) + + def _time_function(self, function, args=(), kwargs=None, count=10): + "Rough estimate timing of get_temp(), takes me about 0.1s" + if kwargs is None: + kwargs = {} + start = _time.time() + for i in range(count): + function(*args, **kwargs) + stop = _time.time() + return float(stop-start)/count + + def _is_heating(self, temp=None, setpoint=None, dead_band=None): + if temp is None: + temp = self.get_temp() + if setpoint is None: + temp = self._backend.get_setpoint() + low_temp = high_temp = setpoint + if dead_band: + low_temp -= dead_band + high_temp += dead_band + if temp < low_temp: + return False + elif temp > high_temp: + return True + return None + + def _select_parameter(self, heating_result=None, cooling_result=None, + dead_band_result=None, **kwargs): + heating = self._is_heating(**kwargs) + if heating: + return heating_result + elif heating is False: + return cooling_result + return dead_band_result + + @staticmethod + def _resample_with_constant_dx(x_data, y_data): + f = _interp1d(x_data, y_data) + x = _linspace(x_data[0], x_data[-1], len(x_data)) + y = f(x) + return x, y + + @staticmethod + def _get_frequency(x_data, y_data): + x,y = Controller._resample_with_constant_dx(x_data, y_data) + dx = x[1] - x[0] + yvec = _fvec(len(y_data)) + mean = y.mean() + for i,_y in enumerate(y_data): + yvec.set(_y - mean, i) + fake_sample_rate = 8000 # aubio is built for audio + p = _pitch(mode='schmitt', bufsize=len(y_data), hopsize=len(y_data), + samplerate=fake_sample_rate, omode='freq', tolerance=0.1) + freq = p(yvec) / (fake_sample_rate * dx) + _LOG.debug('pitch: {:n}, sample rate {:n}'.format(freq, 1./dx)) + del(p) + del(yvec) + return freq + + @staticmethod + def _check_range(value, min, max): + if value < min: + raise ValueError('%g < %g' % (value, min)) + if value > max: + raise ValueError('%g > %g' % (value, max)) + + def _check_temp(temp): + self._check_range(temp, self._min, self._max) + + +class ExponentialModel (_ModelFitter): + "Exponential decay model" + def model(self, params): + tau,y0,y8 = params + x_data = self.info['x data (s)'] + x0 = x_data[0] # raw times in seconds are too far from the epoc + a = 1 - y0/y8 + self._model_data[:] = y8*(1-a*_exp(-(x_data-x0)/tau)) + return self._model_data + + def guess_initial_params(self, outqueue=None, **kwargs): + x_data = self.info['x data (s)'] + y_data = self._data + y8 = y_data[-1] + x_mid = x_data[int(len(x_data)/2)] + y_mid = y_data[int(len(y_data)/2)] + x_start = x_data[0] + y_start = y_data[0] + tau = (x_mid - x_start)/_log((y_start-y8)/(y_mid-y8)) + return (tau, y_start, y8) + + def guess_scale(self, params, outqueue=None, **kwargs): + return (1., 1., 1.) diff --git a/tempcontrol/test.py b/tempcontrol/test.py new file mode 100644 index 0000000..7f9a671 --- /dev/null +++ b/tempcontrol/test.py @@ -0,0 +1,139 @@ +# Copyright (C) 2008-2011 W. Trevor King +# +# This file is part of tempcontrol. +# +# tempcontrol is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation, either +# version 3 of the License, or (at your option) any later version. +# +# tempcontrol is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with tempcontrol. If not, see +# . + +"Basic testing for `Controller`\s and `Backend`\s" + +import time as _time + +from . import LOG as _LOG +from .backend import get_backend as _get_backend +from controller import Controller as _Controller + + +def test_backend(backend=None): + internal_backend = False + if not backend: + internal_backend = True + backend = _get_backend('test')() + try: + sp = backend.get_setpoint() + _LOG.info('temperature = {:n} C'.format(backend.get_temp())) + _LOG.info('setpoint = {:n} C'.format(sp)) + _LOG.info('current = {:n} A'.format(backend.get_current())) + + _set_and_check_setpoint(backend=backend, setpoint=5.0) + _check_max_current(backend=backend) + _set_and_check_setpoint(backend=backend, setpoint=50.0) + _check_max_current(backend=backend) + _set_and_check_setpoint(backend=backend, setpoint=sp) + finally: + if internal_backend: + backend.cleanup() + +def _set_and_check_setpoint(backend, setpoint): + _LOG.info('setting setpoint to {:n} C'.format(setpoint)) + c.set_setpoint(setpoint) + sp = c.get_setpoint() + _LOG.info('setpoint = {:n} C'.format(sp)) + if sp != setpoint: + msg = 'read setpoint {:n} != written setpoint {:n}'.format( + sp, setpoint) + _LOG.error(msg) + raise Exception(msg) + +def _check_max_current(backend): + # give the backend some time to overcome any integral gain + _time.sleep(10) + cur = c.get_current() + _LOG.info('current = {:n} A'.format(cur)) + mcur = c.get_max_current() + if cur != mcur: + temp = backend.get_temp() + sp = backend.get_setpoint() + msg = ('current of {:n} A is not the max {:n} A, but the system is ' + 'at {:n} C while the setpoint is at {:n}').format( + cur, mcur, temp, sp) + _LOG.error(msg) + raise Exception(msg) + +def test_controller_step_response(backend=None, setpoint=25): + internal_backend = False + if not backend: + internal_backend = True + backend = _get_backend('test')() + try: + backend.set_mode('PID') + c = _Controller(backend=backend) + max_current = backend.get_max_current() + current_a = 0.4 * max_current + current_b = 0.5 * max_current + step_response = c.get_step_response( + current_a=current_a, current_b=current_b, tolerance=0.5, stable_time=4.) + if True: + with open('step_response.dat', 'w') as d: + s = step_response[0][0] + for t,T in step_response: + d.write('{:n}\t{:n}\n'.format(t-s, T)) + gain,dead_time,tau,max_rate = c.analyze_step_response( + step_response, current_shift=current_b-current_a) + _LOG.debug(('step response: dead time {:n}, gain {:n}, tau {:n}, ' + 'max-rate {:n}').format(dead_time, gain, tau, max_rate)) + for name,response_fn,modes in [ + ('Zeigler-Nichols', c.ziegler_nichols_step_response, + ['P', 'PI', 'PID']), + ('Cohen-Coon', c.cohen_coon_step_response, + ['P', 'PI', 'PID']), # 'PD' + ('Wang-Juan-Chan', c.wang_juang_chan_step_response, + ['PID']), + ]: + for mode in modes: + p,i,d = response_fn( + gain=gain, dead_time=dead_time, tau=tau, mode=mode) + _LOG.debug( + '{} step response {}: p {:n}, i {:n}, d {:n}'.format( + name, mode, p, i, d)) + finally: + if internal_backend: + backend.cleanup() + +def test_controller_bang_bang_response(backend=None, setpoint=25): + internal_backend = False + if not backend: + internal_backend = True + backend = _get_backend('test')(log_stream=open('pid.log', 'w')) + # shift our noise-less system off its setpoint + backend.set_setpoint(backend.get_temp()+0.1) + try: + c = _Controller(backend=backend) + dead_band = 3*c.estimate_temperature_sensitivity() + bang_bang_response = c.get_bang_bang_response(dead_band=dead_band, num_oscillations=4) + if True: + with open('bang_bang_response.dat', 'w') as d: + s = bang_bang_response[0][0] + for t,T in bang_bang_response: + d.write('{:n}\t{:n}\n'.format(t-s, T)) + amplitude,period = c.analyze_bang_bang_response(bang_bang_response) + _LOG.debug('bang-bang response: amplitude {:n}, period {:n}'.format( + amplitude,period)) + p,i,d = c.ziegler_nichols_bang_bang_response( + amplitude=amplitude, period=period, mode='PID') + _LOG.debug(('Zeigler-Nichols bang-bang response: ' + 'p {:n}, i {:n}, d {:n}').format(p, i, d)) + finally: + if internal_backend: + backend.cleanup() diff --git a/temperature.py b/temperature.py deleted file mode 100644 index 4874bf0..0000000 --- a/temperature.py +++ /dev/null @@ -1,646 +0,0 @@ -import Melcor -import time -import stripchart - -VERSION = "0.2" - -# buzzwords: 'integrator windup' for integral term built up during a slow approach. - -class error (Exception) : - "Errors with the temperature controller" - pass - -class errorMelcor (error) : - pass -class errorOutOfRange (error) : - pass - -def _check1(functionCall) : - (err, val) = functionCall - if err != 0 : - raise errorMelcor - return val - -def _check0(functionCall) : - err = functionCall - if err != 0 : - raise errorMelcor - - -def melcor2double(value) : - (err, doub) = Melcor.melcor2double(value) - if err != 0 : - raise errorMelcor, "Error converting melcor to double" - return doub -def double2melcor(doub) : - (err, val) = Melcor.double2melcor(doub) - if err != 0 : - raise errorMelcor, "Error converting double to melcor" - return val - -def check_range(raw_output, min, max) : - if raw_output < min : - raise errorOutOfRange, '%g < %g' % (raw_output, min) - if raw_output > max : - raise errorOutOfRange, '%g > %g' % (raw_output, max) - -class tempController : - "Pretty wrappers for controlling a Melcor MTCA Temperature Controller" - def __init__(self, controller=1, device='/dev/ttyS0', maxCurrent=0.2) : - """ - (controller, device, maxCurrent) -> (tempController instance) - controller : MTCA controller Id - device : serial port you're using to connect to the controller - maxCurrent : initial maximum allowed current (in Amps) - Set maxCurrent = None if you don't want to adjust from it's prev. value. - - 0.2 A is the default max current since it seems ok to use without fluid - cooled heatsink. If you are cooling the heatsink, use 1.0 A, which seems - safely below the peltier's 1.2 A limit. - """ - self.verbose = False - self.setpoint = 20.0 # degrees C - self.Tmin = 5.0 # setup some protective bounds for sanity checks - self.Tmax = 50.0 - self.specMaxCur = 4.0 # Amps, the rated max current from controller specs - self.T = Melcor.tempController(controller, device) - self.Tstrip = stripchart.stripchart(pipename='Tstrip_pipe', - title='Temp strip') - self.Cstrip = stripchart.stripchart(pipename='Cstrip_pipe', - title='Current strip') - if maxCurrent != None : # if None, just leave maxCurrent at it's prev. val. - self.setMaxCurrent(maxCurrent) # Amps - def getTemp(self) : - "Returns the current process temperature in degrees Celsius" - val = self.read(Melcor.REG_HIGH_RESOLUTION) - temp = val/100.0 - if self.Tstrip.status == 'open' : - self.Tstrip.add_point(temp) - return temp - def getAmbientTemp(self) : - "Returns room temperature in degrees Celsius" - val = self.read(Melcor.REG_AMBIENT_TEMPERATURE) - # convert (Fahrenheit*10) to Celsius - return (val/10.0 - 32)/1.8 - def setSetpoint(self, setpoint) : - "Set the temperature setpoint in degrees Celsius" - val = double2melcor(setpoint) - self.write(Melcor.REG_SET_POINT_1, val) - def getSetpoint(self) : - "Get the temperature setpoint in degrees Celsius" - val = self.read(Melcor.REG_SET_POINT_1) - return melcor2double(val) - def setMaxCurrent(self, maxCur) : - """ - Set the max current in Amps. - (Note to Melcor enthusiasts: set's both the 'above' and 'below' limits) - """ - maxPercent = maxCur / self.specMaxCur * 100 - val = double2melcor(maxPercent) - self.write(Melcor.REG_HIGH_POWER_LIMIT_ABOVE, val) - self.write(Melcor.REG_HIGH_POWER_LIMIT_BELOW, val) - self.maxCurrent = maxCur - def getMaxCurrent(self) : - """ - () -> (currentLimitAbove (A), currentLimitBelow (A), currentLimitSetpoint (deg C)) - """ - per = self.read(Melcor.REG_HIGH_POWER_LIMIT_ABOVE) - curLimAbove = melcor2double(per)/100.0 * self.specMaxCur - per = self.read(Melcor.REG_HIGH_POWER_LIMIT_BELOW) - curLimBelow = melcor2double(per)/100.0 * self.specMaxCur - val = self.read(Melcor.REG_POWER_LIMIT_SETPOINT) - curLimSet = melcor2double(val) - return (curLimAbove, curLimBelow, curLimSet) - def getPercentCurrent(self) : - """ - Returns the percent of rated max current being output. - See getCurrent() - """ - val = int(self.read(Melcor.REG_PERCENT_OUTPUT)) - if val > 2**15 : - val -= 2**16 - return float(val)/10.0 - def getCurrent(self) : - """ - The returned current is not the actual current, - but the current that the temperature controller - calculates it should generate. - If the voltage required to generate that current - exceeds the controllers max voltage (15V on mine), - then the physical current will be less than the - value returned here. - """ - percentOutput = self.getPercentCurrent() - cur = self.specMaxCur * percentOutput / 100.0 - if self.Cstrip.status == 'open' : - self.Cstrip.add_point(cur) - return cur - def setCoolingGains(self, propband=0.1, integral=0, derivative=0) : - """ - (propband, integral, derivative, dead_band) -> None - propband : propotional gain band in degrees C - integral : integral weight in minutes (0.00 to 99.99) - derivative : derivative weight in minutes (? to ?) - See 5.10 and the pages afterwards in the manual for Melcor's explaination. - Formula (from Cornell BioPhys El Producto Beamline notes) - P_cout = -1/T_prop * [ (T_samp - T_set) - + 1/t_int * int_-inf^t (T_samp(t')-T_set(t')) dt' - + t_deriv * dT_samp/dt - Where P_cout is the percent of the rated max current that the controller - would like to output if you weren't limiting it, - T_prop is the propband input to this function, - T_samp is the measured temperature of the sample in deg C, - T_set is the setpoint in deg C, - t_int is the integral input to this function, - the integral with respect to t' is actually only from the time that - T_samp has been with T_prop of T_set (not -inf), and - t_deriv is the derivative input to this function. - - Cooling is output 1 - """ - check_range(propband, 0, 99.9) - check_range(integral, 0, 99.99) - check_range(derivative, 0, 9.99) - - val = double2melcor(propband) - self.write(Melcor.REG_PROPBAND_1, val) - val = int(integral * 100) - self.write(Melcor.REG_INTEGRAL_1, val) - val = int(derivative * 100) - self.write(Melcor.REG_DERIVATIVE_1, val) - def getCoolingGains(self) : - "() -> (propband, integral, derivative)" - val = self.read(Melcor.REG_PROPBAND_1) - propband = melcor2double(val) - val = self.read(Melcor.REG_INTEGRAL_1) - integral = val/100.0 - val = self.read(Melcor.REG_DERIVATIVE_1) - derivative = val/100.0 - return (propband, integral, derivative) - def setHeatingGains(self, propband=0.1, integral=0, derivative=0) : - """ - (propband, integral, derivative, dead_band) -> None - propband : propotional gain band in degrees C - integral : integral weight in minutes (0.00 to 99.99) - derivative : derivative weight in minutes (? to ?) - Don't use derivative, dead time. - Cycle time? - Histerysis? - Burst? - See 5.10 and the pages afterwards in the manual for Melcor's explaination. - Formula (from Cornell BioPhys El Producto Beamline notes) - P_cout = -1/T_prop * [ (T_samp - T_set) - + 1/t_int * int_-inf^t (T_samp(t')-T_set(t')) dt' - + t_deriv * dT_samp/dt - Where P_cout is the percent of the rated max current that the controller - would like to output if you weren't limiting it, - T_prop is the propband input to this function, - T_samp is the measured temperature of the sample in deg C, - T_set is the setpoint in deg C, - t_int is the integral input to this function, - the integral with respect to t' is actually only from the time that - T_samp has been with T_prop of T_set (not -inf), and - t_deriv is the derivative input to this function. - - Heating is output 2 - """ - check_range(propband, 0, 99.9) - check_range(integral, 0, 99.99) - check_range(derivative, 0, 9.99) - - val = double2melcor(propband) - self.write(Melcor.REG_PROPBAND_2, val) - val = int(integral * 100) - self.write(Melcor.REG_INTEGRAL_2, val) - val = int(derivative * 100) - self.write(Melcor.REG_DERIVATIVE_2, val) - def getHeatingGains(self) : - "() -> (propband, integral, derivative)" - val = self.read(Melcor.REG_PROPBAND_2) - propband = melcor2double(val) - val = self.read(Melcor.REG_INTEGRAL_2) - integral = val/100.0 - val = self.read(Melcor.REG_DERIVATIVE_2) - derivative = val/100.0 - return (propband, integral, derivative) - def getFeedbackTerms(self) : - """ - Experimental - """ - pid = int(self.read(Melcor.REG_PID_POWER_1)) - if pid > 2**15 : - pid -= 2**16 - prop = int(self.read(Melcor.REG_PROP_TERM_1)) - if prop >= 2**15 : - prop -= 2**16 - ntgrl = int(self.read(Melcor.REG_INTEGRAL_TERM_1)) - print ntgrl - if ntgrl >= 2**15 : - ntgrl -= 2**16 - deriv = int(self.read(Melcor.REG_DERIVATIVE_TERM_1)) - if deriv >= 2**15 : - deriv -= 2**16 - return (pid, prop, ntgrl, deriv) - def checkFeedbackTerms(self) : - pid, prop, ntgrl, deriv = self.getFeedbackTerms() - pout = self.getPercentCurrent() - T = self.getTemp() - Tset = self.getSetpoint() - if T > Tset : # cooling - Tprop, tint, tderiv = self.getCoolingGains() - else : # heating - Tprop, tint, tderiv = self.getHeatingGains() - print "pid(read) %g =? sum(calc from terms) %g =? cur(read) %g" % (pid, prop+ntgrl+deriv, pout) - print "read: prop %d, integral %d, deriv %d" % (prop, ntgrl, deriv) - print "my calcs: prop %g" % ((Tset-T)/Tprop) - def setTemp(self, setpoint, tolerance=0.3, time=10.0) : - """ - Changes setpoint to SETPOINT and waits for stability - """ - self.setSetpoint(setpoint) - while self.isStable(setpoint, tolerance, time) != True : - pass - def setTemp_funkygain(self, setpoint, dead_time, heat_rate, cool_rate, - peltier_efficiency_fn, outside_equilib_rate, - tolerance=0.3, time=10.0) : - """ - Highly experimental, see diffusion.py - """ - mode = "" - T = self.getTemp() - # full steam ahead - print "full steam ahead" - self.setSetpoint(setpoint) - self.setHeatingGains(0.1, 0, 0) - self.setCoolingGains(0.1, 0, 0) - if T < setpoint : - mode = "Heating" - self._heat_until_close(setpoint, dead_time, heat_rate) - elif T > setpoint : - mode = "Cooling" - self._cool_until_close(setpoint, dead_time, cool_rate) - # coast - print "coast while temperature equilibrates" - self.setHeatingGains(100, 0, 0) - self.setCoolingGains(100, 0, 0) - time.sleep(dead_time*2) - cool_prop, heat_prop = self.calcPropBands() - print "calculated prop bands: c %g, h %g deg C" % (cool_prop, heat_prop) - print "reset integral gain, and bump to predicted props" - # pop down to reset integral gain, could also jump setpoint... - self.setHeatingGains(0.1, 0, 0) - self.setHeatingGains(heat_prop, 0, 0) - self.setCoolingGains(0.1, 0, 0) - self.setCoolingGains(cool_prop, 0, 0) - time.sleep(dead_time*4) - # now add in some integral to reduce droop - print "set integral gains to %g" % (dead_time*4) - self.setHeatingGains(heat_prop, dead_time*4, 0) - self.setCoolingGains(cool_prop, dead_time*4, 0) - time.sleep(dead_time*8) - print "wait to enter tolerance band" - while (self.getTemp()-setpoint) : - time.sleep(dead_time) - print "should be stable now" - if not self.isStable(setpoint, tolerance, time) : - raise error, "Algorithm broken ;)" - def _heat_until_close(self, setpoint, dead_time, heat_rate) : - while self.getTemp() < setpoint - 0.5*rate*dead_time : - time.sleep(dead_time/10.0) - def calcPropBands_HACK(setpoint, peltier_efficiency_fn, outside_equilib_rate) : - heat_loss = outside_equilib_rate * (setpoint - self.getAmbientTemp()) - required_current = heat_loss / peltier_efficiency_fn(setpoint) - if required_current > self.maxCurrent : - raise errorOutOfRange, "Can't source %g Amps", required_current - fraction_current = required_current / self.maxCurrent - droop = 0.5 # expected droop in deg C on only proporitional gain - # droop / T_prop = fraction current - T_prop = droop / fraction_current - if setpoint > self.getAmbientTemp()+5 : # heating - return (T_prop*10, T_prop) - elif setpoint < self.getAmbientTemp()+5 : # cooling - return (T_prop, T_prop*10) - else : # right about room temperature - return (T_prop, T_prop) - def getMode(self) : - mcode = self.read(Melcor.REG_AUTO_MANUAL_OP_MODE) - if mcode == 0 : - return 'auto' - elif mcode == 1 : - return 'manual' - else : - raise error, "Unrecognized mode code %d" % mcode - def setMode(self, mode) : - if mode == 'auto' : - mcode = 0 - elif mode == 'manual' : - mcode = 1 - else : - raise error, "Unrecognized mode %s" % mode - self.write(Melcor.REG_AUTO_MANUAL_OP_MODE, mcode) - def getManualCurrent(self) : - val = int(self.read(Melcor.REG_MANUAL_SET_POINT)) - if val > 2**15 : # convert to signed - val -= 2**16 - pct = float(val)/10.0 # stored value is percent * 10 - return self.specMaxCur * pct / 100.0 - def setManualCurrent(self, amps) : - if amps > self.maxCurrent : - raise error, "Suggested current %g > max %g" % \ - (amps, self.maxCurrent) - pct = amps / self.specMaxCur * 100.0 - val = int(pct * 10.0) - if val < 0 : # convert to unsigned - val += 2**16 - self.write(Melcor.REG_MANUAL_SET_POINT, val) - def calcPropBands_ZN_get_step_response(self, - initial_current=None, - initial_wait_time=20.0, - current_step=0.1, - response_wait_time=40.0, - plotVerbose=False) : - """ - Ziegler-Nichols tuning, using step response for input. - Process must be stable when calling this function. - """ - if initial_current == None : - initial_current = self.getCurrent() - original_mode = self.getMode() - if original_mode == 'manual' : - original_current = self.getManualCurrent() - else : - self.setMode('manual') - self.setManualCurrent(initial_current) - Tarr = [] - tarr = [] - start = time.time() - tm = start - # get some stability data before stepping - while tm < start + initial_wait_time : - tarr.append(tm-start) - Tarr.append(self.getTemp()) - tm = time.time() - # step the output - self.setManualCurrent(initial_current + current_step) - Tlast = Tarr[-1] - while tm < start + initial_wait_time + response_wait_time : - Tnow = self.getTemp() - if Tnow != Tlast : # save us some trouble averaging later - tarr.append(tm-start) - Tarr.append(self.getTemp()) - Tlast = Tnow - tm = time.time() - - self.setMode(original_mode) - if original_mode == 'manual' : - self.setManualCurrent(original_current) - if plotVerbose == True : - from pylab import figure, plot, title, xlabel, ylabel - figure(10) - plot(tarr, Tarr, 'r.-') - xlabel('time (s)') - ylabel('Temp (C)') - title('Plant step response') - return (tarr, Tarr) - def calcPropBands_ZN_analyze_step_response(self, tarr, Tarr, - initial_wait_time=20, - textVerbose=False) : - """ - Analyze the step response to determine dead time td, - and slope at point of inflection spoi. - """ - i=0 - while tarr[i] < initial_wait_time : - i += 1 - # find point of inflection (steepest slope) - istep = i - ipoi = istep - def slope(i) : - return (Tarr[i+1] - Tarr[i])/(tarr[i+1]-tarr[i]) - for i in range(istep, len(tarr)-1) : - if slope(i) > slope(ipoi) : - ipoi = i - print "Max slope at t = %g, T = %g" % (tarr[ipoi], Tarr[ipoi]) - spoi = slope(ipoi) - # find the dead time - # find the initial temperature - initialT = 0.0 - for i in range(istep) : - initialT += Tarr[i] - initialT /= float(istep) - print "Initial temperature %g" % initialT - deltaT = Tarr[ipoi] - initialT - rise_t_to_poi = spoi/deltaT - td = tarr[ipoi] - initial_wait_time - rise_t_to_poi - return (td, spoi) - def calcPropBands_ZN_compute_terms(self, td, spoi) : - kp = 1.2/spoi - ki = 2*td - kd = td/2 - return (kp, ki, kd) - def calcPropBands_ZN(self, initial_current=None, - initial_wait_time=20.0, - current_step=0.1, - response_wait_time=40.0, - textVerbose=False, - plotVerbose=False) : - tarr, Tarr = self.calcPropBands_ZN_get_step_response( \ - initial_current, initial_wait_time, - current_step, response_wait_time, - plotVerbose) - td, spoi = self.calcPropBands_ZN_analyze_step_response( \ - tarr, Tarr, initial_wait_time, textVerbose) - return self.calcPropBands_ZN_compute_terms(td, spoi) - def stripT(self, on) : - if on : - self.stripT = True - pipename = 'Temp_pipe' - self.stripTpipe = os.popen(pipename) - os.system("stripchart -u %s" % pipename) - def isStable(self, setpoint, tolerance=0.3, maxTime=10.0) : - """ - Counts how long the temperature stays within - TOLERANCE of SETPOINT. - Returns when temp goes bad, or MAXTIME elapses. - """ - stable = False - startTime = time.time() - stopTime = startTime - while abs(self.getTemp() - setpoint) < tolerance : - stopTime = time.time() - if (stopTime-startTime) > maxTime : - print "Stable for long enough" - break - if stopTime-startTime > maxTime : - return True - else : - return False - def setFilterTime(self, seconds) : - """ - Positive values to affect only monitored values. - Negative values affect both monitored and control values. - """ - decSeconds = int(seconds*10) - if decSeconds < 0 : # convert (unsigned int) -> (2's compliment signed) - decSeconds += 2**16 - self.write(Melcor.REG_INPUT_SOFTWARE_FILTER_1, decSeconds) - def getFilterTime(self) : - """ - Positive values to affect only monitored values. - Negative values affect both monitored and control values. - """ - val = self.read(Melcor.REG_INPUT_SOFTWARE_FILTER_1) - if val >= 2**15 : # convert (2's complement signed) -> (unsigned int) - val -= 2**16 - return val/10.0 - def sanityCheck(self) : - "Check that some key registers have the values we expect" - self._sanityCheck(Melcor.REG_UNITS_TYPE, 2) # SI - self._sanityCheck(Melcor.REG_C_OR_F, 1) # C - self._sanityCheck(Melcor.REG_FAILURE_MODE, 2) # off - self._sanityCheck(Melcor.REG_RAMPING_MODE, 0) # off - self._sanityCheck(Melcor.REG_OUTPUT_1, 1) # cool - self._sanityCheck(Melcor.REG_OUTPUT_2, 1) # heat - def _sanityCheck(self, register, expected_value) : - val = self.read(register) - if val != expected_value : - print "Register %d, expected %d, was %d" % (register, - expected_value, - val) - raise error, "Controller settings error" - def read(self, register) : - """ - (register) -> (value) - Returns the value of the specified memory register on the controller. - Registers are defined in the Melcor module. - See melcor_registers.h for a pointers on meanings and manual page nums. - """ - (err, val) = self.T.read(register) - if err != 0 : - raise errorMelcor - return val - def write(self, register, value) : - """ - (register, value) -> None - Sets the value of the specified memory register on the controller. - Registers are defined in the Melcor module. - See melcor_registers.h for a pointers on meanings and manual page nums. - """ - err = self.T.write(register, value) - if err != 0 : - raise errorMelcor - def getDeadtimeData(self, num_oscillations=10, curHysteresis=0.8) : - orig_heat_gains = self.getHeatingGains() - orig_cool_gains = self.getCoolingGains() - if self.verbose : - print "Measuring dead time" - print " go to bang-bang" - self.setHeatingGains(0.1, 0, 0) - self.setCoolingGains(0.1, 0, 0) - def isHeating(cur) : - if cur > curHysteresis : - return True - elif cur < -curHysteresis : - return False - else : - return None - i=0 - timeArr = [0.0] - temp = self.getTemp() - cur = self.getCurrent() - heat_first = isHeating(cur) - start_time = time.time() - tm = 0 - if verbose : - print " Wait to exit hysteresis region" - while heat_first == None and tm < 30: - temp = t.getTemp() - cur = t.getCurrent() - heat_first = isHeating(temp, cur) - tm = time.time()-start_time - if tm > 30 : - raise error, "after 30 seconds, still inside hysteresis region" - if self.verbose : - print " Read oscillations" - heating = heat_first - start_time = time.time() - tempArr = [temp] - curArr = [cur] - if verbose : - print "Temp %g\t(%g),\tCur %g,\tTime %d" % (temp, temp-Tset, cur, 0) - while i < numOscillations*2 : - temp = t.getTemp() - tm = time.time()-start_time - cur = t.getCurrent() - tempArr.append(temp) - timeArr.append(tm) - curArr.append(cur) - check_signs(temp,cur) - if heating == True and isHeating(temp, cur) == False : - print "Transition to cooling (i=%d)" % i - heating = False - i += 1 - elif heating == False and isHeating(temp, cur) == True : - print "Transition to heating (i=%d)" % i - heating = True - i += 1 - if verbose : - print " Restoring gains" - self.setHeatingGains(*orig_heat_gains) - self.setCoolingGains(*orig_cool_gains) - def time_getTemp(self) : - "Rough estimate timeing of getTemp(), takes me about 0.1s" - start = time.time() - for i in range(10) : - self.getTemp() - stop = time.time() - return (stop-start)/10.0 - -def _test_tempController() : - t = tempController(controller=1, maxCurrent=0.1) - - print "Temp = %g" % t.getTemp() - print "Current = %g" % t.getCurrent() - print "Setpoint = %g" % t.getSetpoint() - - print "Setting setpoint to 5.0 deg C" - t.setSetpoint(5.0) - sp = t.getSetpoint() - print "Setpoint = %g" % sp - if sp != 5.0 : - raise Exception, "Setpoint in %g != setpoint out %g" % (sp, 5.0) - time.sleep(10) # give the controller some time to overcome any integral gain - c = t.getCurrent() - print "Current = %g" % c - mca, mcb, mct = t.getMaxCurrent() - if t.getTemp() < mct : # we're below the high power limit setpoint, use mcb - if c != mcb : - raise Exception, "Current not at max %g, and we're shooting for a big temp" % mcb - else : - if c != mca : - raise Exception, "Current not at max %g, and we're shooting for a big temp" % mca - - - print "Setting setpoint to 50.0 deg C" - t.setSetpoint(50.0) - sp = t.getSetpoint() - print "Setpoint = %g" % sp - if sp != 5.0 : - raise Exception, "Setpoint in %g != setpoint out %g" % (sp, 5.0) - time.sleep(10) - c = t.getCurrent() - print "Current = %g" % c - print "Success" - mca, mcb, mct = t.getMaxCurrent() - if t.getTemp() < mct : # we're below the high power limit setpoint, use mcb - if -c != mcb : - raise Exception, "Current not at min %g, and we're shooting for a big temp" % (-mcb) - else : - if -c != mca : - raise Exception, "Current not at min %g, and we're shooting for a big temp" % (-mca) - -def test() : - _test_tempController() - -if __name__ == "__main__" : - test() -- 2.26.2