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