1d9d0f779f9d9c52007d68d89c05b89658855ba7
[pypid.git] / pypid / backend / melcor.py
1 # Copyright (C) 2008-2011 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of pypid.
4 #
5 # pypid is free software: you can redistribute it and/or
6 # modify it under the terms of the GNU Lesser General Public
7 # License as published by the Free Software Foundation, either
8 # version 3 of the License, or (at your option) any later version.
9 #
10 # pypid is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU Lesser General Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with pypid.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 import struct as _struct
20
21 import serial as _serial
22
23 from pymodbus.client.sync import ModbusSerialClient as _ModbusSerialClient
24
25 from .. import LOG as _LOG
26 from . import Backend as _Backend
27 from . import ManualMixin as _ManualMixin
28 from . import PIDMixin as _PIDMixin
29 from . import TemperatureMixin as _TemperatureMixin
30
31
32 class Register (object):
33     def __init__(self, name, value, direction='rw', reference=None, help=None):
34         self.name = name
35         self.value = value
36         self.direction = direction
37         self.reference = reference
38         self.help = help
39         self.needs_decimal = False
40
41     def __str__(self):
42         return '<%s %s (%d)>' % (self.__class__.__name__, self.name, self.value)
43
44     def encode(self, value, **kwargs):
45         return value
46
47     def decode(self, value, **kwargs):
48         return value
49
50
51 class ChoiceRegister (Register):
52     def __init__(self, *args, **kwargs):
53         self.choices = kwargs.pop('choices')
54         super(ChoiceRegister, self).__init__(*args, **kwargs)
55
56     def encode(self, value, **kwargs):
57         for key,v in self.choices.items():
58             if v == value:
59                 return key
60         raise ValueError(value)
61
62     def decode(self, value, **kwargs):
63         try:
64             return self.choices[value]
65         except KeyError:
66             _LOG.error('unrecognized value %s for %s' % (value, self.name))
67
68
69 class FloatRegister (Register):
70     def __init__(self, *args, **kwargs):
71         self.decimal = kwargs.pop('decimal', None)
72         self.decimal_offset = kwargs.pop('decimal_offset', None)
73         super(FloatRegister, self).__init__(*args, **kwargs)
74         self.needs_decimal = not self.decimal
75
76     @staticmethod
77     def _float2melcor(float, decimal=None):
78         """Convert a Python float into Melcor's two's-compliment representation
79
80         >>> m = FloatRegister._float2melcor(-3.5, decimal=10.0)
81         >>> m
82         65501
83         >>> FloatRegister._melcor2float(m, decimal=10.0)
84         -3.5
85         """
86         return _struct.unpack('H', _struct.pack('h', int(float * decimal)))[0]
87
88     @staticmethod
89     def _melcor2float(melcor, decimal=None):
90         """Convert Melcor's two's compliment representation to a Python float
91
92         >>> FloatRegister._melcor2float(65501, decimal=10.0)
93         -3.5
94         """
95         return _struct.unpack('h', _struct.pack('H', melcor))[0] / decimal
96
97     def encode(self, value, **kwargs):
98         if self.decimal:
99             decimal = self.decimal
100         elif self.decimal_offset:
101             decimal *= self.decimal_offset
102         return self._float2melcor(value, decimal)
103
104     def decode(self, value, decimal=None):
105         if self.decimal:
106             decimal = self.decimal
107         elif 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 and not self._decimal:
460             self._decimal = self._get_decimal()
461         rc = self._client.read_holding_registers(
462             address=register.value, count=1, unit=self._controller)
463         assert rc.function_code < 0x80
464         value = rc.registers[0]
465         v = register.decode(value, decimal=self._decimal)
466         _LOG.info('read %s: %s %s (%s)' % (register_name, rc, v, rc.registers))
467         return v
468
469     def _write(self, register_name, value):
470         register = self._register[register_name]
471         if 'w' not in register.direction:
472             raise ValueError(register_name)
473         if register.needs_decimal and not self._decimal:
474             self._decimal = self._get_decimal()
475         v = register.encode(value, decimal=self._decimal)
476         _LOG.info('write %s: %s (%s)' % (register_name, v, value))
477         rc = self._client.write_register(
478             address=register.value, value=v, unit=self._controller)
479         assert rc.function_code < 0x80
480
481     def _get_decimal(self):
482         return self._read('DECIMAL_1')
483
484     # Support for Backend methods
485
486     def get_pv(self):
487         return self._read('HIGH_RESOLUTION')
488
489     def get_ambient_pv(self):
490         return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
491
492     def set_max_mv(self, max):
493         """Set the max current in Amps
494
495         0.2 A is the default max current since it seems ok to use
496         without fluid cooled heatsink.  If you are cooling the
497         heatsink, use 1.0 A, which seems safely below the peltier's
498         1.2 A limit.
499
500         Note to Melcor enthusiasts: this method set's both the 'above'
501         and 'below' limits.
502         """
503         max_percent = max / self._spec_max_current * 100
504         self._write('HIGH_POWER_LIMIT_ABOVE', max_percent)
505         self._write('HIGH_POWER_LIMIT_BELOW', max_percent)
506         self._max_current = max
507
508     def get_max_mv(self):
509         percent = self._read('HIGH_POWER_LIMIT_ABOVE')
510         above = percent/100. * self._spec_max_current
511         percent = self._read('HIGH_POWER_LIMIT_BELOW')
512         below = percent/100. * self._spec_max_current
513         #setpoint = self._read('POWER_LIMIT_SET_POINT')
514         assert above == below, 'Backend() only expects a single power limit'
515         self._max_current = above
516         return above
517
518     def get_mv(self):
519         pout = self._read('PERCENT_OUTPUT')
520         cur = self._spec_max_current * pout / 100.0
521         return cur
522
523     def get_modes(self):
524         register = self._register['AUTO_MANUAL_OP_MODE']
525         return sorted(register.choices.values())
526
527     def get_mode(self):
528         return self._read('AUTO_MANUAL_OP_MODE')
529
530     def set_mode(self, mode):
531         self._write('AUTO_MANUAL_OP_MODE', mode)
532
533     def dump_configuration(self):
534         for register in self._registers:
535             if 'r' in register.direction:
536                 value = self._read(register.name)
537                 print('%s\t%s' % (register.name, value))
538
539     # ManualMixin methods
540
541     def set_mv(self, current):
542         if current > self._spec_max_current:
543             raise ValueError('current {} exceeds spec maximum {}'.format(
544                     current, self._spec_max_current))
545         pout = current / self._spec_max_current * 100.0
546         self._write('REG_MANUAL_SET_POINT', pout)
547
548     # PIDMixin methods
549
550     def set_setpoint(self, setpoint):
551         self._write('SET_POINT_1', setpoint)
552
553     def get_setpoint(self):
554         return self._read('SET_POINT_1')
555
556     def _set_gains(self, output, proportional=None, integral=None,
557                    derivative=None):
558         """
559         (output, proportional, integral, derivative, dead_band) -> None
560         output       : 1 (cooling) or 2 (heating)
561         proportional : propotional gain band in amps per degrees C
562         integral     : integral weight in minutes (0.00 to 99.99)
563         derivative   : derivative weight in minutes (? to ?)
564
565         Don't use derivative, dead time.
566         Cycle time?
567         Histerysis?
568         Burst?
569
570         See 5.10 and the pages afterwards in the manual for Melcor's
571         explanation.  The integral with respect to t' is actually only
572         from the time that T_samp has been with T_prop of T_set (not
573         -inf), and
574         """
575         if proportional is not None:
576             max_current = self.get_max_current()
577             propband = max_current/proportional
578             propband_name = 'PROPBAND_%d' % output
579             register = self._register[propband_name]
580             if propband > register.max:
581                 # round down, to support bang-bang experiments
582                 _LOG.warn(
583                     'limiting propband %d to maximum: {:n} -> {:n} C'.format(
584                         propband, register.max))
585                 propband = register.max
586             self._write(propband_name, propband)
587         if integral is not None:
588             self._write('INTEGRAL_%d' % output, integral)
589         if derivative is not None:
590             self._write('DERIVATIVE_%d' % output, derivative)
591
592     def _get_gains(self, output):
593         propband = self._read('PROPBAND_%d' % output)
594         integral = self._read('INTEGRAL_%d' % output)
595         derivative = self._read('DERIVATIVE_%d' % output)
596         max_current = self.get_max_current()
597         proportional = max_current/propband
598         return (proportional, integral, derivative)
599
600     def set_down_gains(self, proportional=None, integral=None,
601                        derivative=None):
602         self._set_gains(
603             output=1, proportional=proportional, integral=integral,
604             derivative=derivative)
605
606     def get_down_gains(self):
607         return self._get_gains(output=1)
608
609     def set_up_gains(self, proportional=None, integral=None, derivative=None):
610         self._set_gains(
611             output=2, proportional=proportional, integral=integral,
612             derivative=derivative)
613
614     def get_up_gains(self):
615         return self._get_gains(output=2)
616
617     def get_feedback_terms(self):
618         """
619         """
620         pid = int(self._read('PID_POWER_1'))
621         prop = int(self._read('PROP_TERM_1'))
622         ntgrl = int(self._read('INTEGRAL_TERM_1'))
623         deriv = int(self._read('DERIVATIVE_TERM_1'))
624         return (pid, prop, ntgrl, deriv)
625
626     def clear_integral_term(self):
627         # The controller resets the integral term when the temperature
628         # is outside the propbands
629         _LOG.debug('clearing integral term')
630         cp,ci,cd = self.get_cooling_gains()
631         hp,hi,hd = self.get_heating_gains()
632         sp = self.get_setpoint()
633         small_temp_range = 0.1
634         max_current = self.get_max_current()
635         p = max_current / small_temp_range
636         self.set_cooling_gains(proportional=p)
637         self.set_heating_gains(proportional=p)
638         while True:
639             _LOG.debug('waiting for an out-of-propband temperature')
640             if abs(self.get_temp() - sp) > small_temp_range:
641                 break  # we're out of the propband, I-term resets
642         self.set_cooling_gains(proportional=cp)
643         self.set_heating_gains(proportional=hp)
644         _LOG.debug('integral term cleared')
645
646     # utility methods
647
648     def sanity_check(self):
649         "Check that some key registers have the values we expect"
650         self._sanity_check('UNITS_TYPE',   'SI, use integral and derivative')
651         self._sanity_check('C_OR_F',       'celsius')
652         self._sanity_check('FAILURE_MODE', 'off')
653         self._sanity_check('RAMPING_MODE', 'off')
654         self._sanity_check('OUTPUT_1',     'cool')
655         self._sanity_check('OUTPUT_2',     'heat')
656         self._sanity_check('AUTO_MANUAL_OP_MODE',  'PID')
657
658     def _sanity_check(self, register_name, expected_value):
659         value = self._read(register_name)
660         if value != expected_value :
661             _LOG.error('invalid value %s for %s (expected %s)'
662                        % (value, register_name, expected_value))
663             raise ValueError(value)