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):
98 decimal = self.decimal
99 if self.decimal_offset:
100 decimal *= self.decimal_offset
101 return self._float2melcor(value, decimal)
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)
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)
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)
122 def decode(self, value, **kwargs):
123 return super(BoundedFloatRegister, self).decode(value, **kwargs)
126 class MelcorBackend (_Backend, _ManualMixin, _PIDMixin, _TemperatureMixin):
127 """Temperature control backend for a Melcor MTCA Temperature Controller
129 * PV: process temperature
130 * PV-units: degrees Celsius
131 * MV: controller current
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',
148 3: 'ramping set point',
149 4: 'event input status',
152 7: 'auto-tune set point',
154 9: 'event set point',
155 10: 'local or remote calibration mode',
156 11: 'calibration offset',
177 32: 'proportional term',
179 34: 'derivative term',
182 37: 'alarm hysteresis 2',
183 38: 'alarm hysteresis 3',
184 39: 'alarm hysteresis 4',
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={
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={
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={
298 ChoiceRegister('SILENCING_2', 722, reference='5.20, 6.14', choices={
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={
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={
315 ChoiceRegister('SILENCING_3', 739, reference='5.20, 6.15', choices={
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={
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={
334 ChoiceRegister('EVENT_FUNCTION', 1060, reference='5.8, 6.12', choices={
336 1: 'switch to event set point',
337 2: 'turn off control outputs and disable alarms',
338 3: 'turn off control outputs',
340 5: 'switch to manual mode',
341 6: 'initiate an auto-tune',
343 8: 'lock everything except primary set point',
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={
388 Register('DEFAULT_SETTINGS', 1602, direction='w', reference='6.24'),
389 ChoiceRegister('OVERLOADED_CALIBRATION_1', 1603, direction='w', reference='6.24, 6.25', choices={
391 1: 'thermocouple, 0mV',
392 2: 'thermocouple, 50mV',
393 3: 'thermocouple, 32deg',
395 5: 'lead resistance',
396 6: 'RTD, 15 Ohms', # RTD = Resistance Temp. Detector
400 10: 'process 1, 4mA',
401 11: 'process 1, 20mA',
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={
412 4: 'process 2, 20mA',
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.),
424 del(_custom_prompt_kwargs)
425 _register = dict((r.name, r) for r in _registers)
427 def __init__(self, controller=1, device='/dev/ttyS0', baudrate=9600):
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
433 # the rated max current from controller specs
434 self._spec_max_current = 4.0 # Amps
436 self._controller = controller
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
441 self._client = _ModbusSerialClient(
443 port=device, # '/dev/ttyS0' or 0
444 bytesize=_serial.EIGHTBITS,
445 parity=_serial.PARITY_NONE,
446 stopbits=_serial.STOPBITS_ONE,
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))
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
483 def _get_decimal(self):
484 return self._read('DECIMAL_1')
486 # Support for Backend methods
489 return self._read('HIGH_RESOLUTION')
491 def get_ambient_pv(self):
492 return self._convert_F_to_C(self._read('AMBIENT_TEMPERATURE'))
494 def set_max_mv(self, max):
495 """Set the max current in Amps
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
502 Note to Melcor enthusiasts: this method set's both the 'above'
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
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
521 pout = self._read('PERCENT_OUTPUT')
522 cur = self._spec_max_current * pout / 100.0
526 register = self._register['AUTO_MANUAL_OP_MODE']
527 return sorted(register.choices.values())
530 return self._read('AUTO_MANUAL_OP_MODE')
532 def set_mode(self, mode):
533 self._write('AUTO_MANUAL_OP_MODE', mode)
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))
541 # ManualMixin methods
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)
552 def set_setpoint(self, setpoint):
553 self._write('SET_POINT_1', setpoint)
555 def get_setpoint(self):
556 return self._read('SET_POINT_1')
558 def _set_gains(self, output, proportional=None, integral=None,
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 ?)
567 Don't use derivative, dead time.
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
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
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)
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)
602 def set_down_gains(self, proportional=None, integral=None,
605 output=1, proportional=proportional, integral=integral,
606 derivative=derivative)
608 def get_down_gains(self):
609 return self._get_gains(output=1)
611 def set_up_gains(self, proportional=None, integral=None, derivative=None):
613 output=2, proportional=proportional, integral=integral,
614 derivative=derivative)
616 def get_up_gains(self):
617 return self._get_gains(output=2)
619 def get_feedback_terms(self):
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)
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)
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')
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')
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)