From 345b21dbb5cf3e1e5d352f8a02c06d6ead72a30f Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 13:47:00 +0100 Subject: [PATCH 1/8] add Inficon SQM-160 --- pymeasure/instruments/__init__.py | 1 + pymeasure/instruments/inficon/__init__.py | 25 +++ pymeasure/instruments/inficon/sqm160.py | 225 ++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 pymeasure/instruments/inficon/__init__.py create mode 100644 pymeasure/instruments/inficon/sqm160.py diff --git a/pymeasure/instruments/__init__.py b/pymeasure/instruments/__init__.py index b11e721412..6302ebe627 100644 --- a/pymeasure/instruments/__init__.py +++ b/pymeasure/instruments/__init__.py @@ -49,6 +49,7 @@ from . import hcp from . import heidenhain from . import hp +from . import inficon from . import ipgphotonics from . import keithley from . import keysight diff --git a/pymeasure/instruments/inficon/__init__.py b/pymeasure/instruments/inficon/__init__.py new file mode 100644 index 0000000000..fa78c2dfcc --- /dev/null +++ b/pymeasure/instruments/inficon/__init__.py @@ -0,0 +1,25 @@ +# +# This file is part of the PyMeasure package. +# +# Copyright (c) 2013-2023 PyMeasure Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from .sqm160 import SQM160 diff --git a/pymeasure/instruments/inficon/sqm160.py b/pymeasure/instruments/inficon/sqm160.py new file mode 100644 index 0000000000..ba7ee45ad2 --- /dev/null +++ b/pymeasure/instruments/inficon/sqm160.py @@ -0,0 +1,225 @@ +# +# This file is part of the PyMeasure package. +# +# Copyright (c) 2013-2023 PyMeasure Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +from pymeasure.instruments import Channel, Instrument + + +def calculate_checksum(msg): + """calculate a two byte Cyclic Redundancy Check based on 14 bits + + Parameters + ---------- + msg: bytes + Message of the device without the sync character + """ + # check if message contains data + if not msg: + return chr(0) + chr(0) + # initialize CRC + crc = 0x3fff + # loop over characters in message + for char in msg: + crc ^= char + for i in range(8): + tmpcrc = crc + crc = crc >> 1 + if tmpcrc & 1 == 1: + crc ^= 0x2001 + crc &= 0x3fff + # separate 14 significant bits in two byte checksum + return bytes(((crc & 0x7f) + 34, ((crc >> 7) & 0x7f) + 34)) + + +class SensorChannel(Channel): + """Sensor channel for individual rate measurements.""" + + rate = Channel.measurement( + "L{ch}?", """Get the current rate for a sensor in Angstrom per second""", + cast=float, + ) + + thickness = Channel.measurement( + "N{ch}", """Get the current thickness for a sensor in Angstrom""", + cast=float, + ) + + frequency = Channel.measurement( + "P{ch}", """Get the current frequency for a sensor in Hz""", + cast=float, + ) + + crystal_life = Channel.measurement( + "R{ch}", """Get the crystal life value in percent""", + cast=float, + ) + + +class SQM160(Instrument): + """Inficon SQM-160 multi-film rate/thickness monitor. + + Uses a quartz crystal sensor to measure rate and thickness in a thin + film deposition process. Connection to the device is commonly made through + a serial connection (RS232) or optionally via USB or Ethernet. + + A command packet always consists of the following: + - 1 Byte: Sync character ('!' appears only at the start of a message). + - 1 Byte: length character obtained from the message length without CRC. + A value of 34 is added so that no '!' can occur. + - Command message with variable length. + - 2 Byte: Cyclic Redundancy Check (CRC) checksum. + + A response packet always consists of: + - 1 Byte: Sync character ('!' appears only at the start of a message). + - 1 Byte: length character obtained from the message length without CRC. + A value of 35 is added. + - 1 Byte: Response status character indicating the status of the command. + - Response message with variable length. + - 2 Byte: Cyclic Redundancy Check (CRC) checksum. + + :param adapter: pyvisa resource name of the instrument or adapter instance + :param string name: Name of the instrument. + :param string baud_rate: Baud rate used by the serial connection. + :param kwargs: Any valid key-word argument for Instrument + + """ + sensor_1 = Instrument.ChannelCreator(SensorChannel, 1) + sensor_2 = Instrument.ChannelCreator(SensorChannel, 2) + sensor_3 = Instrument.ChannelCreator(SensorChannel, 3) + sensor_4 = Instrument.ChannelCreator(SensorChannel, 4) + sensor_5 = Instrument.ChannelCreator(SensorChannel, 5) + sensor_6 = Instrument.ChannelCreator(SensorChannel, 6) + + def __init__(self, adapter, name="Inficon SQM-160 thickness monitor", + baud_rate=19200, **kwargs): + super().__init__(adapter, + name, + includeSCPI=False, + write_termination="", + read_termination="", + asrl=dict(baud_rate=baud_rate), + timeout=3000, + **kwargs) + + def read(self): + """Reads a response message from the instrument. + + This method also checks for a correct checksum. + + :returns: the response packet + :rtype: string + :raises ConnectionError: if a checksum error is detected or a wrong + response status is detected. + """ + header = super().read_bytes(2) + # check valid header + if header[0] != 33: # b"!" + raise ConnectionError(f"invalid header start byte '{header[0]}' received") + length = header[1] - 35 + if length <= 0: + raise ConnectionError(f"invalid message length '{header[1]}' -> length {length}") + + response_status = super().read_bytes(1) + if response_status == b"C": + raise ConnectionError(f"invalid command response received") + elif response_status == b"D": + raise ConnectionError(f"Problem with data in command") + elif response_status != b"A": + raise ConnectionError(f"unknown response status character '{response_status}'") + + if length - 1 > 0: + data = super().read_bytes(length - 1) + else: + data = b"" + chksum = super().read_bytes(2) + calculated_checksum = calculate_checksum(header[1].to_bytes() + response_status + data) + if chksum == calculated_checksum: + return data.decode() + else: + raise ConnectionError( + f"checksum error in received message '{header + response_status + data}' " + f"with checksum '{calculated_checksum}' but received '{chksum}'") + + def write(self, command): + """Write a command to the device.""" + length = chr(len(command) + 34) + message = f"{length}{command}".encode() + super().write_bytes(b"!" + message + calculate_checksum(message)) + + def check_set_errors(self): + """Check the errors after setting a property.""" + self.read() + return [] # no error happened + + firmware_version = Instrument.measurement( + "@", """Get the firmware version.""", + cast=str, + ) + + number_of_channels = Instrument.measurement( + "J", """Get the number of installed channels""", + cast=int, + ) + + average_rate = Instrument.measurement( + "M", """Get the current average rate in Angstrom per second""", + cast=float, + ) + + average_thickness = Instrument.measurement( + "O", """Get the current average thickness in Angstrom""", + cast=float, + ) + + all_values = Instrument.measurement( + "W", """Get the current rate (Angstrom/s), Thickness (Angstrom), and frequency (Hz) + for each sensor""", + cast=float, + preprocess_reply=lambda msg: msg[5:], # ingore first '00.00' + ) + + reset_flag = Instrument.measurement( + "Y", """Get the power-up reset flag which is True only when read first after a power cycle.""", + cast=int, + values={True: 1, False: 0}, + map_values=True, + ) + + def reset_system_parameters(self): + """Reset all film and system parameters.""" + self.write("Z") + self.read() # read obligatory response message + + def reset_thickness_rate(self): + """Reset the average thickness and rate. + + This also sets all active Sensor Rates and Thicknesses to zero + """ + self.write("S") + self.read() # read obligatory response message + + def reset_time(self): + """Reset the time of the monitor to zero. + """ + self.write("T") + self.read() # read obligatory response message \ No newline at end of file From dba04ead44e83c4cb8fbce6dca104db13c2839ea Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 13:55:53 +0100 Subject: [PATCH 2/8] add documentation files --- docs/api/instruments/index.rst | 1 + docs/api/instruments/inficon/index.rst | 12 ++++++++++++ docs/api/instruments/inficon/sqm160.rst | 11 +++++++++++ 3 files changed, 24 insertions(+) create mode 100644 docs/api/instruments/inficon/index.rst create mode 100644 docs/api/instruments/inficon/sqm160.rst diff --git a/docs/api/instruments/index.rst b/docs/api/instruments/index.rst index 7c347b56c2..1bf0d05d24 100644 --- a/docs/api/instruments/index.rst +++ b/docs/api/instruments/index.rst @@ -41,6 +41,7 @@ Instruments by manufacturer: heidenhain/index hcp/index hp/index + inficon/index ipgphotonics/index keithley/index keysight/index diff --git a/docs/api/instruments/inficon/index.rst b/docs/api/instruments/inficon/index.rst new file mode 100644 index 0000000000..e3f282d6ee --- /dev/null +++ b/docs/api/instruments/inficon/index.rst @@ -0,0 +1,12 @@ +.. module:: pymeasure.instruments.inficon + +####### +Inficon +####### + +This section contains specific documentation on the Inficon instruments that are implemented. If you are interested in an instrument not included, please consider :doc:`adding the instrument `. + +.. toctree:: + :maxdepth: 2 + + sqm160 diff --git a/docs/api/instruments/inficon/sqm160.rst b/docs/api/instruments/inficon/sqm160.rst new file mode 100644 index 0000000000..ed3e479718 --- /dev/null +++ b/docs/api/instruments/inficon/sqm160.rst @@ -0,0 +1,11 @@ +################################################# +Inficon SQM-160 multi-film rate/thickness monitor +################################################# + +.. autoclass:: pymeasure.instruments.inficon.sqm160.SQM160 + :members: + :show-inheritance: + +.. autoclass:: pymeasure.instruments.inficon.sqm160.SensorChannel + :members: + :show-inheritance: From c36abaccbf3d3085ea6eacf163e28af4bbf987f8 Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 14:36:33 +0100 Subject: [PATCH 3/8] add test files for SQM160 --- tests/instruments/inficon/test_sqm160.py | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/instruments/inficon/test_sqm160.py diff --git a/tests/instruments/inficon/test_sqm160.py b/tests/instruments/inficon/test_sqm160.py new file mode 100644 index 0000000000..6a2a1c83ba --- /dev/null +++ b/tests/instruments/inficon/test_sqm160.py @@ -0,0 +1,88 @@ +# +# This file is part of the PyMeasure package. +# +# Copyright (c) 2013-2023 PyMeasure Developers +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +import pytest + +from pymeasure.test import expected_protocol +from pymeasure.instruments.inficon.sqm160 import calculate_checksum, SQM160 + + +def test_checksum(): + """Verify the calculate_checksum function.""" + # test against values documented in the manual + assert calculate_checksum(b'#@') == b'O7' + assert calculate_checksum(b'$C?') == b'g/' + + +def test_firmware_version(): + """Verify the communication of the firmware version.""" + with expected_protocol( + SQM160, + [(b"!#@O7", b"!0AMON Ver 4.13Uw"),], + ) as inst: + assert inst.firmware_version == "MON Ver 4.13" + + +def test_number_of_channels(): + """Verify the communication of the number of channels.""" + with expected_protocol( + SQM160, + [(b"!#JO8", b"!%A6v\x86"),], + ) as inst: + assert inst.number_of_channels == 6 + + +def test_average_rate(): + """Verify reading of the average rate.""" + with expected_protocol( + SQM160, + [(b"!#M\x8e\x8a", b"!*A 0.01 i?"),], + ) as inst: + assert inst.average_rate == pytest.approx(0.01) + + +def test_average_thickness(): + """Verify reading of the average rate.""" + with expected_protocol( + SQM160, + [(b"!#O\x8f9", b"!+A 0.000 Jo"),], + ) as inst: + assert inst.average_thickness == pytest.approx(0.0) + + +def test_channel_rate(): + """Verify reading of the rate of a channel.""" + with expected_protocol( + SQM160, + [(b"!%L1?\x85{", b"!*A 0.00 [d"),], + ) as inst: + assert inst.sensor_1.rate == pytest.approx(0.0) + + +def test_channel_frequency(): + """Verify reading of the frequency of a channel.""" + with expected_protocol( + SQM160, + [(b"!$P1Z\x91", b"!/A5875830.230:X"),], + ) as inst: + assert inst.sensor_1.frequency == pytest.approx(5875830.23) From c35903acddd97802b05c3de31e9d61bf4d797ab5 Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 14:51:38 +0100 Subject: [PATCH 4/8] attempt intentation in docstring --- pymeasure/instruments/inficon/sqm160.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pymeasure/instruments/inficon/sqm160.py b/pymeasure/instruments/inficon/sqm160.py index ba7ee45ad2..b81138c83d 100644 --- a/pymeasure/instruments/inficon/sqm160.py +++ b/pymeasure/instruments/inficon/sqm160.py @@ -83,19 +83,19 @@ class SQM160(Instrument): a serial connection (RS232) or optionally via USB or Ethernet. A command packet always consists of the following: - - 1 Byte: Sync character ('!' appears only at the start of a message). - - 1 Byte: length character obtained from the message length without CRC. - A value of 34 is added so that no '!' can occur. - - Command message with variable length. - - 2 Byte: Cyclic Redundancy Check (CRC) checksum. + - 1 Byte: Sync character ('!' appears only at the start of a message). + - 1 Byte: length character obtained from the message length without CRC. + A value of 34 is added so that no '!' can occur. + - Command message with variable length. + - 2 Byte: Cyclic Redundancy Check (CRC) checksum. A response packet always consists of: - - 1 Byte: Sync character ('!' appears only at the start of a message). - - 1 Byte: length character obtained from the message length without CRC. - A value of 35 is added. - - 1 Byte: Response status character indicating the status of the command. - - Response message with variable length. - - 2 Byte: Cyclic Redundancy Check (CRC) checksum. + - 1 Byte: Sync character ('!' appears only at the start of a message). + - 1 Byte: length character obtained from the message length without CRC. + A value of 35 is added. + - 1 Byte: Response status character indicating the status of the command. + - Response message with variable length. + - 2 Byte: Cyclic Redundancy Check (CRC) checksum. :param adapter: pyvisa resource name of the instrument or adapter instance :param string name: Name of the instrument. From 6cd62055c3198e247a42630c184d7b55c3651cb0 Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 14:54:12 +0100 Subject: [PATCH 5/8] fix linting errors in code --- pymeasure/instruments/inficon/sqm160.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pymeasure/instruments/inficon/sqm160.py b/pymeasure/instruments/inficon/sqm160.py index b81138c83d..e48f32a3e5 100644 --- a/pymeasure/instruments/inficon/sqm160.py +++ b/pymeasure/instruments/inficon/sqm160.py @@ -141,9 +141,9 @@ def read(self): response_status = super().read_bytes(1) if response_status == b"C": - raise ConnectionError(f"invalid command response received") + raise ConnectionError("invalid command response received") elif response_status == b"D": - raise ConnectionError(f"Problem with data in command") + raise ConnectionError("Problem with data in command") elif response_status != b"A": raise ConnectionError(f"unknown response status character '{response_status}'") @@ -199,7 +199,8 @@ def check_set_errors(self): ) reset_flag = Instrument.measurement( - "Y", """Get the power-up reset flag which is True only when read first after a power cycle.""", + "Y", """Get the power-up reset flag. + It is True only when read first after a power cycle.""", cast=int, values={True: 1, False: 0}, map_values=True, @@ -222,4 +223,4 @@ def reset_time(self): """Reset the time of the monitor to zero. """ self.write("T") - self.read() # read obligatory response message \ No newline at end of file + self.read() # read obligatory response message From d37d98dbefeb788f0cfff29fe711769a4c119657 Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 14:58:24 +0100 Subject: [PATCH 6/8] add length argument in to_bytes required in Python <=3.10 --- pymeasure/instruments/inficon/sqm160.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pymeasure/instruments/inficon/sqm160.py b/pymeasure/instruments/inficon/sqm160.py index e48f32a3e5..b73a31bdb2 100644 --- a/pymeasure/instruments/inficon/sqm160.py +++ b/pymeasure/instruments/inficon/sqm160.py @@ -152,7 +152,8 @@ def read(self): else: data = b"" chksum = super().read_bytes(2) - calculated_checksum = calculate_checksum(header[1].to_bytes() + response_status + data) + calculated_checksum = calculate_checksum( + header[1].to_bytes(length=1) + response_status + data) if chksum == calculated_checksum: return data.decode() else: From a0b3560aa8dd3c692cc5507b31fd439963d40978 Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 15:01:28 +0100 Subject: [PATCH 7/8] add byteorder for Python <=3.10 --- pymeasure/instruments/inficon/sqm160.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymeasure/instruments/inficon/sqm160.py b/pymeasure/instruments/inficon/sqm160.py index b73a31bdb2..b56fcb3c3c 100644 --- a/pymeasure/instruments/inficon/sqm160.py +++ b/pymeasure/instruments/inficon/sqm160.py @@ -153,7 +153,7 @@ def read(self): data = b"" chksum = super().read_bytes(2) calculated_checksum = calculate_checksum( - header[1].to_bytes(length=1) + response_status + data) + header[1].to_bytes(length=1, byteorder='big') + response_status + data) if chksum == calculated_checksum: return data.decode() else: From 4f309f2edf956a12c0e8d7842ca4b88244208cb1 Mon Sep 17 00:00:00 2001 From: Dominik Kriegner Date: Mon, 4 Dec 2023 20:08:51 +0100 Subject: [PATCH 8/8] avoid unnecessary super() calls --- pymeasure/instruments/inficon/sqm160.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pymeasure/instruments/inficon/sqm160.py b/pymeasure/instruments/inficon/sqm160.py index b56fcb3c3c..53f0757100 100644 --- a/pymeasure/instruments/inficon/sqm160.py +++ b/pymeasure/instruments/inficon/sqm160.py @@ -131,7 +131,7 @@ def read(self): :raises ConnectionError: if a checksum error is detected or a wrong response status is detected. """ - header = super().read_bytes(2) + header = self.read_bytes(2) # check valid header if header[0] != 33: # b"!" raise ConnectionError(f"invalid header start byte '{header[0]}' received") @@ -139,7 +139,7 @@ def read(self): if length <= 0: raise ConnectionError(f"invalid message length '{header[1]}' -> length {length}") - response_status = super().read_bytes(1) + response_status = self.read_bytes(1) if response_status == b"C": raise ConnectionError("invalid command response received") elif response_status == b"D": @@ -148,10 +148,10 @@ def read(self): raise ConnectionError(f"unknown response status character '{response_status}'") if length - 1 > 0: - data = super().read_bytes(length - 1) + data = self.read_bytes(length - 1) else: data = b"" - chksum = super().read_bytes(2) + chksum = self.read_bytes(2) calculated_checksum = calculate_checksum( header[1].to_bytes(length=1, byteorder='big') + response_status + data) if chksum == calculated_checksum: @@ -165,7 +165,7 @@ def write(self, command): """Write a command to the device.""" length = chr(len(command) + 34) message = f"{length}{command}".encode() - super().write_bytes(b"!" + message + calculate_checksum(message)) + self.write_bytes(b"!" + message + calculate_checksum(message)) def check_set_errors(self): """Check the errors after setting a property."""