Also fix decimal handling for Melcor floats based on DECIMAL_1 during writes.
[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:
474             if not self._decimal:
475                 self._decimal = self._get_decimal()
476             register.decimal = self._decimal
477         v = register.encode(value, decimal=self._decimal)
478         _LOG.info('write %s: %s (%s)' % (register_name, v, value))
479         rc = self._client.write_register(
480             address=register.value, value=v, unit=self._controller)
481         assert rc.function_code < 0x80
482
483     def _get_decimal(self):
484         return self._read('DECIMAL_1')
485
486     # Support for Backend methods
487
488     def get_pv(self):
489         return self._read('HIGH_RESOLUTION')
490
491     def get_ambient_pv(self):
492         return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
493
494     def set_max_mv(self, max):
495         """Set the max current in Amps
496
497         0.2 A is the default max current since it seems ok to use
498         without fluid cooled heatsink.  If you are cooling the
499         heatsink, use 1.0 A, which seems safely below the peltier's
500         1.2 A limit.
501
502         Note to Melcor enthusiasts: this method set's both the 'above'
503         and 'below' limits.
504         """
505         max_percent = max / self._spec_max_current * 100
506         self._write('HIGH_POWER_LIMIT_ABOVE', max_percent)
507         self._write('HIGH_POWER_LIMIT_BELOW', max_percent)
508         self._max_current = max
509
510     def get_max_mv(self):
511         percent = self._read('HIGH_POWER_LIMIT_ABOVE')
512         above = percent/100. * self._spec_max_current
513         percent = self._read('HIGH_POWER_LIMIT_BELOW')
514         below = percent/100. * self._spec_max_current
515         #setpoint = self._read('POWER_LIMIT_SET_POINT')
516         assert above == below, 'Backend() only expects a single power limit'
517         self._max_current = above
518         return above
519
520     def get_mv(self):
521         pout = self._read('PERCENT_OUTPUT')
522         cur = self._spec_max_current * pout / 100.0
523         return cur
524
525     def get_modes(self):
526         register = self._register['AUTO_MANUAL_OP_MODE']
527         return sorted(register.choices.values())
528
529     def get_mode(self):
530         return self._read('AUTO_MANUAL_OP_MODE')
531
532     def set_mode(self, mode):
533         self._write('AUTO_MANUAL_OP_MODE', mode)
534
535     def dump_configuration(self):
536         for register in self._registers:
537             if 'r' in register.direction:
538                 value = self._read(register.name)
539                 print('%s\t%s' % (register.name, value))
540
541     # ManualMixin methods
542
543     def set_mv(self, current):
544         if current > self._spec_max_current:
545             raise ValueError('current {} exceeds spec maximum {}'.format(
546                     current, self._spec_max_current))
547         pout = current / self._spec_max_current * 100.0
548         self._write('REG_MANUAL_SET_POINT', pout)
549
550     # PIDMixin methods
551
552     def set_setpoint(self, setpoint):
553         self._write('SET_POINT_1', setpoint)
554
555     def get_setpoint(self):
556         return self._read('SET_POINT_1')
557
558     def _set_gains(self, output, proportional=None, integral=None,
559                    derivative=None):
560         """
561         (output, proportional, integral, derivative, dead_band) -> None
562         output       : 1 (cooling) or 2 (heating)
563         proportional : propotional gain band in amps per degrees C
564         integral     : integral weight in minutes (0.00 to 99.99)
565         derivative   : derivative weight in minutes (? to ?)
566
567         Don't use derivative, dead time.
568         Cycle time?
569         Histerysis?
570         Burst?
571
572         See 5.10 and the pages afterwards in the manual for Melcor's
573         explanation.  The integral with respect to t' is actually only
574         from the time that T_samp has been with T_prop of T_set (not
575         -inf), and
576         """
577         if proportional is not None:
578             max_current = self.get_max_current()
579             propband = max_current/proportional
580             propband_name = 'PROPBAND_%d' % output
581             register = self._register[propband_name]
582             if propband > register.max:
583                 # round down, to support bang-bang experiments
584                 _LOG.warn(
585                     'limiting propband %d to maximum: {:n} -> {:n} C'.format(
586                         propband, register.max))
587                 propband = register.max
588             self._write(propband_name, propband)
589         if integral is not None:
590             self._write('INTEGRAL_%d' % output, integral)
591         if derivative is not None:
592             self._write('DERIVATIVE_%d' % output, derivative)
593
594     def _get_gains(self, output):
595         propband = self._read('PROPBAND_%d' % output)
596         integral = self._read('INTEGRAL_%d' % output)
597         derivative = self._read('DERIVATIVE_%d' % output)
598         max_current = self.get_max_current()
599         proportional = max_current/propband
600         return (proportional, integral, derivative)
601
602     def set_down_gains(self, proportional=None, integral=None,
603                        derivative=None):
604         self._set_gains(
605             output=1, proportional=proportional, integral=integral,
606             derivative=derivative)
607
608     def get_down_gains(self):
609         return self._get_gains(output=1)
610
611     def set_up_gains(self, proportional=None, integral=None, derivative=None):
612         self._set_gains(
613             output=2, proportional=proportional, integral=integral,
614             derivative=derivative)
615
616     def get_up_gains(self):
617         return self._get_gains(output=2)
618
619     def get_feedback_terms(self):
620         """
621         """
622         pid = int(self._read('PID_POWER_1'))
623         prop = int(self._read('PROP_TERM_1'))
624         ntgrl = int(self._read('INTEGRAL_TERM_1'))
625         deriv = int(self._read('DERIVATIVE_TERM_1'))
626         return (pid, prop, ntgrl, deriv)
627
628     def clear_integral_term(self):
629         # The controller resets the integral term when the temperature
630         # is outside the propbands
631         _LOG.debug('clearing integral term')
632         cp,ci,cd = self.get_cooling_gains()
633         hp,hi,hd = self.get_heating_gains()
634         sp = self.get_setpoint()
635         small_temp_range = 0.1
636         max_current = self.get_max_current()
637         p = max_current / small_temp_range
638         self.set_cooling_gains(proportional=p)
639         self.set_heating_gains(proportional=p)
640         while True:
641             _LOG.debug('waiting for an out-of-propband temperature')
642             if abs(self.get_temp() - sp) > small_temp_range:
643                 break  # we're out of the propband, I-term resets
644         self.set_cooling_gains(proportional=cp)
645         self.set_heating_gains(proportional=hp)
646         _LOG.debug('integral term cleared')
647
648     # utility methods
649
650     def sanity_check(self):
651         "Check that some key registers have the values we expect"
652         self._sanity_check('UNITS_TYPE',   'SI, use integral and derivative')
653         self._sanity_check('C_OR_F',       'celsius')
654         self._sanity_check('FAILURE_MODE', 'off')
655         self._sanity_check('RAMPING_MODE', 'off')
656         self._sanity_check('OUTPUT_1',     'cool')
657         self._sanity_check('OUTPUT_2',     'heat')
658         self._sanity_check('AUTO_MANUAL_OP_MODE',  'PID')
659
660     def _sanity_check(self, register_name, expected_value):
661         value = self._read(register_name)
662         if value != expected_value :
663             _LOG.error('invalid value %s for %s (expected %s)'
664                        % (value, register_name, expected_value))
665             raise ValueError(value)