1 # Copyright (C) 2011-2012 W. Trevor King <wking@tremily.us>
3 # This file is part of pypid.
5 # pypid is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
10 # pypid is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License along with
15 # pypid. If not, see <http://www.gnu.org/licenses/>.
17 import struct as _struct
19 import serial as _serial
21 from pymodbus.client.sync import ModbusSerialClient as _ModbusSerialClient
23 from .. import LOG as _LOG
24 from . import Backend as _Backend
25 from . import ManualMixin as _ManualMixin
26 from . import PIDMixin as _PIDMixin
27 from . import TemperatureMixin as _TemperatureMixin
30 class MelcorError (Exception):
34 class Register (object):
35 def __init__(self, name, value, direction='rw', reference=None, help=None):
38 self.direction = direction
39 self.reference = reference
41 self.needs_decimal = False
44 return '<%s %s (%d)>' % (self.__class__.__name__, self.name, self.value)
46 def encode(self, value, **kwargs):
49 def decode(self, value, **kwargs):
53 class ChoiceRegister (Register):
54 def __init__(self, *args, **kwargs):
55 self.choices = kwargs.pop('choices')
56 super(ChoiceRegister, self).__init__(*args, **kwargs)
58 def encode(self, value, **kwargs):
59 for key,v in self.choices.items():
62 raise ValueError(value)
64 def decode(self, value, **kwargs):
66 return self.choices[value]
68 _LOG.error('unrecognized value %s for %s' % (value, self.name))
71 class FloatRegister (Register):
72 def __init__(self, *args, **kwargs):
73 self.decimal = kwargs.pop('decimal', None)
74 self.decimal_offset = kwargs.pop('decimal_offset', None)
75 super(FloatRegister, self).__init__(*args, **kwargs)
76 self.needs_decimal = not self.decimal
79 def _float2melcor(float, decimal=None):
80 """Convert a Python float into Melcor's two's-compliment representation
82 >>> m = FloatRegister._float2melcor(-3.5, decimal=10.0)
85 >>> FloatRegister._melcor2float(m, decimal=10.0)
88 return _struct.unpack('H', _struct.pack('h', int(float * decimal)))[0]
91 def _melcor2float(melcor, decimal=None):
92 """Convert Melcor's two's compliment representation to a Python float
94 >>> FloatRegister._melcor2float(65501, decimal=10.0)
97 return _struct.unpack('h', _struct.pack('H', melcor))[0] / decimal
99 def encode(self, value, **kwargs):
100 decimal = self.decimal
101 if self.decimal_offset:
102 decimal *= self.decimal_offset
103 return self._float2melcor(value, decimal)
105 def decode(self, value, decimal=None):
106 decimal = self.decimal
107 if self.decimal_offset:
108 decimal *= self.decimal_offset
109 return self._melcor2float(value, decimal)
112 class BoundedFloatRegister (FloatRegister):
113 def __init__(self, *args, **kwargs):
114 self.min = kwargs.pop('min', None)
115 self.max = kwargs.pop('max', None)
116 super(BoundedFloatRegister, self).__init__(*args, **kwargs)
118 def encode(self, value, **kwargs):
119 if value < self.min or value > self.max:
120 raise ValueError('{} out of range [{}, {}] for {}'.format(
121 value, self.min, self.max, self))
122 return super(BoundedFloatRegister, self).encode(value, **kwargs)
124 def decode(self, value, **kwargs):
125 return super(BoundedFloatRegister, self).decode(value, **kwargs)
128 class MelcorBackend (_Backend, _ManualMixin, _PIDMixin, _TemperatureMixin):
129 """Temperature control backend for a Melcor MTCA Temperature Controller
131 * PV: process temperature
132 * PV-units: degrees Celsius
133 * MV: controller current
139 # Relative register addresses from back page of Melcor Manual.
140 # Then I went through Chapter 6 tables looking for missing
141 # registers. References are from Series MTCA Thermoelectric
142 # Cooler Controller Instruction Manual, Revision 5.121900.
143 _custom_prompt_kwargs = {
144 'reference': '5.2, 6.20',
145 'help': 'Setup a custom menu',
150 3: 'ramping set point',
151 4: 'event input status',
154 7: 'auto-tune set point',
156 9: 'event set point',
157 10: 'local or remote calibration mode',
158 11: 'calibration offset',
179 32: 'proportional term',
181 34: 'derivative term',
184 37: 'alarm hysteresis 2',
185 38: 'alarm hysteresis 3',
186 39: 'alarm hysteresis 4',
191 Register('MODEL_NUMBER', 0, direction='r', reference='6.22'),
192 Register('SERIAL_NUMBER_1', 1, direction='r', reference='6.22', help='first 4 digits'),
193 Register('SERIAL_NUMBER_2', 2, direction='r', reference='6.22', help='last 4 digits'),
194 Register('SOFTWARE_ID_NUMBER', 3, direction='r', reference='6.22'),
195 Register('SOFTWARE_REVISION', 4, direction='r', reference='6.22'),
196 Register('DATE_OF_MANUFACTURE', 5, direction='r', reference='6.22', help='WEEK:YEAR (WWYY)'),
197 ChoiceRegister('INPUT_2_HARDWARE_ENABLED', 9, direction='r', reference='1.2, 6.22', choices={
198 0: 'none', 5: 'process event'}, help='INPUT_2 option installed'),
199 ChoiceRegister('OUTPUT_1_HARDWARE', 16, direction='r', reference='6.23', choices={
200 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}),
201 ChoiceRegister('OUTPUT_2_HARDWARE', 17, direction='r', reference='6.23', choices={
202 0: 'none', 1: 'relay', 2: 'solid state', 3: 'dc', 4: 'process'}),
203 ChoiceRegister('OUTPUT_3_HARDWARE', 18, direction='r', reference='6.23', choices={
204 0: 'none', 1: 'relay'}),
205 ChoiceRegister('OUTPUT_4_HARDWARE', 19, direction='r', reference='5.9, 6.23', choices={
206 0: 'none', 1: 'relay', 4: 'process', 6: '485', 7: '232'},
207 help='Retransmit option installed'),
208 Register('DISABLE_NONVOLATILE_MEM', 24, reference=''),
209 FloatRegister('PROCESS_1', 100, direction='r', reference='6.3', help='Current temp (input to INPUT_1) (mdbl)'),
210 Register('ERROR_1', 101, reference=''),
211 Register('PERCENT_OUTPUT', 103, direction='r', reference='5.4, 6.4', help="% of controller's rated maximum power/current"),
212 Register('ACTUAL_2', 104, reference=''),
213 FloatRegister('PROCESS_2', 105, direction='r', reference='6.4', help='Value of signal input to INPUT_2'),
214 Register('ALARM_2_STATUS', 106, reference=''),
215 Register('ALARM_3_STATUS', 110, reference=''),
216 Register('ALARM_4_STATUS', 114, reference=''),
217 Register('OPERATION_MODE', 200, reference='?'),
218 ChoiceRegister('EVENT_INPUT_STATUS', 201, direction='r', reference='6.4', choices={
219 1:True, 0:False}, help='Whether EVENT_FUNCTION satisfies EVENT_CONDITION'),
220 FloatRegister('REMOTE_SET_POINT', 202, direction='r', reference='6.3', help='Or event set point'),
221 Register('RAMPING_SET_POINT', 203, direction='r', reference='6.4', help='Active if RAMPING_MODE not set to OFF'),
222 # NOTE: sometimes the *_TERM_1 registers blib to 10x the predicted value. I don't know why yet...
223 FloatRegister('PID_POWER_1', 204, reference='Not in manual', help='Calculated output current %, active when Factory->Diagnostic->Troubleshooting == 1, but no modbus register for Troubleshooting (6.24).', decimal=10.),
224 FloatRegister('PROP_TERM_1', 205, reference='Not in manual', help='(Tset-Tcur)/Tprop see temperature.tempControl.getFeedbackTerms(), active when Troubleshooting == 1.', decimal=1.),
225 FloatRegister('INTEGRAL_TERM_1', 206, reference='', decimal=1.),
226 FloatRegister('DERIVATIVE_TERM_1', 207, reference='', decimal=1.),
227 Register('SYSTEM_ERROR', 209, reference=''),
228 Register('OPEN_LOOP_ERROR', 210, reference=''),
229 FloatRegister('SET_POINT_1', 300, reference='5.7 6.3', help='Set-point for INPUT_1'),
230 ChoiceRegister('AUTO_MANUAL_OP_MODE', 301, direction='r', reference='6.4', help='Select control mode', choices={0: 'PID', 1: 'manual'}),
231 Register('AUTO_TUNE_SETPOINT', 304, reference='6.5', help='Set auto tune setpoint as % of current set point (default 90%)'),
232 ChoiceRegister('AUTO_TUNE_START_1', 305, reference='6.5', help='Initiate or cancel auto-tune. Active if AUTO_MANUAL_OP_MODE is Auto (PID)', choices = {0: 'off or cancel', 1: 'initiate', 2: 'set only PID 1', 3: 'set only PID2'}),
233 FloatRegister('EVENT_SET_POINT_1', 306, reference='6.2', decimal=1.),
234 FloatRegister('BOOST_SET_POINT_1', 309, reference='1.2', help='Optional, on back plate'),
235 Register('MANUAL_SET_POINT', 310, reference='6.3', help='If AUTO_MANUAL_OP_MODE is MANUAL (manual)'),
236 Register('CLEAR_INPUT_ERRORS', 311, reference=''),
237 ChoiceRegister('LOCAL_REMOTE_1', 316, reference='5.9, 6.5', choices={
238 0: 'local', 1: 'remote'}, help='Selects active setpoint. Active if INPUT_2 is not OFF or EVENT'),
239 FloatRegister('SET_POINT_2', 319, reference='6.5', help='?boost setpoint? Active if both output 1 and output 2 are set to HEAT, or both are set to COOL, or if INPUT_2 is set to EVENT and EVENT_FUNCTION to SP'),
240 FloatRegister('ALARM_2_LOW', 321, reference='5.18, 6.2, 6.8'),
241 FloatRegister('ALARM_2_HIGH', 322, reference='5.18, 6.2, 6.8'),
242 Register('CLEAR_ALARMS', 331, reference=''),
243 Register('SILENCE_ALARMS', 332, reference=''),
244 FloatRegister('ALARM_3_LOW', 340, reference='5.18, 6.2, 6.9'),
245 FloatRegister('ALARM_3_HIGH', 341, reference='5.18, 6.2, 6.9'),
246 BoundedFloatRegister('PROPBAND_1', 500, reference='6.2, 6.5', help='Width of proportional band in PID control(mdbl)', min=0, max=9999),
247 BoundedFloatRegister('INTEGRAL_1', 501, reference='6.6', help='Set integral time in minutes for output 1', decimal=100., min=0, max=99.99),
248 BoundedFloatRegister('RESET_1', 502, reference='6.6', help='Set reset time in repeats per minute for output 1 if UNITS_TYPE set to US', decimal=100., min=0, max=99.99),
249 BoundedFloatRegister('DERIVATIVE_1', 503, reference='6.6', help='Set derivative time in minutes', decimal=100., min=0, max=9.99),
250 BoundedFloatRegister('RATE_1', 504, reference='6.6', decimal=100., min=0, max=9.99),
251 BoundedFloatRegister('DEAD_BAND_1', 505, reference='6.2, 6.7', min=0, max=9999),
252 FloatRegister('CYCLE_TIME_1', 506, reference='6.6', help='Valid range depends on output type. Relay: 5.0 to 60.0, solid state: 0.1 to 60.0. Not worth the extra call to automate this check.', decimal=10.),
253 BoundedFloatRegister('HYSTERESIS_1', 507, reference='6.2, 6.6', min=1, max=9999),
254 ChoiceRegister('BURST_1', 509, reference='5.16, 6.6', choices={
256 BoundedFloatRegister('PROPBAND_2', 510, reference='6.2, 6.7', min=0, max=9999),
257 BoundedFloatRegister('INTEGRAL_2', 511, reference='6.7', decimal=100., min=0, max=99.99),
258 BoundedFloatRegister('RESET_2', 512, reference='6.7', decimal=100., min=0, max=99.99),
259 BoundedFloatRegister('DERIVATIVE_2', 513, reference='6.7', decimal=100., min=0, max=9.99),
260 BoundedFloatRegister('RATE_2', 514, reference='6.7', decimal=100., min=0, max=9.99),
261 BoundedFloatRegister('DEAD_BAND_2', 515, reference='6.2, 6.8', min=0, max=9999),
262 FloatRegister('CYCLE_TIME_2', 516, reference='6.8', help='Valid range depends on output type. Relay: 5.0 to 60.0, solid state: 0.1 to 60.0. Not worth the extra call to automate this check.', decimal=10.),
263 BoundedFloatRegister('HYSTERESIS_2', 517, reference='6.2, 6.8', min=1, max=9999),
264 ChoiceRegister('BURST_2', 519, reference='5.16, 6.7', choices={
266 Register('SENSOR_TYPE_1', 600, reference='5.7', help='Sensor used for INPUT_1'),
267 Register('INPUT_1', 601, reference='5.7', help='Temperature measurement'),
268 FloatRegister('RANGE_LOW_1', 602, reference='5.7, 6.2, 6.11', help='Minimum SET_POINT_1'),
269 FloatRegister('RANGE_HIGH_1', 603, reference='5.7, 6.2, 6.11', help='Maximum SET_POINT_1'),
270 BoundedFloatRegister('INPUT_SOFTWARE_FILTER_1', 604, reference='5.6, 6.2, 6.11, ', help='Averaging to smooth INPUT_1 (positive only affect monitor values, negative affect both monitor and control)', decimal=10., min=-60, max=60),
271 FloatRegister('CALIBRATION_OFFSET_1', 605, reference='5.5, 6.2, 6.5', help='Offset added to INPUT_1'),
272 ChoiceRegister('DECIMAL_1', 606, reference='6.11', choices={
273 0: 1., 1: 10., 2: 1., 3: 10., 4: 100., 5: 1000.}),
274 ChoiceRegister('INPUT_ERROR_LATCHING', 607, reference='6.18', choices={
275 0: 'latching', 1: 'no latching'}),
276 ChoiceRegister('INPUT_2', 611, reference='5.8, 6.11', choices={
277 0: 'off', 1: 'event', 2: '4-20mA', 3: '0-20mA', 4: '0-5V dc', 5: '1-5V dc', 6: '0-10V dc'},
278 help='For external control'),
279 FloatRegister('RANGE_LOW_2', 612, reference='5.9, 6.2, 6.12', help='Minimum INPUT_2 signal'),
280 FloatRegister('RANGE_HIGH_2', 613, reference='5.9, 6.2, 6.12', help='Maximum INPUT_2 signal'),
281 FloatRegister('CALIBRATION_OFFSET_2', 615, reference='5.5,, 6.2, 6.12', help='Offset added to INPUT_2'),
282 ChoiceRegister('OUTPUT_1', 700, reference='6.13', choices={
283 0: 'heat', 1: 'cool'}),
284 ChoiceRegister('PROCESS_1_TYPE', 701, reference='6.13', choices={
285 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'}),
286 Register('HIGH_LIMIT_SET_POINT', 702, reference=''),
287 FloatRegister('POWER_LIMIT_SET_POINT', 713, reference='5.4, 6.2, 6.19', help='Temperature set point for power limits'),
288 FloatRegister('HIGH_POWER_LIMIT_ABOVE', 714, reference='5.4', help='% limit when above PLSP'),
289 FloatRegister('HIGH_POWER_LIMIT_BELOW', 715, reference='5.4', help='% limit when below PLSP'),
290 ChoiceRegister('OUTPUT_2', 717, reference='6.13', choices={
291 0: 'off', 1: 'heat', 2: 'cool', 3: 'alarm'}),
292 ChoiceRegister('PROCESS_2_TYPE', 718, reference='6.13', choices={
293 0: '4-20mA', 1: '0-20mA', 2: '0-5V dc', 3: '1-5V dc', 4: '0-10V dc'},
294 help='The manual claims: (0: 4-20mA, 1: 0-20mA, 2: 0-10V dc, 3: 0-5V dc, 4: 1-5V dc), but I think it has the same sttings as PROCESS_1_TYPE, because that matches the results I expect when setting PROCESS_2_TYPE from software while watching the relevant display menu'),
295 ChoiceRegister('ALARM_2_TYPE', 719, reference='5.19, 6.13', choices={
296 0: 'process', 1: 'deviation'}, help='Select alarm type. A process alarm responds when the temperature leaves a fixed range. A deviation alarm responds when the temperature deviates from the set point by a set number of degrees'),
297 FloatRegister('ALARM_HYSTERESIS_2', 720, reference='5.18, 6.2, 6.13', help='Set the switching histeresis for the alarm output. This defines a band on the inside of the alarm set point. When the process temperature is in this band, the alarm state will not change.'),
298 ChoiceRegister('LATCHING_2', 721, reference='5.19, 6.14', choices={
300 ChoiceRegister('SILENCING_2', 722, reference='5.20, 6.14', choices={
302 ChoiceRegister('ALARM_ACTIVE_SIDES_2' , 723, reference='6.14', choices={
303 0: 'both', 1: 'high', 2: 'low'},
304 help='Select which side or sides the alarm setpoints can be programmed for'),
305 ChoiceRegister('ALARM_LOGIC_2', 724, reference='6.14', choices={
306 0: 'de-energize', 1: 'energize'},
307 help='Select alarm 2 output condition in the alarm state. De-energizing is the failsafe behaviour.'),
308 ChoiceRegister('ALARM_ANNUNCIATION_2', 725, reference='6.14', choices={
310 ChoiceRegister('OUTPUT_3', 734, reference='6.15', choices={
311 0: 'off', 1: 'alarm'}),
312 ChoiceRegister('ALARM_3_TYPE', 736, reference='5.19, 6.15', choices={
313 0: 'process', 1: 'deviation'}, help='Select alarm type. A process alarm responds when the temperature leaves a fixed range. A deviation alarm responds when the temperature deviates from the set point by a set number of degrees'),
314 FloatRegister('ALARM_HYSTERESIS_3', 737, reference='5.18, 6.2, 6.15', help='Set the switching histeresis for the alarm output. This defines a band on the inside of the alarm set point. When the process temperature is in this band, the alarm state will not change.'),
315 ChoiceRegister('LATCHING_3', 738, reference='5.19, 6.15', choices={
317 ChoiceRegister('SILENCING_3', 739, reference='5.20, 6.15', choices={
319 ChoiceRegister('ALARM_ACTIVE_SIDES_3', 740, reference='6.15', choices={
320 0: 'both', 1: 'high', 3: 'low'},
321 help='Select which side or sides the alarm setpoints can be programmed for'),
322 ChoiceRegister('ALARM_LOGIC_3', 741, reference='6.16', choices={
323 0: 'de-energize', 1: 'energize'},
324 help='Select alarm 3 output condition in the alarm state. De-energizing is the failsafe behaviour.'),
325 ChoiceRegister('ALARM_ANNUNCIATION_2', 742, reference='6.16', choices={
327 ChoiceRegister('UNITS_TYPE', 900, reference='6.18', choices={
328 1: 'US, use reset and rate', 2: 'SI, use integral and derivative'}),
329 ChoiceRegister('C_OR_F', 901, reference='6.18', choices={
330 0: 'fahrenheit', 1: 'celsius'}),
331 ChoiceRegister('FAILURE_MODE', 902, reference='?.?, 6.18', choices={
332 0: 'bumpless', 1: 'manual', 2: 'off'}),
333 Register('MANUAL_DEFAULT_POWER', 903, reference='6.19'),
334 ChoiceRegister('OPEN_LOOP_DETECT', 904, reference='5.21, 6.19', choices={
336 ChoiceRegister('EVENT_FUNCTION', 1060, reference='5.8, 6.12', choices={
338 1: 'switch to event set point',
339 2: 'turn off control outputs and disable alarms',
340 3: 'turn off control outputs',
342 5: 'switch to manual mode',
343 6: 'initiate an auto-tune',
345 8: 'lock everything except primary set point',
347 help='Selects response to INPUT_2'),
348 ChoiceRegister('EVENT_CONDITION', 1061, direction='r', reference='5.8, 6.12', choices={
349 0: 'low', 1: 'high', 2: 'rise', 3: 'fall'},
350 help='What behavior triggers Events'),
351 ChoiceRegister('RAMPING_MODE', 1100, reference='6.19', choices={
352 0: 'off', 1: 'startup only', 2: 'startup or setpoint change'}),
353 Register('RAMP_RATE', 1101, reference=''),
354 ChoiceRegister('RAMP_SCALE', 1102, reference='6.19', choices={
355 0: 'minute', 1: 'hour'}),
356 Register('SET_POINT_MENU_LOCK', 1300, reference='6.21'),
357 Register('OPERATIONS_PAGE_MENU_LOCK', 1301, reference=''),
358 Register('SETUP_PAGE_LOCK', 1302, reference=''),
359 Register('CUSTOM_MENU_LOCK', 1304, reference=''),
360 Register('CALIBRATION_MENU_LOCK', 1305, reference=''),
361 ChoiceRegister('CUSTOM_PROMPT_NUMBER_1', 1400, **_custom_prompt_kwargs),
362 ChoiceRegister('CUSTOM_PROMPT_NUMBER_2', 1401, **_custom_prompt_kwargs),
363 ChoiceRegister('CUSTOM_PROMPT_NUMBER_3', 1402, **_custom_prompt_kwargs),
364 ChoiceRegister('CUSTOM_PROMPT_NUMBER_4', 1403, **_custom_prompt_kwargs),
365 ChoiceRegister('CUSTOM_PROMPT_NUMBER_5', 1404, **_custom_prompt_kwargs),
366 ChoiceRegister('CUSTOM_PROMPT_NUMBER_6', 1405, **_custom_prompt_kwargs),
367 ChoiceRegister('CUSTOM_PROMPT_NUMBER_7', 1406, **_custom_prompt_kwargs),
368 ChoiceRegister('CUSTOM_PROMPT_NUMBER_8', 1407, **_custom_prompt_kwargs),
369 ChoiceRegister('CUSTOM_PROMPT_NUMBER_9', 1408, **_custom_prompt_kwargs),
370 ChoiceRegister('CUSTOM_PROMPT_NUMBER_10', 1409, **_custom_prompt_kwargs),
371 ChoiceRegister('CUSTOM_PROMPT_NUMBER_11', 1410, **_custom_prompt_kwargs),
372 ChoiceRegister('CUSTOM_PROMPT_NUMBER_12', 1411, **_custom_prompt_kwargs),
373 ChoiceRegister('CUSTOM_PROMPT_NUMBER_13', 1412, **_custom_prompt_kwargs),
374 ChoiceRegister('CUSTOM_PROMPT_NUMBER_14', 1413, **_custom_prompt_kwargs),
375 ChoiceRegister('CUSTOM_PROMPT_NUMBER_15', 1414, **_custom_prompt_kwargs),
376 ChoiceRegister('CUSTOM_PROMPT_NUMBER_16', 1415, **_custom_prompt_kwargs),
377 FloatRegister('AMBIENT_TEMPERATURE', 1500, direction='r', reference='6.23', help='Always in deg F, regardless of C_OR_F', decimal=10.),
378 Register('AMBIENT_A_D_COUNTS', 1501, direction='r', reference='6.23'),
379 Register('CHANNEL_1_A_D_COUNTS', 1504, direction='r', reference='6.24'),
380 Register('CHANNEL_2_A_D_COUNTS', 1505, direction='r', reference='6.24'),
381 ChoiceRegister('TEST_DISPLAY', 1513, reference='6.23', choices={
382 0: 'off', 1: 'on'}, help='Cyclic display test'),
383 ChoiceRegister('TEST_OUTPUT', 1514, reference='6.23', choices={
384 0: 'none', 1: 'output 1', 2: 'outptut 2', 3: 'output 3',
385 4: 'output 4', 5: 'all outputs'},
386 help='Turns onn specific output'),
387 Register('LINE_FREQUENCY', 1515, direction='r', reference='6.24', help='AC line freq in Hz'),
388 ChoiceRegister('RESTORE_FACTORY_CALIBRATION', 1601, direction='w', reference='6.24', choices={
390 Register('DEFAULT_SETTINGS', 1602, direction='w', reference='6.24'),
391 ChoiceRegister('OVERLOADED_CALIBRATION_1', 1603, direction='w', reference='6.24, 6.25', choices={
393 1: 'thermocouple, 0mV',
394 2: 'thermocouple, 50mV',
395 3: 'thermocouple, 32deg',
397 5: 'lead resistance',
398 6: 'RTD, 15 Ohms', # RTD = Resistance Temp. Detector
402 10: 'process 1, 4mA',
403 11: 'process 1, 20mA',
405 Register('OUTPUT_CALIBRATION_1_4MA', 1604, direction='w', reference='6.26'),
406 Register('OUTPUT_CALIBRATION_1_20MA', 1605, direction='w', reference='6.26'),
407 Register('OUTPUT_CALIBRATION_1_1V', 1606, direction='w', reference='6.26'),
408 Register('OUTPUT_CALIBRATION_1_10V', 1607, direction='w', reference='6.27'),
409 ChoiceRegister('OVERLOADED_CALIBRATION_2', 1608, direction='w', reference='6.26', choices={
414 4: 'process 2, 20mA',
416 Register('OUTPUT_CALIBRATION_2_4MA', 1609, direction='w', reference='6.27'),
417 Register('OUTPUT_CALIBRATION_2_20MA', 1610, direction='w', reference='6.27'),
418 Register('OUTPUT_CALIBRATION_2_1V', 1611, direction='w', reference='6.27'),
419 Register('OUTPUT_CALIBRATION_2_10V', 1612, direction='w', reference='6.27'),
420 Register('OUTPUT_CALIBRATION_4_4MA', 1619, direction='w', reference='6.27'),
421 Register('OUTPUT_CALIBRATION_4_20MA', 1620, direction='w', reference='6.27'),
422 Register('OUTPUT_CALIBRATION_4_1V', 1621, direction='w', reference='6.27'),
423 Register('OUTPUT_CALIBRATION_4_10V', 1622, direction='w', reference='6.27'),
424 FloatRegister('HIGH_RESOLUTION', 1707, direction='r', reference='6.23', help='High resolution input value', decimal_offset=10.),
426 del(_custom_prompt_kwargs)
427 _register = dict((r.name, r) for r in _registers)
429 def __init__(self, controller=1, device='/dev/ttyS0', baudrate=9600):
431 controller : MTCA controller ID
432 device : serial port you're using to connect to the controller
433 baudrate : baud rate for which you've configured your controller
435 # the rated max current from controller specs
436 self._spec_max_current = 4.0 # Amps
438 self._controller = controller
440 # from the Melcor Manual, A.4 (p96), messages should be coded
441 # in eight-bit bytes, with no parity bit, and one stop bit
443 self._client = _ModbusSerialClient(
445 port=device, # '/dev/ttyS0' or 0
446 bytesize=_serial.EIGHTBITS,
447 parity=_serial.PARITY_NONE,
448 stopbits=_serial.STOPBITS_ONE,
455 def _read(self, register_name):
456 register = self._register[register_name]
457 if 'r' not in register.direction:
458 raise ValueError(register_name)
459 if register.needs_decimal:
460 if not self._decimal:
461 self._decimal = self._get_decimal()
462 register.decimal = self._decimal
463 rc = self._client.read_holding_registers(
464 address=register.value, count=1, unit=self._controller)
467 ('could not read {} from {}. Is the controller connected?'
468 ).format(register, self._client))
469 assert rc.function_code < 0x80
470 value = rc.registers[0]
471 v = register.decode(value, decimal=self._decimal)
472 _LOG.info('read %s: %s %s (%s)' % (register_name, rc, v, rc.registers))
475 def _write(self, register_name, value):
476 register = self._register[register_name]
477 if 'w' not in register.direction:
478 raise ValueError(register_name)
479 if register.needs_decimal:
480 if not self._decimal:
481 self._decimal = self._get_decimal()
482 register.decimal = self._decimal
483 v = register.encode(value, decimal=self._decimal)
484 _LOG.info('write %s: %s (%s)' % (register_name, v, value))
485 rc = self._client.write_register(
486 address=register.value, value=v, unit=self._controller)
487 assert rc.function_code < 0x80
489 def _get_decimal(self):
490 return self._read('DECIMAL_1')
492 # Support for Backend methods
495 return self._read('HIGH_RESOLUTION')
497 def get_ambient_pv(self):
498 return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
500 def set_max_mv(self, max):
501 """Set the max current in Amps
503 0.2 A is the default max current since it seems ok to use
504 without fluid cooled heatsink. If you are cooling the
505 heatsink, use 1.0 A, which seems safely below the peltier's
508 Note to Melcor enthusiasts: this method set's both the 'above'
511 max_percent = max / self._spec_max_current * 100
512 self._write('HIGH_POWER_LIMIT_ABOVE', max_percent)
513 self._write('HIGH_POWER_LIMIT_BELOW', max_percent)
514 self._max_current = max
516 def get_max_mv(self):
517 percent = self._read('HIGH_POWER_LIMIT_ABOVE')
518 above = percent/100. * self._spec_max_current
519 percent = self._read('HIGH_POWER_LIMIT_BELOW')
520 below = percent/100. * self._spec_max_current
521 #setpoint = self._read('POWER_LIMIT_SET_POINT')
522 assert above == below, 'Backend() only expects a single power limit'
523 self._max_current = above
527 pout = self._read('PERCENT_OUTPUT')
528 cur = self._spec_max_current * pout / 100.0
532 register = self._register['AUTO_MANUAL_OP_MODE']
533 return sorted(register.choices.values())
536 return self._read('AUTO_MANUAL_OP_MODE')
538 def set_mode(self, mode):
539 self._write('AUTO_MANUAL_OP_MODE', mode)
541 def dump_configuration(self):
542 for register in self._registers:
543 if 'r' in register.direction:
544 value = self._read(register.name)
545 print('%s\t%s' % (register.name, value))
547 # ManualMixin methods
549 def set_mv(self, current):
550 if current > self._spec_max_current:
551 raise ValueError('current {} exceeds spec maximum {}'.format(
552 current, self._spec_max_current))
553 pout = current / self._spec_max_current * 100.0
554 self._write('REG_MANUAL_SET_POINT', pout)
558 def set_setpoint(self, setpoint):
559 self._write('SET_POINT_1', setpoint)
561 def get_setpoint(self):
562 return self._read('SET_POINT_1')
564 def _set_gains(self, output, proportional=None, integral=None,
567 (output, proportional, integral, derivative, dead_band) -> None
568 output : 1 (cooling) or 2 (heating)
569 proportional : propotional gain band in amps per degrees C
570 integral : integral weight in minutes (0.00 to 99.99)
571 derivative : derivative weight in minutes (? to ?)
573 Don't use derivative, dead time.
578 See 5.10 and the pages afterwards in the manual for Melcor's
579 explanation. The integral with respect to t' is actually only
580 from the time that T_samp has been with T_prop of T_set (not
583 if proportional is not None:
584 max_current = self.get_max_current()
585 propband = max_current/proportional
586 propband_name = 'PROPBAND_%d' % output
587 register = self._register[propband_name]
588 if propband > register.max:
589 # round down, to support bang-bang experiments
591 'limiting propband %d to maximum: {:n} -> {:n} C'.format(
592 propband, register.max))
593 propband = register.max
594 self._write(propband_name, propband)
595 if integral is not None:
596 self._write('INTEGRAL_%d' % output, integral)
597 if derivative is not None:
598 self._write('DERIVATIVE_%d' % output, derivative)
600 def _get_gains(self, output):
601 propband = self._read('PROPBAND_%d' % output)
602 integral = self._read('INTEGRAL_%d' % output)
603 derivative = self._read('DERIVATIVE_%d' % output)
604 max_current = self.get_max_current()
605 proportional = max_current/propband
606 return (proportional, integral, derivative)
608 def set_down_gains(self, proportional=None, integral=None,
611 output=1, proportional=proportional, integral=integral,
612 derivative=derivative)
614 def get_down_gains(self):
615 return self._get_gains(output=1)
617 def set_up_gains(self, proportional=None, integral=None, derivative=None):
619 output=2, proportional=proportional, integral=integral,
620 derivative=derivative)
622 def get_up_gains(self):
623 return self._get_gains(output=2)
625 def get_feedback_terms(self):
628 pid = int(self._read('PID_POWER_1'))
629 prop = int(self._read('PROP_TERM_1'))
630 ntgrl = int(self._read('INTEGRAL_TERM_1'))
631 deriv = int(self._read('DERIVATIVE_TERM_1'))
632 return (pid, prop, ntgrl, deriv)
634 def clear_integral_term(self):
635 # The controller resets the integral term when the temperature
636 # is outside the propbands
637 _LOG.debug('clearing integral term')
638 cp,ci,cd = self.get_cooling_gains()
639 hp,hi,hd = self.get_heating_gains()
640 sp = self.get_setpoint()
641 small_temp_range = 0.1
642 max_current = self.get_max_current()
643 p = max_current / small_temp_range
644 self.set_cooling_gains(proportional=p)
645 self.set_heating_gains(proportional=p)
647 _LOG.debug('waiting for an out-of-propband temperature')
648 if abs(self.get_temp() - sp) > small_temp_range:
649 break # we're out of the propband, I-term resets
650 self.set_cooling_gains(proportional=cp)
651 self.set_heating_gains(proportional=hp)
652 _LOG.debug('integral term cleared')
656 def sanity_check(self):
657 "Check that some key registers have the values we expect"
658 self._sanity_check('UNITS_TYPE', 'SI, use integral and derivative')
659 self._sanity_check('C_OR_F', 'celsius')
660 self._sanity_check('FAILURE_MODE', 'off')
661 self._sanity_check('RAMPING_MODE', 'off')
662 self._sanity_check('OUTPUT_1', 'cool')
663 self._sanity_check('OUTPUT_2', 'heat')
664 self._sanity_check('AUTO_MANUAL_OP_MODE', 'PID')
666 def _sanity_check(self, register_name, expected_value):
667 value = self._read(register_name)
668 if value != expected_value :
669 _LOG.error('invalid value %s for %s (expected %s)'
670 % (value, register_name, expected_value))
671 raise ValueError(value)