1 # Copyright (C) 2008-2011 W. Trevor King <wking@drexel.edu>
3 # This file is part of tempcontrol.
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.
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.
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/>.
19 import struct as _struct
21 import serial as _serial
23 from pymodbus.client.sync import ModbusSerialClient as _ModbusSerialClient
25 from .. import LOG as _LOG
26 from . import Backend as _Backend
27 from . import ManualMixin as _ManualMixin
28 from . import PIDMixin as _PIDMixin
31 class Register (object):
32 def __init__(self, name, value, direction='rw', reference=None, help=None):
35 self.direction = direction
36 self.reference = reference
38 self.needs_decimal = False
41 return '<%s %s (%d)>' % (self.__class__.__name__, self.name, self.value)
43 def encode(self, value, **kwargs):
46 def decode(self, value, **kwargs):
50 class ChoiceRegister (Register):
51 def __init__(self, *args, **kwargs):
52 self.choices = kwargs.pop('choices')
53 super(ChoiceRegister, self).__init__(*args, **kwargs)
55 def encode(self, value, **kwargs):
56 for key,v in self.choices.items():
59 raise ValueError(value)
61 def decode(self, value, **kwargs):
63 return self.choices[value]
65 _LOG.error('unrecognized value %s for %s' % (value, self.name))
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
76 def _float2melcor(float, decimal=None):
77 """Convert a Python float into Melcor's two's-compliment representation
79 >>> m = FloatRegister._float2melcor(-3.5, decimal=10.0)
82 >>> FloatRegister._melcor2float(m, decimal=10.0)
85 return _struct.unpack('H', _struct.pack('h', int(float * decimal)))[0]
88 def _melcor2float(melcor, decimal=None):
89 """Convert Melcor's two's compliment representation to a Python float
91 >>> FloatRegister._melcor2float(65501, decimal=10.0)
94 return _struct.unpack('h', _struct.pack('H', melcor))[0] / decimal
96 def encode(self, value, **kwargs):
98 decimal = self.decimal
99 elif self.decimal_offset:
100 decimal *= self.decimal_offset
101 return self._float2melcor(value, decimal)
103 def decode(self, value, decimal=None):
105 decimal = self.decimal
106 elif self.decimal_offset:
107 decimal *= self.decimal_offset
108 return self._melcor2float(value, decimal)
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)
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)
123 def decode(self, value, **kwargs):
124 return super(BoundedFloatRegister, self).decode(value, **kwargs)
127 class MelcorBackend (_Backend, _ManualMixin, _PIDMixin):
128 """Temperature control backend for a Melcor MTCA Temperature Controller
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',
141 3: 'ramping set point',
142 4: 'event input status',
145 7: 'auto-tune set point',
147 9: 'event set point',
148 10: 'local or remote calibration mode',
149 11: 'calibration offset',
170 32: 'proportional term',
172 34: 'derivative term',
175 37: 'alarm hysteresis 2',
176 38: 'alarm hysteresis 3',
177 39: 'alarm hysteresis 4',
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={
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={
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={
291 ChoiceRegister('SILENCING_2', 722, reference='5.20, 6.14', choices={
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={
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={
308 ChoiceRegister('SILENCING_3', 739, reference='5.20, 6.15', choices={
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={
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={
327 ChoiceRegister('EVENT_FUNCTION', 1060, reference='5.8, 6.12', choices={
329 1: 'switch to event set point',
330 2: 'turn off control outputs and disable alarms',
331 3: 'turn off control outputs',
333 5: 'switch to manual mode',
334 6: 'initiate an auto-tune',
336 8: 'lock everything except primary set point',
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={
381 Register('DEFAULT_SETTINGS', 1602, direction='w', reference='6.24'),
382 ChoiceRegister('OVERLOADED_CALIBRATION_1', 1603, direction='w', reference='6.24, 6.25', choices={
384 1: 'thermocouple, 0mV',
385 2: 'thermocouple, 50mV',
386 3: 'thermocouple, 32deg',
388 5: 'lead resistance',
389 6: 'RTD, 15 Ohms', # RTD = Resistance Temp. Detector
393 10: 'process 1, 4mA',
394 11: 'process 1, 20mA',
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={
405 4: 'process 2, 20mA',
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.),
417 del(_custom_prompt_kwargs)
418 _register = dict((r.name, r) for r in _registers)
420 def __init__(self, controller=1, device='/dev/ttyS0', baudrate=9600):
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
426 # the rated max current from controller specs
427 self._spec_max_current = 4.0 # Amps
429 self._controller = controller
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
434 self._client = _ModbusSerialClient(
436 port=device, # '/dev/ttyS0' or 0
437 bytesize=_serial.EIGHTBITS,
438 parity=_serial.PARITY_NONE,
439 stopbits=_serial.STOPBITS_ONE,
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))
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
472 def _get_decimal(self):
473 return self._read('DECIMAL_1')
475 # Support for Backend methods
478 return self._read('HIGH_RESOLUTION')
480 def get_ambient_temp(self):
481 return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
483 def set_max_current(self, max):
484 """Set the max current in Amps
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
491 Note to Melcor enthusiasts: this method set's both the 'above'
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
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
509 def get_current(self):
510 pout = self._read('PERCENT_OUTPUT')
511 cur = self._spec_max_current * pout / 100.0
515 register = self._register['AUTO_MANUAL_OP_MODE']
516 return sorted(register.choices.values())
519 return self._read('AUTO_MANUAL_OP_MODE')
521 def set_mode(self, mode):
522 self._write('AUTO_MANUAL_OP_MODE', mode)
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))
530 # ManualMixin methods
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)
541 def set_setpoint(self, setpoint):
542 self._write('SET_POINT_1', setpoint)
544 def get_setpoint(self):
545 return self._read('SET_POINT_1')
547 def _set_gains(self, output, proportional=None, integral=None,
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 ?)
556 Don't use derivative, dead time.
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
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
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)
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)
591 def set_cooling_gains(self, proportional=None, integral=None,
594 output=1, proportional=proportional, integral=integral,
595 derivative=derivative)
597 def get_cooling_gains(self):
598 return self._get_gains(output=1)
600 def set_heating_gains(self, proportional=None, integral=None,
603 output=2, proportional=proportional, integral=integral,
604 derivative=derivative)
606 def get_heating_gains(self):
607 return self._get_gains(output=2)
609 def get_feedback_terms(self):
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)
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)
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')
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')
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)