1 # Copyright (C) 2008-2011 W. Trevor King <wking@drexel.edu>
3 # This file is part of pypid.
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.
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.
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/>.
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
29 from . import TemperatureMixin as _TemperatureMixin
32 class Register (object):
33 def __init__(self, name, value, direction='rw', reference=None, help=None):
36 self.direction = direction
37 self.reference = reference
39 self.needs_decimal = False
42 return '<%s %s (%d)>' % (self.__class__.__name__, self.name, self.value)
44 def encode(self, value, **kwargs):
47 def decode(self, value, **kwargs):
51 class ChoiceRegister (Register):
52 def __init__(self, *args, **kwargs):
53 self.choices = kwargs.pop('choices')
54 super(ChoiceRegister, self).__init__(*args, **kwargs)
56 def encode(self, value, **kwargs):
57 for key,v in self.choices.items():
60 raise ValueError(value)
62 def decode(self, value, **kwargs):
64 return self.choices[value]
66 _LOG.error('unrecognized value %s for %s' % (value, self.name))
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
77 def _float2melcor(float, decimal=None):
78 """Convert a Python float into Melcor's two's-compliment representation
80 >>> m = FloatRegister._float2melcor(-3.5, decimal=10.0)
83 >>> FloatRegister._melcor2float(m, decimal=10.0)
86 return _struct.unpack('H', _struct.pack('h', int(float * decimal)))[0]
89 def _melcor2float(melcor, decimal=None):
90 """Convert Melcor's two's compliment representation to a Python float
92 >>> FloatRegister._melcor2float(65501, decimal=10.0)
95 return _struct.unpack('h', _struct.pack('H', melcor))[0] / decimal
97 def encode(self, value, **kwargs):
99 decimal = self.decimal
100 elif self.decimal_offset:
101 decimal *= self.decimal_offset
102 return self._float2melcor(value, decimal)
104 def decode(self, value, decimal=None):
106 decimal = self.decimal
107 elif 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 and not self._decimal:
460 self._decimal = self._get_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))
469 def _write(self, register_name, value):
470 register = self._register[register_name]
471 if 'w' not in register.direction:
472 raise ValueError(register_name)
473 if register.needs_decimal and not self._decimal:
474 self._decimal = self._get_decimal()
475 v = register.encode(value, decimal=self._decimal)
476 _LOG.info('write %s: %s (%s)' % (register_name, v, value))
477 rc = self._client.write_register(
478 address=register.value, value=v, unit=self._controller)
479 assert rc.function_code < 0x80
481 def _get_decimal(self):
482 return self._read('DECIMAL_1')
484 # Support for Backend methods
487 return self._read('HIGH_RESOLUTION')
489 def get_ambient_pv(self):
490 return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
492 def set_max_mv(self, max):
493 """Set the max current in Amps
495 0.2 A is the default max current since it seems ok to use
496 without fluid cooled heatsink. If you are cooling the
497 heatsink, use 1.0 A, which seems safely below the peltier's
500 Note to Melcor enthusiasts: this method set's both the 'above'
503 max_percent = max / self._spec_max_current * 100
504 self._write('HIGH_POWER_LIMIT_ABOVE', max_percent)
505 self._write('HIGH_POWER_LIMIT_BELOW', max_percent)
506 self._max_current = max
508 def get_max_mv(self):
509 percent = self._read('HIGH_POWER_LIMIT_ABOVE')
510 above = percent/100. * self._spec_max_current
511 percent = self._read('HIGH_POWER_LIMIT_BELOW')
512 below = percent/100. * self._spec_max_current
513 #setpoint = self._read('POWER_LIMIT_SET_POINT')
514 assert above == below, 'Backend() only expects a single power limit'
515 self._max_current = above
519 pout = self._read('PERCENT_OUTPUT')
520 cur = self._spec_max_current * pout / 100.0
524 register = self._register['AUTO_MANUAL_OP_MODE']
525 return sorted(register.choices.values())
528 return self._read('AUTO_MANUAL_OP_MODE')
530 def set_mode(self, mode):
531 self._write('AUTO_MANUAL_OP_MODE', mode)
533 def dump_configuration(self):
534 for register in self._registers:
535 if 'r' in register.direction:
536 value = self._read(register.name)
537 print('%s\t%s' % (register.name, value))
539 # ManualMixin methods
541 def set_mv(self, current):
542 if current > self._spec_max_current:
543 raise ValueError('current {} exceeds spec maximum {}'.format(
544 current, self._spec_max_current))
545 pout = current / self._spec_max_current * 100.0
546 self._write('REG_MANUAL_SET_POINT', pout)
550 def set_setpoint(self, setpoint):
551 self._write('SET_POINT_1', setpoint)
553 def get_setpoint(self):
554 return self._read('SET_POINT_1')
556 def _set_gains(self, output, proportional=None, integral=None,
559 (output, proportional, integral, derivative, dead_band) -> None
560 output : 1 (cooling) or 2 (heating)
561 proportional : propotional gain band in amps per degrees C
562 integral : integral weight in minutes (0.00 to 99.99)
563 derivative : derivative weight in minutes (? to ?)
565 Don't use derivative, dead time.
570 See 5.10 and the pages afterwards in the manual for Melcor's
571 explanation. The integral with respect to t' is actually only
572 from the time that T_samp has been with T_prop of T_set (not
575 if proportional is not None:
576 max_current = self.get_max_current()
577 propband = max_current/proportional
578 propband_name = 'PROPBAND_%d' % output
579 register = self._register[propband_name]
580 if propband > register.max:
581 # round down, to support bang-bang experiments
583 'limiting propband %d to maximum: {:n} -> {:n} C'.format(
584 propband, register.max))
585 propband = register.max
586 self._write(propband_name, propband)
587 if integral is not None:
588 self._write('INTEGRAL_%d' % output, integral)
589 if derivative is not None:
590 self._write('DERIVATIVE_%d' % output, derivative)
592 def _get_gains(self, output):
593 propband = self._read('PROPBAND_%d' % output)
594 integral = self._read('INTEGRAL_%d' % output)
595 derivative = self._read('DERIVATIVE_%d' % output)
596 max_current = self.get_max_current()
597 proportional = max_current/propband
598 return (proportional, integral, derivative)
600 def set_down_gains(self, proportional=None, integral=None,
603 output=1, proportional=proportional, integral=integral,
604 derivative=derivative)
606 def get_down_gains(self):
607 return self._get_gains(output=1)
609 def set_up_gains(self, proportional=None, integral=None, derivative=None):
611 output=2, proportional=proportional, integral=integral,
612 derivative=derivative)
614 def get_up_gains(self):
615 return self._get_gains(output=2)
617 def get_feedback_terms(self):
620 pid = int(self._read('PID_POWER_1'))
621 prop = int(self._read('PROP_TERM_1'))
622 ntgrl = int(self._read('INTEGRAL_TERM_1'))
623 deriv = int(self._read('DERIVATIVE_TERM_1'))
624 return (pid, prop, ntgrl, deriv)
626 def clear_integral_term(self):
627 # The controller resets the integral term when the temperature
628 # is outside the propbands
629 _LOG.debug('clearing integral term')
630 cp,ci,cd = self.get_cooling_gains()
631 hp,hi,hd = self.get_heating_gains()
632 sp = self.get_setpoint()
633 small_temp_range = 0.1
634 max_current = self.get_max_current()
635 p = max_current / small_temp_range
636 self.set_cooling_gains(proportional=p)
637 self.set_heating_gains(proportional=p)
639 _LOG.debug('waiting for an out-of-propband temperature')
640 if abs(self.get_temp() - sp) > small_temp_range:
641 break # we're out of the propband, I-term resets
642 self.set_cooling_gains(proportional=cp)
643 self.set_heating_gains(proportional=hp)
644 _LOG.debug('integral term cleared')
648 def sanity_check(self):
649 "Check that some key registers have the values we expect"
650 self._sanity_check('UNITS_TYPE', 'SI, use integral and derivative')
651 self._sanity_check('C_OR_F', 'celsius')
652 self._sanity_check('FAILURE_MODE', 'off')
653 self._sanity_check('RAMPING_MODE', 'off')
654 self._sanity_check('OUTPUT_1', 'cool')
655 self._sanity_check('OUTPUT_2', 'heat')
656 self._sanity_check('AUTO_MANUAL_OP_MODE', 'PID')
658 def _sanity_check(self, register_name, expected_value):
659 value = self._read(register_name)
660 if value != expected_value :
661 _LOG.error('invalid value %s for %s (expected %s)'
662 % (value, register_name, expected_value))
663 raise ValueError(value)