Ran update-copyright.py.
[pypid.git] / pypid / backend / melcor.py
1 # Copyright (C) 2011-2012 W. Trevor King <wking@tremily.us>
2 #
3 # This file is part of pypid.
4 #
5 # pypid is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pypid is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pypid.  If not, see <http://www.gnu.org/licenses/>.
16
17 import struct as _struct
18
19 import serial as _serial
20
21 from pymodbus.client.sync import ModbusSerialClient as _ModbusSerialClient
22
23 from .. import LOG as _LOG
24 from . import Backend as _Backend
25 from . import ManualMixin as _ManualMixin
26 from . import PIDMixin as _PIDMixin
27 from . import TemperatureMixin as _TemperatureMixin
28
29
30 class MelcorError (Exception):
31     pass
32
33
34 class Register (object):
35     def __init__(self, name, value, direction='rw', reference=None, help=None):
36         self.name = name
37         self.value = value
38         self.direction = direction
39         self.reference = reference
40         self.help = help
41         self.needs_decimal = False
42
43     def __str__(self):
44         return '<%s %s (%d)>' % (self.__class__.__name__, self.name, self.value)
45
46     def encode(self, value, **kwargs):
47         return value
48
49     def decode(self, value, **kwargs):
50         return value
51
52
53 class ChoiceRegister (Register):
54     def __init__(self, *args, **kwargs):
55         self.choices = kwargs.pop('choices')
56         super(ChoiceRegister, self).__init__(*args, **kwargs)
57
58     def encode(self, value, **kwargs):
59         for key,v in self.choices.items():
60             if v == value:
61                 return key
62         raise ValueError(value)
63
64     def decode(self, value, **kwargs):
65         try:
66             return self.choices[value]
67         except KeyError:
68             _LOG.error('unrecognized value %s for %s' % (value, self.name))
69
70
71 class FloatRegister (Register):
72     def __init__(self, *args, **kwargs):
73         self.decimal = kwargs.pop('decimal', None)
74         self.decimal_offset = kwargs.pop('decimal_offset', None)
75         super(FloatRegister, self).__init__(*args, **kwargs)
76         self.needs_decimal = not self.decimal
77
78     @staticmethod
79     def _float2melcor(float, decimal=None):
80         """Convert a Python float into Melcor's two's-compliment representation
81
82         >>> m = FloatRegister._float2melcor(-3.5, decimal=10.0)
83         >>> m
84         65501
85         >>> FloatRegister._melcor2float(m, decimal=10.0)
86         -3.5
87         """
88         return _struct.unpack('H', _struct.pack('h', int(float * decimal)))[0]
89
90     @staticmethod
91     def _melcor2float(melcor, decimal=None):
92         """Convert Melcor's two's compliment representation to a Python float
93
94         >>> FloatRegister._melcor2float(65501, decimal=10.0)
95         -3.5
96         """
97         return _struct.unpack('h', _struct.pack('H', melcor))[0] / decimal
98
99     def encode(self, value, **kwargs):
100         decimal = self.decimal
101         if self.decimal_offset:
102             decimal *= self.decimal_offset
103         return self._float2melcor(value, decimal)
104
105     def decode(self, value, decimal=None):
106         decimal = self.decimal
107         if self.decimal_offset:
108             decimal *= self.decimal_offset
109         return self._melcor2float(value, decimal)
110
111
112 class BoundedFloatRegister (FloatRegister):
113     def __init__(self, *args, **kwargs):
114         self.min = kwargs.pop('min', None)
115         self.max = kwargs.pop('max', None)
116         super(BoundedFloatRegister, self).__init__(*args, **kwargs)
117
118     def encode(self, value, **kwargs):
119         if value < self.min or value > self.max:
120             raise ValueError('{} out of range [{}, {}] for {}'.format(
121                     value, self.min, self.max, self))
122         return super(BoundedFloatRegister, self).encode(value, **kwargs)
123
124     def decode(self, value, **kwargs):
125         return super(BoundedFloatRegister, self).decode(value, **kwargs)
126
127
128 class MelcorBackend (_Backend, _ManualMixin, _PIDMixin, _TemperatureMixin):
129     """Temperature control backend for a Melcor MTCA Temperature Controller
130
131     * PV: process temperature
132     * PV-units: degrees Celsius
133     * MV: controller current
134     * MV-units: amps
135     """
136     pv_units = 'C'
137     mv_units = 'A'
138
139     # Relative register addresses from back page of Melcor Manual.
140     # Then I went through Chapter 6 tables looking for missing
141     # registers.  References are from Series MTCA Thermoelectric
142     # Cooler Controller Instruction Manual, Revision 5.121900.
143     _custom_prompt_kwargs = {
144         'reference': '5.2, 6.20',
145         'help': 'Setup a custom menu',
146         'choices': {
147             0: 'none',
148             1: 'process 2',
149             2: 'percent output',
150             3: 'ramping set point',
151             4: 'event input status',
152             5: 'operation mode',
153             6: 'auto-tune',
154             7: 'auto-tune set point',
155             8: 'set point 2',
156             9: 'event set point',
157             10: 'local or remote calibration mode',
158             11: 'calibration offset',
159             12: 'propband 1',
160             13: 'integral 1',
161             14: 'derivative 1',
162             15: 'reset 1',
163             16: 'rate 1',
164             17: 'cycle time 1',
165             18: 'dead band 1',
166             19: 'propband 2',
167             20: 'integral 2',
168             21: 'derivative 2',
169             22: 'reset 2',
170             23: 'rate 2',
171             24: 'cycle time 2',
172             25: 'dead band 2',
173             26: 'alarm 2 high',
174             27: 'alarm 2 low',
175             28: 'alarm 3 high',
176             29: 'alarm 3 low',
177             30: 'alarm 4 high',
178             31: 'alarm 4 low',
179             32: 'proportional term',
180             33: 'integral term',
181             34: 'derivative term',
182             35: 'hysteresis 1',
183             36: 'hysteresis 2',
184             37: 'alarm hysteresis 2',
185             38: 'alarm hysteresis 3',
186             39: 'alarm hysteresis 4',
187             40: 'set point 1',
188             },
189         }
190     _registers = [
191         Register('MODEL_NUMBER',                   0, direction='r', reference='6.22'),
192         Register('SERIAL_NUMBER_1',                1, direction='r', reference='6.22', help='first 4 digits'),
193         Register('SERIAL_NUMBER_2',                2, direction='r', reference='6.22', help='last 4 digits'),
194         Register('SOFTWARE_ID_NUMBER',             3, direction='r', reference='6.22'),
195         Register('SOFTWARE_REVISION',              4, direction='r', reference='6.22'),
196         Register('DATE_OF_MANUFACTURE',            5, direction='r', reference='6.22', help='WEEK:YEAR (WWYY)'),
197         ChoiceRegister('INPUT_2_HARDWARE_ENABLED', 9, direction='r', reference='1.2, 6.22', choices={
198                 0: 'none', 5: 'process event'}, help='INPUT_2 option installed'),
199         ChoiceRegister('OUTPUT_1_HARDWARE',       16, direction='r', reference='6.23', choices={
200                 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}),
201         ChoiceRegister('OUTPUT_2_HARDWARE',       17, direction='r', reference='6.23', choices={
202                 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}),
203         ChoiceRegister('OUTPUT_3_HARDWARE',       18, direction='r', reference='6.23', choices={
204                 0: 'none', 1: 'relay'}),
205         ChoiceRegister('OUTPUT_4_HARDWARE',       19, direction='r', reference='5.9, 6.23', choices={
206                 0: 'none', 1: 'relay', 4: 'process', 6: '485', 7: '232'},
207                        help='Retransmit option installed'),
208         Register('DISABLE_NONVOLATILE_MEM',       24, reference=''),
209         FloatRegister('PROCESS_1',               100, direction='r', reference='6.3', help='Current temp (input to INPUT_1) (mdbl)'),
210         Register('ERROR_1',                      101, reference=''),
211         Register('PERCENT_OUTPUT',               103, direction='r', reference='5.4, 6.4', help="% of controller's rated maximum power/current"),
212         Register('ACTUAL_2',                     104, reference=''),
213         FloatRegister('PROCESS_2',               105, direction='r', reference='6.4', help='Value of signal input to INPUT_2'),
214         Register('ALARM_2_STATUS',               106, reference=''),
215         Register('ALARM_3_STATUS',               110, reference=''),
216         Register('ALARM_4_STATUS',               114, reference=''),
217         Register('OPERATION_MODE',               200, reference='?'),
218         ChoiceRegister('EVENT_INPUT_STATUS',     201, direction='r', reference='6.4', choices={
219                 1:True, 0:False}, help='Whether EVENT_FUNCTION satisfies EVENT_CONDITION'),
220         FloatRegister('REMOTE_SET_POINT',        202, direction='r', reference='6.3', help='Or event set point'),
221         Register('RAMPING_SET_POINT',            203, direction='r', reference='6.4', help='Active if RAMPING_MODE not set to OFF'),
222         # NOTE: sometimes the *_TERM_1 registers blib to 10x the predicted value.  I don't know why yet...
223         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.),
224         FloatRegister('PROP_TERM_1',             205, reference='Not in manual', help='(Tset-Tcur)/Tprop see temperature.tempControl.getFeedbackTerms(), active when Troubleshooting == 1.', decimal=1.),
225         FloatRegister('INTEGRAL_TERM_1',         206, reference='', decimal=1.),
226         FloatRegister('DERIVATIVE_TERM_1',       207, reference='', decimal=1.),
227         Register('SYSTEM_ERROR',                 209, reference=''),
228         Register('OPEN_LOOP_ERROR',              210, reference=''),
229         FloatRegister('SET_POINT_1',             300, reference='5.7 6.3', help='Set-point for INPUT_1'),
230         ChoiceRegister('AUTO_MANUAL_OP_MODE',    301, direction='r', reference='6.4', help='Select control mode', choices={0: 'PID', 1: 'manual'}),
231         Register('AUTO_TUNE_SETPOINT',           304, reference='6.5', help='Set auto tune setpoint as % of current set point (default 90%)'),
232         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'}),
233         FloatRegister('EVENT_SET_POINT_1',       306, reference='6.2', decimal=1.),
234         FloatRegister('BOOST_SET_POINT_1',       309, reference='1.2', help='Optional, on back plate'),
235         Register('MANUAL_SET_POINT',             310, reference='6.3', help='If AUTO_MANUAL_OP_MODE is MANUAL (manual)'),
236         Register('CLEAR_INPUT_ERRORS',           311, reference=''),
237         ChoiceRegister('LOCAL_REMOTE_1',         316, reference='5.9, 6.5', choices={
238                     0: 'local', 1: 'remote'}, help='Selects active setpoint.  Active if INPUT_2 is not OFF or EVENT'),
239         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'),
240         FloatRegister('ALARM_2_LOW',             321, reference='5.18, 6.2, 6.8'),
241         FloatRegister('ALARM_2_HIGH',            322, reference='5.18, 6.2, 6.8'),
242         Register('CLEAR_ALARMS',                 331, reference=''),
243         Register('SILENCE_ALARMS',               332, reference=''),
244         FloatRegister('ALARM_3_LOW',             340, reference='5.18, 6.2, 6.9'),
245         FloatRegister('ALARM_3_HIGH',            341, reference='5.18, 6.2, 6.9'),
246         BoundedFloatRegister('PROPBAND_1',       500, reference='6.2, 6.5', help='Width of proportional band in PID control(mdbl)', min=0, max=9999),
247         BoundedFloatRegister('INTEGRAL_1',       501, reference='6.6', help='Set integral time in minutes for output 1', decimal=100., min=0, max=99.99),
248         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),
249         BoundedFloatRegister('DERIVATIVE_1',     503, reference='6.6', help='Set derivative time in minutes', decimal=100., min=0, max=9.99),
250         BoundedFloatRegister('RATE_1',           504, reference='6.6', decimal=100., min=0, max=9.99),
251         BoundedFloatRegister('DEAD_BAND_1',      505, reference='6.2, 6.7', min=0, max=9999),
252         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.),
253         BoundedFloatRegister('HYSTERESIS_1',     507, reference='6.2, 6.6', min=1, max=9999),
254         ChoiceRegister('BURST_1',                509, reference='5.16, 6.6', choices={
255                 0: 'no', 1: 'yes'}),
256         BoundedFloatRegister('PROPBAND_2',       510, reference='6.2, 6.7', min=0, max=9999),
257         BoundedFloatRegister('INTEGRAL_2',       511, reference='6.7', decimal=100., min=0, max=99.99),
258         BoundedFloatRegister('RESET_2',          512, reference='6.7', decimal=100., min=0, max=99.99),
259         BoundedFloatRegister('DERIVATIVE_2',     513, reference='6.7', decimal=100., min=0, max=9.99),
260         BoundedFloatRegister('RATE_2',           514, reference='6.7', decimal=100., min=0, max=9.99),
261         BoundedFloatRegister('DEAD_BAND_2',      515, reference='6.2, 6.8', min=0, max=9999),
262         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.),
263         BoundedFloatRegister('HYSTERESIS_2',     517, reference='6.2, 6.8', min=1, max=9999),
264         ChoiceRegister('BURST_2',                519, reference='5.16, 6.7', choices={
265                 0: 'no', 1: 'yes'}),
266         Register('SENSOR_TYPE_1',                600, reference='5.7', help='Sensor used for INPUT_1'),
267         Register('INPUT_1',                      601, reference='5.7', help='Temperature measurement'),
268         FloatRegister('RANGE_LOW_1',             602, reference='5.7, 6.2, 6.11', help='Minimum SET_POINT_1'),
269         FloatRegister('RANGE_HIGH_1',            603, reference='5.7, 6.2, 6.11', help='Maximum SET_POINT_1'),
270         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),
271         FloatRegister('CALIBRATION_OFFSET_1',    605, reference='5.5, 6.2, 6.5', help='Offset added to INPUT_1'),
272         ChoiceRegister('DECIMAL_1',              606, reference='6.11', choices={
273                 0: 1., 1: 10., 2: 1., 3: 10., 4: 100., 5: 1000.}),
274         ChoiceRegister('INPUT_ERROR_LATCHING',   607, reference='6.18', choices={
275                     0: 'latching', 1: 'no latching'}),
276         ChoiceRegister('INPUT_2',                611, reference='5.8, 6.11', choices={
277                 0: 'off', 1: 'event', 2: '4-20mA', 3: '0-20mA', 4: '0-5V dc', 5: '1-5V dc', 6: '0-10V dc'},
278                        help='For external control'),
279         FloatRegister('RANGE_LOW_2',             612, reference='5.9, 6.2, 6.12', help='Minimum INPUT_2 signal'),
280         FloatRegister('RANGE_HIGH_2',            613, reference='5.9, 6.2, 6.12', help='Maximum INPUT_2 signal'),
281         FloatRegister('CALIBRATION_OFFSET_2',    615, reference='5.5,, 6.2, 6.12', help='Offset added to INPUT_2'),
282         ChoiceRegister('OUTPUT_1',               700, reference='6.13', choices={
283                     0: 'heat', 1: 'cool'}),
284         ChoiceRegister('PROCESS_1_TYPE',         701, reference='6.13', choices={
285                 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'}),
286         Register('HIGH_LIMIT_SET_POINT',         702, reference=''),
287         FloatRegister('POWER_LIMIT_SET_POINT',   713, reference='5.4, 6.2, 6.19', help='Temperature set point for power limits'),
288         FloatRegister('HIGH_POWER_LIMIT_ABOVE',  714, reference='5.4', help='% limit when above PLSP'),
289         FloatRegister('HIGH_POWER_LIMIT_BELOW',  715, reference='5.4', help='% limit when below PLSP'),
290         ChoiceRegister('OUTPUT_2',               717, reference='6.13', choices={
291                 0: 'off', 1: 'heat', 2: 'cool', 3: 'alarm'}),
292         ChoiceRegister('PROCESS_2_TYPE',         718, reference='6.13', choices={
293                 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'},
294                  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'),
295         ChoiceRegister('ALARM_2_TYPE',           719, reference='5.19, 6.13', choices={
296                 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'),
297         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.'),
298         ChoiceRegister('LATCHING_2',             721, reference='5.19, 6.14', choices={
299                 0: 'no', 1: 'yes'}),
300         ChoiceRegister('SILENCING_2',            722, reference='5.20, 6.14', choices={
301                 0: 'no', 1: 'yes'}),
302         ChoiceRegister('ALARM_ACTIVE_SIDES_2' ,  723, reference='6.14', choices={
303                 0: 'both', 1: 'high', 2: 'low'},
304                  help='Select which side or sides the alarm setpoints can be programmed for'),
305         ChoiceRegister('ALARM_LOGIC_2',          724, reference='6.14', choices={
306                 0: 'de-energize', 1: 'energize'},
307                  help='Select alarm 2 output condition in the alarm state.  De-energizing is the failsafe behaviour.'),
308         ChoiceRegister('ALARM_ANNUNCIATION_2',   725, reference='6.14', choices={
309                 0: 'no', 1: 'yes'}),
310         ChoiceRegister('OUTPUT_3',               734, reference='6.15', choices={
311                 0: 'off', 1: 'alarm'}),
312         ChoiceRegister('ALARM_3_TYPE',           736, reference='5.19, 6.15', choices={
313                 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'),
314         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.'),
315         ChoiceRegister('LATCHING_3',             738, reference='5.19, 6.15', choices={
316                 0: 'no', 1: 'yes'}),
317         ChoiceRegister('SILENCING_3',            739, reference='5.20, 6.15', choices={
318                 0: 'no', 1: 'yes'}),
319         ChoiceRegister('ALARM_ACTIVE_SIDES_3',   740, reference='6.15', choices={
320                 0: 'both', 1: 'high', 3: 'low'},
321                  help='Select which side or sides the alarm setpoints can be programmed for'),
322         ChoiceRegister('ALARM_LOGIC_3',          741, reference='6.16', choices={
323                 0: 'de-energize', 1: 'energize'},
324                  help='Select alarm 3 output condition in the alarm state.  De-energizing is the failsafe behaviour.'),
325         ChoiceRegister('ALARM_ANNUNCIATION_2',   742, reference='6.16', choices={
326                 0: 'no', 1: 'yes'}),
327         ChoiceRegister('UNITS_TYPE',             900, reference='6.18', choices={
328                 1: 'US, use reset and rate', 2: 'SI, use integral and derivative'}),
329         ChoiceRegister('C_OR_F',                 901, reference='6.18', choices={
330                 0: 'fahrenheit', 1: 'celsius'}),
331         ChoiceRegister('FAILURE_MODE',           902, reference='?.?, 6.18', choices={
332                 0: 'bumpless', 1: 'manual', 2: 'off'}),
333         Register('MANUAL_DEFAULT_POWER',         903, reference='6.19'),
334         ChoiceRegister('OPEN_LOOP_DETECT',       904, reference='5.21, 6.19', choices={
335                 0: 'on', 1: 'off'}),
336         ChoiceRegister('EVENT_FUNCTION',        1060, reference='5.8, 6.12', choices={
337                 0: 'none',
338                 1: 'switch to event set point',
339                 2: 'turn off control outputs and disable alarms',
340                 3: 'turn off control outputs',
341                 4: 'lock keyboard',
342                 5: 'switch to manual mode',
343                 6: 'initiate an auto-tune',
344                 7: 'clear alarm',
345                 8: 'lock everything except primary set point',
346                 },
347                        help='Selects response to INPUT_2'),
348         ChoiceRegister('EVENT_CONDITION',       1061, direction='r', reference='5.8, 6.12', choices={
349                 0: 'low', 1: 'high', 2: 'rise', 3: 'fall'},
350                        help='What behavior triggers Events'),
351         ChoiceRegister('RAMPING_MODE',          1100, reference='6.19', choices={
352                 0: 'off', 1: 'startup only', 2: 'startup or setpoint change'}),
353         Register('RAMP_RATE',                   1101, reference=''),
354         ChoiceRegister('RAMP_SCALE',            1102, reference='6.19', choices={
355                 0: 'minute', 1: 'hour'}),
356         Register('SET_POINT_MENU_LOCK',         1300, reference='6.21'),
357         Register('OPERATIONS_PAGE_MENU_LOCK',   1301, reference=''),
358         Register('SETUP_PAGE_LOCK',             1302, reference=''),
359         Register('CUSTOM_MENU_LOCK',            1304, reference=''),
360         Register('CALIBRATION_MENU_LOCK',       1305, reference=''),
361         ChoiceRegister('CUSTOM_PROMPT_NUMBER_1',  1400, **_custom_prompt_kwargs),
362         ChoiceRegister('CUSTOM_PROMPT_NUMBER_2',  1401, **_custom_prompt_kwargs),
363         ChoiceRegister('CUSTOM_PROMPT_NUMBER_3',  1402, **_custom_prompt_kwargs),
364         ChoiceRegister('CUSTOM_PROMPT_NUMBER_4',  1403, **_custom_prompt_kwargs),
365         ChoiceRegister('CUSTOM_PROMPT_NUMBER_5',  1404, **_custom_prompt_kwargs),
366         ChoiceRegister('CUSTOM_PROMPT_NUMBER_6',  1405, **_custom_prompt_kwargs),
367         ChoiceRegister('CUSTOM_PROMPT_NUMBER_7',  1406, **_custom_prompt_kwargs),
368         ChoiceRegister('CUSTOM_PROMPT_NUMBER_8',  1407, **_custom_prompt_kwargs),
369         ChoiceRegister('CUSTOM_PROMPT_NUMBER_9',  1408, **_custom_prompt_kwargs),
370         ChoiceRegister('CUSTOM_PROMPT_NUMBER_10', 1409, **_custom_prompt_kwargs),
371         ChoiceRegister('CUSTOM_PROMPT_NUMBER_11', 1410, **_custom_prompt_kwargs),
372         ChoiceRegister('CUSTOM_PROMPT_NUMBER_12', 1411, **_custom_prompt_kwargs),
373         ChoiceRegister('CUSTOM_PROMPT_NUMBER_13', 1412, **_custom_prompt_kwargs),
374         ChoiceRegister('CUSTOM_PROMPT_NUMBER_14', 1413, **_custom_prompt_kwargs),
375         ChoiceRegister('CUSTOM_PROMPT_NUMBER_15', 1414, **_custom_prompt_kwargs),
376         ChoiceRegister('CUSTOM_PROMPT_NUMBER_16', 1415, **_custom_prompt_kwargs),
377         FloatRegister('AMBIENT_TEMPERATURE',    1500, direction='r', reference='6.23', help='Always in deg F, regardless of C_OR_F', decimal=10.),
378         Register('AMBIENT_A_D_COUNTS',          1501, direction='r', reference='6.23'),
379         Register('CHANNEL_1_A_D_COUNTS',        1504, direction='r', reference='6.24'),
380         Register('CHANNEL_2_A_D_COUNTS',        1505, direction='r', reference='6.24'),
381         ChoiceRegister('TEST_DISPLAY',          1513, reference='6.23', choices={
382                 0: 'off', 1: 'on'}, help='Cyclic display test'),
383         ChoiceRegister('TEST_OUTPUT',           1514, reference='6.23', choices={
384                 0: 'none', 1: 'output 1', 2: 'outptut 2', 3: 'output 3',
385                 4: 'output 4', 5: 'all outputs'},
386                        help='Turns onn specific output'),
387         Register('LINE_FREQUENCY',              1515, direction='r', reference='6.24', help='AC line freq in Hz'),
388         ChoiceRegister('RESTORE_FACTORY_CALIBRATION', 1601, direction='w', reference='6.24', choices={
389                 0: 'no', 1: 'yes'}),
390         Register('DEFAULT_SETTINGS',            1602, direction='w', reference='6.24'),
391         ChoiceRegister('OVERLOADED_CALIBRATION_1', 1603, direction='w', reference='6.24, 6.25', choices={
392                 0: 'no',
393                 1: 'thermocouple, 0mV',
394                 2: 'thermocouple, 50mV',
395                 3: 'thermocouple, 32deg',
396                 4: 'ground',
397                 5: 'lead resistance',
398                 6: 'RTD, 15 Ohms',  # RTD = Resistance Temp. Detector
399                 7: 'RTD, 380 Ohms',
400                 8: 'process 1, 0V',
401                 9: 'process 1, 10V',
402                 10: 'process 1, 4mA',
403                 11: 'process 1, 20mA',
404                 }),
405         Register('OUTPUT_CALIBRATION_1_4MA',    1604, direction='w', reference='6.26'),
406         Register('OUTPUT_CALIBRATION_1_20MA',   1605, direction='w', reference='6.26'),
407         Register('OUTPUT_CALIBRATION_1_1V',     1606, direction='w', reference='6.26'),
408         Register('OUTPUT_CALIBRATION_1_10V',    1607, direction='w', reference='6.27'),
409         ChoiceRegister('OVERLOADED_CALIBRATION_2', 1608, direction='w', reference='6.26', choices={
410                 0: 'no',
411                 1: 'process 2, 0V',
412                 2: 'process 2, 10V',
413                 3: 'process 2, 4mA',
414                 4: 'process 2, 20mA',
415                 }),
416         Register('OUTPUT_CALIBRATION_2_4MA',    1609, direction='w', reference='6.27'),
417         Register('OUTPUT_CALIBRATION_2_20MA',   1610, direction='w', reference='6.27'),
418         Register('OUTPUT_CALIBRATION_2_1V',     1611, direction='w', reference='6.27'),
419         Register('OUTPUT_CALIBRATION_2_10V',    1612, direction='w', reference='6.27'),
420         Register('OUTPUT_CALIBRATION_4_4MA',    1619, direction='w', reference='6.27'),
421         Register('OUTPUT_CALIBRATION_4_20MA',   1620, direction='w', reference='6.27'),
422         Register('OUTPUT_CALIBRATION_4_1V',     1621, direction='w', reference='6.27'),
423         Register('OUTPUT_CALIBRATION_4_10V',    1622, direction='w', reference='6.27'),
424         FloatRegister('HIGH_RESOLUTION',        1707, direction='r', reference='6.23', help='High resolution input value', decimal_offset=10.),
425         ]
426     del(_custom_prompt_kwargs)
427     _register = dict((r.name, r) for r in _registers)
428
429     def __init__(self, controller=1, device='/dev/ttyS0', baudrate=9600):
430         """
431         controller : MTCA controller ID
432         device     : serial port you're using to connect to the controller
433         baudrate   : baud rate for which you've configured your controller
434         """
435         # the rated max current from controller specs
436         self._spec_max_current = 4.0  # Amps
437
438         self._controller = controller
439
440         # from the Melcor Manual, A.4 (p96), messages should be coded
441         # in eight-bit bytes, with no parity bit, and one stop bit
442         # (8N1).
443         self._client = _ModbusSerialClient(
444             method='rtu',
445             port=device,  # '/dev/ttyS0' or 0
446             bytesize=_serial.EIGHTBITS,
447             parity=_serial.PARITY_NONE,
448             stopbits=_serial.STOPBITS_ONE,
449             baudrate=baudrate,
450             timeout=0.5,
451             )
452
453         self._decimal = None
454
455     def _read(self, register_name):
456         register = self._register[register_name]
457         if 'r' not in register.direction:
458             raise ValueError(register_name)
459         if register.needs_decimal:
460             if not self._decimal:
461                 self._decimal = self._get_decimal()
462             register.decimal = self._decimal
463         rc = self._client.read_holding_registers(
464             address=register.value, count=1, unit=self._controller)
465         if rc is None:
466             raise MelcorError(
467                 ('could not read {} from {}.  Is the controller connected?'
468                  ).format(register, self._client))
469         assert rc.function_code < 0x80
470         value = rc.registers[0]
471         v = register.decode(value, decimal=self._decimal)
472         _LOG.info('read %s: %s %s (%s)' % (register_name, rc, v, rc.registers))
473         return v
474
475     def _write(self, register_name, value):
476         register = self._register[register_name]
477         if 'w' not in register.direction:
478             raise ValueError(register_name)
479         if register.needs_decimal:
480             if not self._decimal:
481                 self._decimal = self._get_decimal()
482             register.decimal = self._decimal
483         v = register.encode(value, decimal=self._decimal)
484         _LOG.info('write %s: %s (%s)' % (register_name, v, value))
485         rc = self._client.write_register(
486             address=register.value, value=v, unit=self._controller)
487         assert rc.function_code < 0x80
488
489     def _get_decimal(self):
490         return self._read('DECIMAL_1')
491
492     # Support for Backend methods
493
494     def get_pv(self):
495         return self._read('HIGH_RESOLUTION')
496
497     def get_ambient_pv(self):
498         return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
499
500     def set_max_mv(self, max):
501         """Set the max current in Amps
502
503         0.2 A is the default max current since it seems ok to use
504         without fluid cooled heatsink.  If you are cooling the
505         heatsink, use 1.0 A, which seems safely below the peltier's
506         1.2 A limit.
507
508         Note to Melcor enthusiasts: this method set's both the 'above'
509         and 'below' limits.
510         """
511         max_percent = max / self._spec_max_current * 100
512         self._write('HIGH_POWER_LIMIT_ABOVE', max_percent)
513         self._write('HIGH_POWER_LIMIT_BELOW', max_percent)
514         self._max_current = max
515
516     def get_max_mv(self):
517         percent = self._read('HIGH_POWER_LIMIT_ABOVE')
518         above = percent/100. * self._spec_max_current
519         percent = self._read('HIGH_POWER_LIMIT_BELOW')
520         below = percent/100. * self._spec_max_current
521         #setpoint = self._read('POWER_LIMIT_SET_POINT')
522         assert above == below, 'Backend() only expects a single power limit'
523         self._max_current = above
524         return above
525
526     def get_mv(self):
527         pout = self._read('PERCENT_OUTPUT')
528         cur = self._spec_max_current * pout / 100.0
529         return cur
530
531     def get_modes(self):
532         register = self._register['AUTO_MANUAL_OP_MODE']
533         return sorted(register.choices.values())
534
535     def get_mode(self):
536         return self._read('AUTO_MANUAL_OP_MODE')
537
538     def set_mode(self, mode):
539         self._write('AUTO_MANUAL_OP_MODE', mode)
540
541     def dump_configuration(self):
542         for register in self._registers:
543             if 'r' in register.direction:
544                 value = self._read(register.name)
545                 print('%s\t%s' % (register.name, value))
546
547     # ManualMixin methods
548
549     def set_mv(self, current):
550         if current > self._spec_max_current:
551             raise ValueError('current {} exceeds spec maximum {}'.format(
552                     current, self._spec_max_current))
553         pout = current / self._spec_max_current * 100.0
554         self._write('REG_MANUAL_SET_POINT', pout)
555
556     # PIDMixin methods
557
558     def set_setpoint(self, setpoint):
559         self._write('SET_POINT_1', setpoint)
560
561     def get_setpoint(self):
562         return self._read('SET_POINT_1')
563
564     def _set_gains(self, output, proportional=None, integral=None,
565                    derivative=None):
566         """
567         (output, proportional, integral, derivative, dead_band) -> None
568         output       : 1 (cooling) or 2 (heating)
569         proportional : propotional gain band in amps per degrees C
570         integral     : integral weight in minutes (0.00 to 99.99)
571         derivative   : derivative weight in minutes (? to ?)
572
573         Don't use derivative, dead time.
574         Cycle time?
575         Histerysis?
576         Burst?
577
578         See 5.10 and the pages afterwards in the manual for Melcor's
579         explanation.  The integral with respect to t' is actually only
580         from the time that T_samp has been with T_prop of T_set (not
581         -inf), and
582         """
583         if proportional is not None:
584             max_current = self.get_max_current()
585             propband = max_current/proportional
586             propband_name = 'PROPBAND_%d' % output
587             register = self._register[propband_name]
588             if propband > register.max:
589                 # round down, to support bang-bang experiments
590                 _LOG.warn(
591                     'limiting propband %d to maximum: {:n} -> {:n} C'.format(
592                         propband, register.max))
593                 propband = register.max
594             self._write(propband_name, propband)
595         if integral is not None:
596             self._write('INTEGRAL_%d' % output, integral)
597         if derivative is not None:
598             self._write('DERIVATIVE_%d' % output, derivative)
599
600     def _get_gains(self, output):
601         propband = self._read('PROPBAND_%d' % output)
602         integral = self._read('INTEGRAL_%d' % output)
603         derivative = self._read('DERIVATIVE_%d' % output)
604         max_current = self.get_max_current()
605         proportional = max_current/propband
606         return (proportional, integral, derivative)
607
608     def set_down_gains(self, proportional=None, integral=None,
609                        derivative=None):
610         self._set_gains(
611             output=1, proportional=proportional, integral=integral,
612             derivative=derivative)
613
614     def get_down_gains(self):
615         return self._get_gains(output=1)
616
617     def set_up_gains(self, proportional=None, integral=None, derivative=None):
618         self._set_gains(
619             output=2, proportional=proportional, integral=integral,
620             derivative=derivative)
621
622     def get_up_gains(self):
623         return self._get_gains(output=2)
624
625     def get_feedback_terms(self):
626         """
627         """
628         pid = int(self._read('PID_POWER_1'))
629         prop = int(self._read('PROP_TERM_1'))
630         ntgrl = int(self._read('INTEGRAL_TERM_1'))
631         deriv = int(self._read('DERIVATIVE_TERM_1'))
632         return (pid, prop, ntgrl, deriv)
633
634     def clear_integral_term(self):
635         # The controller resets the integral term when the temperature
636         # is outside the propbands
637         _LOG.debug('clearing integral term')
638         cp,ci,cd = self.get_cooling_gains()
639         hp,hi,hd = self.get_heating_gains()
640         sp = self.get_setpoint()
641         small_temp_range = 0.1
642         max_current = self.get_max_current()
643         p = max_current / small_temp_range
644         self.set_cooling_gains(proportional=p)
645         self.set_heating_gains(proportional=p)
646         while True:
647             _LOG.debug('waiting for an out-of-propband temperature')
648             if abs(self.get_temp() - sp) > small_temp_range:
649                 break  # we're out of the propband, I-term resets
650         self.set_cooling_gains(proportional=cp)
651         self.set_heating_gains(proportional=hp)
652         _LOG.debug('integral term cleared')
653
654     # utility methods
655
656     def sanity_check(self):
657         "Check that some key registers have the values we expect"
658         self._sanity_check('UNITS_TYPE',   'SI, use integral and derivative')
659         self._sanity_check('C_OR_F',       'celsius')
660         self._sanity_check('FAILURE_MODE', 'off')
661         self._sanity_check('RAMPING_MODE', 'off')
662         self._sanity_check('OUTPUT_1',     'cool')
663         self._sanity_check('OUTPUT_2',     'heat')
664         self._sanity_check('AUTO_MANUAL_OP_MODE',  'PID')
665
666     def _sanity_check(self, register_name, expected_value):
667         value = self._read(register_name)
668         if value != expected_value :
669             _LOG.error('invalid value %s for %s (expected %s)'
670                        % (value, register_name, expected_value))
671             raise ValueError(value)