1777da8bece8453e1be21e6d2e24decce43fa936
[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         decimal = self.decimal
99         if self.decimal_offset:
100             decimal *= self.decimal_offset
101         return self._float2melcor(value, decimal)
102
103     def decode(self, value, decimal=None):
104         decimal = self.decimal
105         if self.decimal_offset:
106             decimal *= self.decimal_offset
107         return self._melcor2float(value, decimal)
108
109
110 class BoundedFloatRegister (FloatRegister):
111     def __init__(self, *args, **kwargs):
112         self.min = kwargs.pop('min', None)
113         self.max = kwargs.pop('max', None)
114         super(BoundedFloatRegister, self).__init__(*args, **kwargs)
115
116     def encode(self, value, **kwargs):
117         if value < self.min or value > self.max:
118             raise ValueError('{} out of range [{}, {}] for {}'.format(
119                     value, self.min, self.max, self))
120         return super(BoundedFloatRegister, self).encode(value, **kwargs)
121
122     def decode(self, value, **kwargs):
123         return super(BoundedFloatRegister, self).decode(value, **kwargs)
124
125
126 class MelcorBackend (_Backend, _ManualMixin, _PIDMixin, _TemperatureMixin):
127     """Temperature control backend for a Melcor MTCA Temperature Controller
128
129     * PV: process temperature
130     * PV-units: degrees Celsius
131     * MV: controller current
132     * MV-units: amps
133     """
134     pv_units = 'C'
135     mv_units = 'A'
136
137     # Relative register addresses from back page of Melcor Manual.
138     # Then I went through Chapter 6 tables looking for missing
139     # registers.  References are from Series MTCA Thermoelectric
140     # Cooler Controller Instruction Manual, Revision 5.121900.
141     _custom_prompt_kwargs = {
142         'reference': '5.2, 6.20',
143         'help': 'Setup a custom menu',
144         'choices': {
145             0: 'none',
146             1: 'process 2',
147             2: 'percent output',
148             3: 'ramping set point',
149             4: 'event input status',
150             5: 'operation mode',
151             6: 'auto-tune',
152             7: 'auto-tune set point',
153             8: 'set point 2',
154             9: 'event set point',
155             10: 'local or remote calibration mode',
156             11: 'calibration offset',
157             12: 'propband 1',
158             13: 'integral 1',
159             14: 'derivative 1',
160             15: 'reset 1',
161             16: 'rate 1',
162             17: 'cycle time 1',
163             18: 'dead band 1',
164             19: 'propband 2',
165             20: 'integral 2',
166             21: 'derivative 2',
167             22: 'reset 2',
168             23: 'rate 2',
169             24: 'cycle time 2',
170             25: 'dead band 2',
171             26: 'alarm 2 high',
172             27: 'alarm 2 low',
173             28: 'alarm 3 high',
174             29: 'alarm 3 low',
175             30: 'alarm 4 high',
176             31: 'alarm 4 low',
177             32: 'proportional term',
178             33: 'integral term',
179             34: 'derivative term',
180             35: 'hysteresis 1',
181             36: 'hysteresis 2',
182             37: 'alarm hysteresis 2',
183             38: 'alarm hysteresis 3',
184             39: 'alarm hysteresis 4',
185             40: 'set point 1',
186             },
187         }
188     _registers = [
189         Register('MODEL_NUMBER',                   0, direction='r', reference='6.22'),
190         Register('SERIAL_NUMBER_1',                1, direction='r', reference='6.22', help='first 4 digits'),
191         Register('SERIAL_NUMBER_2',                2, direction='r', reference='6.22', help='last 4 digits'),
192         Register('SOFTWARE_ID_NUMBER',             3, direction='r', reference='6.22'),
193         Register('SOFTWARE_REVISION',              4, direction='r', reference='6.22'),
194         Register('DATE_OF_MANUFACTURE',            5, direction='r', reference='6.22', help='WEEK:YEAR (WWYY)'),
195         ChoiceRegister('INPUT_2_HARDWARE_ENABLED', 9, direction='r', reference='1.2, 6.22', choices={
196                 0: 'none', 5: 'process event'}, help='INPUT_2 option installed'),
197         ChoiceRegister('OUTPUT_1_HARDWARE',       16, direction='r', reference='6.23', choices={
198                 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}),
199         ChoiceRegister('OUTPUT_2_HARDWARE',       17, direction='r', reference='6.23', choices={
200                 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}),
201         ChoiceRegister('OUTPUT_3_HARDWARE',       18, direction='r', reference='6.23', choices={
202                 0: 'none', 1: 'relay'}),
203         ChoiceRegister('OUTPUT_4_HARDWARE',       19, direction='r', reference='5.9, 6.23', choices={
204                 0: 'none', 1: 'relay', 4: 'process', 6: '485', 7: '232'},
205                        help='Retransmit option installed'),
206         Register('DISABLE_NONVOLATILE_MEM',       24, reference=''),
207         FloatRegister('PROCESS_1',               100, direction='r', reference='6.3', help='Current temp (input to INPUT_1) (mdbl)'),
208         Register('ERROR_1',                      101, reference=''),
209         Register('PERCENT_OUTPUT',               103, direction='r', reference='5.4, 6.4', help="% of controller's rated maximum power/current"),
210         Register('ACTUAL_2',                     104, reference=''),
211         FloatRegister('PROCESS_2',               105, direction='r', reference='6.4', help='Value of signal input to INPUT_2'),
212         Register('ALARM_2_STATUS',               106, reference=''),
213         Register('ALARM_3_STATUS',               110, reference=''),
214         Register('ALARM_4_STATUS',               114, reference=''),
215         Register('OPERATION_MODE',               200, reference='?'),
216         ChoiceRegister('EVENT_INPUT_STATUS',     201, direction='r', reference='6.4', choices={
217                 1:True, 0:False}, help='Whether EVENT_FUNCTION satisfies EVENT_CONDITION'),
218         FloatRegister('REMOTE_SET_POINT',        202, direction='r', reference='6.3', help='Or event set point'),
219         Register('RAMPING_SET_POINT',            203, direction='r', reference='6.4', help='Active if RAMPING_MODE not set to OFF'),
220         # NOTE: sometimes the *_TERM_1 registers blib to 10x the predicted value.  I don't know why yet...
221         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.),
222         FloatRegister('PROP_TERM_1',             205, reference='Not in manual', help='(Tset-Tcur)/Tprop see temperature.tempControl.getFeedbackTerms(), active when Troubleshooting == 1.', decimal=1.),
223         FloatRegister('INTEGRAL_TERM_1',         206, reference='', decimal=1.),
224         FloatRegister('DERIVATIVE_TERM_1',       207, reference='', decimal=1.),
225         Register('SYSTEM_ERROR',                 209, reference=''),
226         Register('OPEN_LOOP_ERROR',              210, reference=''),
227         FloatRegister('SET_POINT_1',             300, reference='5.7 6.3', help='Set-point for INPUT_1'),
228         ChoiceRegister('AUTO_MANUAL_OP_MODE',    301, direction='r', reference='6.4', help='Select control mode', choices={0: 'PID', 1: 'manual'}),
229         Register('AUTO_TUNE_SETPOINT',           304, reference='6.5', help='Set auto tune setpoint as % of current set point (default 90%)'),
230         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'}),
231         FloatRegister('EVENT_SET_POINT_1',       306, reference='6.2', decimal=1.),
232         FloatRegister('BOOST_SET_POINT_1',       309, reference='1.2', help='Optional, on back plate'),
233         Register('MANUAL_SET_POINT',             310, reference='6.3', help='If AUTO_MANUAL_OP_MODE is MANUAL (manual)'),
234         Register('CLEAR_INPUT_ERRORS',           311, reference=''),
235         ChoiceRegister('LOCAL_REMOTE_1',         316, reference='5.9, 6.5', choices={
236                     0: 'local', 1: 'remote'}, help='Selects active setpoint.  Active if INPUT_2 is not OFF or EVENT'),
237         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'),
238         FloatRegister('ALARM_2_LOW',             321, reference='5.18, 6.2, 6.8'),
239         FloatRegister('ALARM_2_HIGH',            322, reference='5.18, 6.2, 6.8'),
240         Register('CLEAR_ALARMS',                 331, reference=''),
241         Register('SILENCE_ALARMS',               332, reference=''),
242         FloatRegister('ALARM_3_LOW',             340, reference='5.18, 6.2, 6.9'),
243         FloatRegister('ALARM_3_HIGH',            341, reference='5.18, 6.2, 6.9'),
244         BoundedFloatRegister('PROPBAND_1',       500, reference='6.2, 6.5', help='Width of proportional band in PID control(mdbl)', min=0, max=9999),
245         BoundedFloatRegister('INTEGRAL_1',       501, reference='6.6', help='Set integral time in minutes for output 1', decimal=100., min=0, max=99.99),
246         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),
247         BoundedFloatRegister('DERIVATIVE_1',     503, reference='6.6', help='Set derivative time in minutes', decimal=100., min=0, max=9.99),
248         BoundedFloatRegister('RATE_1',           504, reference='6.6', decimal=100., min=0, max=9.99),
249         BoundedFloatRegister('DEAD_BAND_1',      505, reference='6.2, 6.7', min=0, max=9999),
250         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.),
251         BoundedFloatRegister('HYSTERESIS_1',     507, reference='6.2, 6.6', min=1, max=9999),
252         ChoiceRegister('BURST_1',                509, reference='5.16, 6.6', choices={
253                 0: 'no', 1: 'yes'}),
254         BoundedFloatRegister('PROPBAND_2',       510, reference='6.2, 6.7', min=0, max=9999),
255         BoundedFloatRegister('INTEGRAL_2',       511, reference='6.7', decimal=100., min=0, max=99.99),
256         BoundedFloatRegister('RESET_2',          512, reference='6.7', decimal=100., min=0, max=99.99),
257         BoundedFloatRegister('DERIVATIVE_2',     513, reference='6.7', decimal=100., min=0, max=9.99),
258         BoundedFloatRegister('RATE_2',           514, reference='6.7', decimal=100., min=0, max=9.99),
259         BoundedFloatRegister('DEAD_BAND_2',      515, reference='6.2, 6.8', min=0, max=9999),
260         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.),
261         BoundedFloatRegister('HYSTERESIS_2',     517, reference='6.2, 6.8', min=1, max=9999),
262         ChoiceRegister('BURST_2',                519, reference='5.16, 6.7', choices={
263                 0: 'no', 1: 'yes'}),
264         Register('SENSOR_TYPE_1',                600, reference='5.7', help='Sensor used for INPUT_1'),
265         Register('INPUT_1',                      601, reference='5.7', help='Temperature measurement'),
266         FloatRegister('RANGE_LOW_1',             602, reference='5.7, 6.2, 6.11', help='Minimum SET_POINT_1'),
267         FloatRegister('RANGE_HIGH_1',            603, reference='5.7, 6.2, 6.11', help='Maximum SET_POINT_1'),
268         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),
269         FloatRegister('CALIBRATION_OFFSET_1',    605, reference='5.5, 6.2, 6.5', help='Offset added to INPUT_1'),
270         ChoiceRegister('DECIMAL_1',              606, reference='6.11', choices={
271                 0: 1., 1: 10., 2: 1., 3: 10., 4: 100., 5: 1000.}),
272         ChoiceRegister('INPUT_ERROR_LATCHING',   607, reference='6.18', choices={
273                     0: 'latching', 1: 'no latching'}),
274         ChoiceRegister('INPUT_2',                611, reference='5.8, 6.11', choices={
275                 0: 'off', 1: 'event', 2: '4-20mA', 3: '0-20mA', 4: '0-5V dc', 5: '1-5V dc', 6: '0-10V dc'},
276                        help='For external control'),
277         FloatRegister('RANGE_LOW_2',             612, reference='5.9, 6.2, 6.12', help='Minimum INPUT_2 signal'),
278         FloatRegister('RANGE_HIGH_2',            613, reference='5.9, 6.2, 6.12', help='Maximum INPUT_2 signal'),
279         FloatRegister('CALIBRATION_OFFSET_2',    615, reference='5.5,, 6.2, 6.12', help='Offset added to INPUT_2'),
280         ChoiceRegister('OUTPUT_1',               700, reference='6.13', choices={
281                     0: 'heat', 1: 'cool'}),
282         ChoiceRegister('PROCESS_1_TYPE',         701, reference='6.13', choices={
283                 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'}),
284         Register('HIGH_LIMIT_SET_POINT',         702, reference=''),
285         FloatRegister('POWER_LIMIT_SET_POINT',   713, reference='5.4, 6.2, 6.19', help='Temperature set point for power limits'),
286         FloatRegister('HIGH_POWER_LIMIT_ABOVE',  714, reference='5.4', help='% limit when above PLSP'),
287         FloatRegister('HIGH_POWER_LIMIT_BELOW',  715, reference='5.4', help='% limit when below PLSP'),
288         ChoiceRegister('OUTPUT_2',               717, reference='6.13', choices={
289                 0: 'off', 1: 'heat', 2: 'cool', 3: 'alarm'}),
290         ChoiceRegister('PROCESS_2_TYPE',         718, reference='6.13', choices={
291                 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'},
292                  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'),
293         ChoiceRegister('ALARM_2_TYPE',           719, reference='5.19, 6.13', choices={
294                 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'),
295         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.'),
296         ChoiceRegister('LATCHING_2',             721, reference='5.19, 6.14', choices={
297                 0: 'no', 1: 'yes'}),
298         ChoiceRegister('SILENCING_2',            722, reference='5.20, 6.14', choices={
299                 0: 'no', 1: 'yes'}),
300         ChoiceRegister('ALARM_ACTIVE_SIDES_2' ,  723, reference='6.14', choices={
301                 0: 'both', 1: 'high', 2: 'low'},
302                  help='Select which side or sides the alarm setpoints can be programmed for'),
303         ChoiceRegister('ALARM_LOGIC_2',          724, reference='6.14', choices={
304                 0: 'de-energize', 1: 'energize'},
305                  help='Select alarm 2 output condition in the alarm state.  De-energizing is the failsafe behaviour.'),
306         ChoiceRegister('ALARM_ANNUNCIATION_2',   725, reference='6.14', choices={
307                 0: 'no', 1: 'yes'}),
308         ChoiceRegister('OUTPUT_3',               734, reference='6.15', choices={
309                 0: 'off', 1: 'alarm'}),
310         ChoiceRegister('ALARM_3_TYPE',           736, reference='5.19, 6.15', choices={
311                 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'),
312         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.'),
313         ChoiceRegister('LATCHING_3',             738, reference='5.19, 6.15', choices={
314                 0: 'no', 1: 'yes'}),
315         ChoiceRegister('SILENCING_3',            739, reference='5.20, 6.15', choices={
316                 0: 'no', 1: 'yes'}),
317         ChoiceRegister('ALARM_ACTIVE_SIDES_3',   740, reference='6.15', choices={
318                 0: 'both', 1: 'high', 3: 'low'},
319                  help='Select which side or sides the alarm setpoints can be programmed for'),
320         ChoiceRegister('ALARM_LOGIC_3',          741, reference='6.16', choices={
321                 0: 'de-energize', 1: 'energize'},
322                  help='Select alarm 3 output condition in the alarm state.  De-energizing is the failsafe behaviour.'),
323         ChoiceRegister('ALARM_ANNUNCIATION_2',   742, reference='6.16', choices={
324                 0: 'no', 1: 'yes'}),
325         ChoiceRegister('UNITS_TYPE',             900, reference='6.18', choices={
326                 1: 'US, use reset and rate', 2: 'SI, use integral and derivative'}),
327         ChoiceRegister('C_OR_F',                 901, reference='6.18', choices={
328                 0: 'fahrenheit', 1: 'celsius'}),
329         ChoiceRegister('FAILURE_MODE',           902, reference='?.?, 6.18', choices={
330                 0: 'bumpless', 1: 'manual', 2: 'off'}),
331         Register('MANUAL_DEFAULT_POWER',         903, reference='6.19'),
332         ChoiceRegister('OPEN_LOOP_DETECT',       904, reference='5.21, 6.19', choices={
333                 0: 'on', 1: 'off'}),
334         ChoiceRegister('EVENT_FUNCTION',        1060, reference='5.8, 6.12', choices={
335                 0: 'none',
336                 1: 'switch to event set point',
337                 2: 'turn off control outputs and disable alarms',
338                 3: 'turn off control outputs',
339                 4: 'lock keyboard',
340                 5: 'switch to manual mode',
341                 6: 'initiate an auto-tune',
342                 7: 'clear alarm',
343                 8: 'lock everything except primary set point',
344                 },
345                        help='Selects response to INPUT_2'),
346         ChoiceRegister('EVENT_CONDITION',       1061, direction='r', reference='5.8, 6.12', choices={
347                 0: 'low', 1: 'high', 2: 'rise', 3: 'fall'},
348                        help='What behavior triggers Events'),
349         ChoiceRegister('RAMPING_MODE',          1100, reference='6.19', choices={
350                 0: 'off', 1: 'startup only', 2: 'startup or setpoint change'}),
351         Register('RAMP_RATE',                   1101, reference=''),
352         ChoiceRegister('RAMP_SCALE',            1102, reference='6.19', choices={
353                 0: 'minute', 1: 'hour'}),
354         Register('SET_POINT_MENU_LOCK',         1300, reference='6.21'),
355         Register('OPERATIONS_PAGE_MENU_LOCK',   1301, reference=''),
356         Register('SETUP_PAGE_LOCK',             1302, reference=''),
357         Register('CUSTOM_MENU_LOCK',            1304, reference=''),
358         Register('CALIBRATION_MENU_LOCK',       1305, reference=''),
359         ChoiceRegister('CUSTOM_PROMPT_NUMBER_1',  1400, **_custom_prompt_kwargs),
360         ChoiceRegister('CUSTOM_PROMPT_NUMBER_2',  1401, **_custom_prompt_kwargs),
361         ChoiceRegister('CUSTOM_PROMPT_NUMBER_3',  1402, **_custom_prompt_kwargs),
362         ChoiceRegister('CUSTOM_PROMPT_NUMBER_4',  1403, **_custom_prompt_kwargs),
363         ChoiceRegister('CUSTOM_PROMPT_NUMBER_5',  1404, **_custom_prompt_kwargs),
364         ChoiceRegister('CUSTOM_PROMPT_NUMBER_6',  1405, **_custom_prompt_kwargs),
365         ChoiceRegister('CUSTOM_PROMPT_NUMBER_7',  1406, **_custom_prompt_kwargs),
366         ChoiceRegister('CUSTOM_PROMPT_NUMBER_8',  1407, **_custom_prompt_kwargs),
367         ChoiceRegister('CUSTOM_PROMPT_NUMBER_9',  1408, **_custom_prompt_kwargs),
368         ChoiceRegister('CUSTOM_PROMPT_NUMBER_10', 1409, **_custom_prompt_kwargs),
369         ChoiceRegister('CUSTOM_PROMPT_NUMBER_11', 1410, **_custom_prompt_kwargs),
370         ChoiceRegister('CUSTOM_PROMPT_NUMBER_12', 1411, **_custom_prompt_kwargs),
371         ChoiceRegister('CUSTOM_PROMPT_NUMBER_13', 1412, **_custom_prompt_kwargs),
372         ChoiceRegister('CUSTOM_PROMPT_NUMBER_14', 1413, **_custom_prompt_kwargs),
373         ChoiceRegister('CUSTOM_PROMPT_NUMBER_15', 1414, **_custom_prompt_kwargs),
374         ChoiceRegister('CUSTOM_PROMPT_NUMBER_16', 1415, **_custom_prompt_kwargs),
375         FloatRegister('AMBIENT_TEMPERATURE',    1500, direction='r', reference='6.23', help='Always in deg F, regardless of C_OR_F', decimal=10.),
376         Register('AMBIENT_A_D_COUNTS',          1501, direction='r', reference='6.23'),
377         Register('CHANNEL_1_A_D_COUNTS',        1504, direction='r', reference='6.24'),
378         Register('CHANNEL_2_A_D_COUNTS',        1505, direction='r', reference='6.24'),
379         ChoiceRegister('TEST_DISPLAY',          1513, reference='6.23', choices={
380                 0: 'off', 1: 'on'}, help='Cyclic display test'),
381         ChoiceRegister('TEST_OUTPUT',           1514, reference='6.23', choices={
382                 0: 'none', 1: 'output 1', 2: 'outptut 2', 3: 'output 3',
383                 4: 'output 4', 5: 'all outputs'},
384                        help='Turns onn specific output'),
385         Register('LINE_FREQUENCY',              1515, direction='r', reference='6.24', help='AC line freq in Hz'),
386         ChoiceRegister('RESTORE_FACTORY_CALIBRATION', 1601, direction='w', reference='6.24', choices={
387                 0: 'no', 1: 'yes'}),
388         Register('DEFAULT_SETTINGS',            1602, direction='w', reference='6.24'),
389         ChoiceRegister('OVERLOADED_CALIBRATION_1', 1603, direction='w', reference='6.24, 6.25', choices={
390                 0: 'no',
391                 1: 'thermocouple, 0mV',
392                 2: 'thermocouple, 50mV',
393                 3: 'thermocouple, 32deg',
394                 4: 'ground',
395                 5: 'lead resistance',
396                 6: 'RTD, 15 Ohms',  # RTD = Resistance Temp. Detector
397                 7: 'RTD, 380 Ohms',
398                 8: 'process 1, 0V',
399                 9: 'process 1, 10V',
400                 10: 'process 1, 4mA',
401                 11: 'process 1, 20mA',
402                 }),
403         Register('OUTPUT_CALIBRATION_1_4MA',    1604, direction='w', reference='6.26'),
404         Register('OUTPUT_CALIBRATION_1_20MA',   1605, direction='w', reference='6.26'),
405         Register('OUTPUT_CALIBRATION_1_1V',     1606, direction='w', reference='6.26'),
406         Register('OUTPUT_CALIBRATION_1_10V',    1607, direction='w', reference='6.27'),
407         ChoiceRegister('OVERLOADED_CALIBRATION_2', 1608, direction='w', reference='6.26', choices={
408                 0: 'no',
409                 1: 'process 2, 0V',
410                 2: 'process 2, 10V',
411                 3: 'process 2, 4mA',
412                 4: 'process 2, 20mA',
413                 }),
414         Register('OUTPUT_CALIBRATION_2_4MA',    1609, direction='w', reference='6.27'),
415         Register('OUTPUT_CALIBRATION_2_20MA',   1610, direction='w', reference='6.27'),
416         Register('OUTPUT_CALIBRATION_2_1V',     1611, direction='w', reference='6.27'),
417         Register('OUTPUT_CALIBRATION_2_10V',    1612, direction='w', reference='6.27'),
418         Register('OUTPUT_CALIBRATION_4_4MA',    1619, direction='w', reference='6.27'),
419         Register('OUTPUT_CALIBRATION_4_20MA',   1620, direction='w', reference='6.27'),
420         Register('OUTPUT_CALIBRATION_4_1V',     1621, direction='w', reference='6.27'),
421         Register('OUTPUT_CALIBRATION_4_10V',    1622, direction='w', reference='6.27'),
422         FloatRegister('HIGH_RESOLUTION',        1707, direction='r', reference='6.23', help='High resolution input value', decimal_offset=10.),
423         ]
424     del(_custom_prompt_kwargs)
425     _register = dict((r.name, r) for r in _registers)
426
427     def __init__(self, controller=1, device='/dev/ttyS0', baudrate=9600):
428         """
429         controller : MTCA controller ID
430         device     : serial port you're using to connect to the controller
431         baudrate   : baud rate for which you've configured your controller
432         """
433         # the rated max current from controller specs
434         self._spec_max_current = 4.0  # Amps
435
436         self._controller = controller
437
438         # from the Melcor Manual, A.4 (p96), messages should be coded
439         # in eight-bit bytes, with no parity bit, and one stop bit
440         # (8N1).
441         self._client = _ModbusSerialClient(
442             method='rtu',
443             port=device,  # '/dev/ttyS0' or 0
444             bytesize=_serial.EIGHTBITS,
445             parity=_serial.PARITY_NONE,
446             stopbits=_serial.STOPBITS_ONE,
447             baudrate=baudrate,
448             timeout=0.5,
449             )
450
451         self._decimal = None
452
453     def _read(self, register_name):
454         register = self._register[register_name]
455         if 'r' not in register.direction:
456             raise ValueError(register_name)
457         if register.needs_decimal:
458             if not self._decimal:
459                 self._decimal = self._get_decimal()
460             register.decimal = self._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)