Skip to content

Commit efb2be0

Browse files
authored
Add preliminary support for Merlin detector (#65)
* Set up boilerplate for new camera * Implement merlin detector interface * Set defaults from config * Debug merlin connection * Load data correctly as 512x512 arrays I verified the data with the data displayed in the merlin software The first byte of the framedata has to be skipped when reading the data to extract the correct metadata and avoid off-by-one error when converting the buffer into a numpy array * Add code to load merlin framedata * Refactor code * Update default merlin config * Store data in bytearray, log timings for debugging * Make data recv more robust and remove need for arbitrary delays * Add test for merlin_io * Use f-strings * Update documentation * Test support for Python 3.10, 3.11
1 parent 2604172 commit efb2be0

12 files changed

Lines changed: 446 additions & 3 deletions

File tree

.github/workflows/test.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,25 @@ jobs:
2121
strategy:
2222
fail-fast: false
2323
matrix:
24-
python-version: [3.7, 3.8, 3.9]
24+
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
2525

2626
steps:
2727
- uses: actions/checkout@v3
28+
2829
- name: Set up Python ${{ matrix.python-version }}
2930
uses: actions/setup-python@v4
3031
with:
3132
python-version: ${{ matrix.python-version }}
3233
architecture: x64
3334

3435
- uses: actions/cache@v3
36+
id: cache-python-env
3537
with:
3638
path: ${{ env.pythonLocation }}
3739
key: ${{ env.pythonLocation }}-${{ hashFiles('setup.cfg') }}
3840

3941
- name: Install
42+
if: steps.cache-python-env.outputs.cache-hit != 'true'
4043
run: |
4144
python -m pip install -e .[develop]
4245

docs/config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ mag:
211211
This file holds the specifications of the camera. This file is must be located the `config/camera` directory, and can have any name as defined in `settings.yaml`.
212212

213213
**interface**
214-
Give the interface of the camera interface to connect to, for example: `timepix`/`emmenu`/`simulate`/`gatan`. Leave blank to load the camera specs, but do not load the camera module (this also turns off the videostream gui).
214+
Give the interface of the camera interface to connect to, for example: `timepix`/`emmenu`/`simulate`/`gatan`/'merlin'. Leave blank to load the camera specs, but do not load the camera module (this also turns off the videostream gui).
215215

216216
**default_binsize**
217217
Set the default binsize, default: `1`.

instamatic/camera/camera.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def get_cam(interface: str = None):
2929
from instamatic.camera.camera_emmenu import CameraEMMENU as cam
3030
elif interface == 'serval':
3131
from instamatic.camera.camera_serval import CameraServal as cam
32+
elif interface == 'merlin':
33+
from instamatic.camera.camera_merlin import CameraMerlin as cam
3234
else:
3335
raise ValueError(f'No such camera interface: {interface}')
3436

instamatic/camera/camera_merlin.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import atexit
2+
import logging
3+
import socket
4+
import time
5+
6+
import numpy as np
7+
8+
from instamatic import config
9+
10+
from .merlin_io import load_mib
11+
12+
logger = logging.getLogger(__name__)
13+
14+
# socket.settimeout(5) # seconds
15+
socket.setdefaulttimeout(5) # seconds
16+
17+
18+
def MPX_CMD(type_cmd: str = 'GET', cmd: str = 'DETECTORSTATUS') -> bytes:
19+
"""Generate TCP command bytes for Merlin software.
20+
21+
Default value 'GET,DETECTORSTATUS' probes for
22+
the current status of the detector.
23+
24+
Parameters
25+
----------
26+
type_cmd : str, optional
27+
Type of the command
28+
cmd : str, optional
29+
Command to execute
30+
31+
Returns
32+
-------
33+
bytes
34+
Command code in bytes format
35+
"""
36+
length = len(cmd)
37+
# tmp = 'MPX,00000000' + str(length+5) + ',' + type_cmd + ',' + cmd
38+
tmp = f'MPX,00000000{length+5},{type_cmd},{cmd}'
39+
logger.debug(tmp)
40+
return tmp.encode()
41+
42+
43+
class CameraMerlin:
44+
"""Camera interface for the Quantum Detectors Merlin camera."""
45+
46+
def __init__(self, name='merlin'):
47+
"""Initialize camera module."""
48+
super().__init__()
49+
50+
self.name = name
51+
52+
self.load_defaults()
53+
54+
self.establishConnection()
55+
self.establishDataConnection()
56+
57+
msg = f'Camera {self.getName()} initialized'
58+
logger.info(msg)
59+
60+
atexit.register(self.releaseConnection)
61+
62+
def load_defaults(self):
63+
if self.name != config.settings.camera:
64+
config.load_camera_config(camera_name=self.name)
65+
66+
self.streamable = True
67+
68+
self.__dict__.update(config.camera.mapping)
69+
70+
def getImage(self, exposure=None, binsize=None, **kwargs) -> np.ndarray:
71+
"""Image acquisition routine. If the exposure and binsize are not
72+
given, the default values are read from the config file.
73+
74+
exposure:
75+
Exposure time in seconds.
76+
binsize:
77+
Which binning to use.
78+
"""
79+
frames = self.getMovie(n_frames=1, exposure=exposure, binsize=binsize)
80+
return frames[0]
81+
82+
def receive_data(self, *, nbytes: int) -> bytearray:
83+
"""Safely receive from the socket until `n_bytes` of data are
84+
received."""
85+
data = bytearray()
86+
while len(data) != nbytes:
87+
data.extend(self.s_data.recv(nbytes - len(data)))
88+
return data
89+
90+
def getMovie(self, n_frames, exposure=None, binsize=None, **kwargs):
91+
"""Movie acquisition routine. If the exposure and binsize are not
92+
given, the default values are read from the config file.
93+
94+
exposure:
95+
Exposure time in seconds.
96+
binsize:
97+
Which binning to use.
98+
"""
99+
if exposure is None:
100+
exposure = self.default_exposure
101+
if not binsize:
102+
binsize = self.default_binsize
103+
104+
# convert s to ms
105+
exposure_ms = exposure * 1000
106+
107+
# Set continuous mode on
108+
self.s_cmd.sendall(MPX_CMD('SET', 'CONTINUOUSRW,1'))
109+
# Set frame time in miliseconds
110+
self.s_cmd.sendall(MPX_CMD('SET', f'ACQUISITIONTIME,{exposure_ms}'))
111+
# Set gap time in milliseconds (The number corresponds to sum of frame and gap time)
112+
self.s_cmd.sendall(MPX_CMD('SET', f'ACQUISITIONPERIOD,{exposure_ms}'))
113+
# Set number of frames to be acquired
114+
self.s_cmd.sendall(MPX_CMD('SET', f'NUMFRAMESTOACQUIRE,{n_frames}'))
115+
# Disable file saving
116+
self.s_cmd.sendall(MPX_CMD('SET', 'FILEENABLE,0'))
117+
# Start acquisition
118+
self.s_cmd.sendall(MPX_CMD('CMD', 'STARTACQUISITION'))
119+
120+
start = self.receive_data(nbytes=14)
121+
122+
header_size = int(start[4:])
123+
124+
header = self.receive_data(nbytes=header_size)
125+
126+
logger.info('Header data received (%s).', header_size)
127+
128+
frames = []
129+
130+
# overhead ~300 ms per frame, round-trips to server ~28 ms
131+
for x in range(n_frames):
132+
mpx_header = self.receive_data(nbytes=14)
133+
size = int(mpx_header[4:])
134+
135+
logger.info('Receiving frame %s: %s (%s)', x, size, mpx_header)
136+
137+
framedata = self.receive_data(nbytes=size)
138+
139+
frames.append(framedata)
140+
141+
logger.info('%s frames received.', n_frames)
142+
143+
# Must skip first byte when loading data to avoid off-by-one error
144+
data = [load_mib(frame[1:]).squeeze() for frame in frames]
145+
146+
return data
147+
148+
def isCameraInfoAvailable(self) -> bool:
149+
"""Check if the camera is available."""
150+
return True
151+
152+
def getImageDimensions(self) -> (int, int):
153+
"""Get the binned dimensions reported by the camera."""
154+
binning = self.getBinning()
155+
dim_x, dim_y = self.getCameraDimensions()
156+
157+
dim_x = int(dim_x / binning)
158+
dim_y = int(dim_y / binning)
159+
160+
return dim_x, dim_y
161+
162+
def getCameraDimensions(self) -> (int, int):
163+
"""Get the dimensions reported by the camera."""
164+
return self.dimensions
165+
166+
def getName(self) -> str:
167+
"""Get the name reported by the camera."""
168+
return self.name
169+
170+
def establishConnection(self) -> None:
171+
"""Establish connection to command port of the merlin software."""
172+
# Create command socket
173+
s_cmd = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
174+
# Connect sockets and probe for the detector status
175+
176+
logger.info('Connecting to Merlin on %s:%s', self.host, self.commandport)
177+
178+
try:
179+
s_cmd.connect((self.host, self.commandport))
180+
181+
s_cmd.sendall(MPX_CMD('GET', 'SOFTWAREVERSION'))
182+
version = s_cmd.recv(1024)
183+
logger.info(f'Version CMD: {version.decode()}')
184+
185+
s_cmd.sendall(MPX_CMD('GET', 'DETECTORSTATUS'))
186+
status = s_cmd.recv(1024)
187+
logger.info(f'Status CMD: {status.decode()}')
188+
189+
except ConnectionRefusedError:
190+
raise RuntimeError(
191+
f'Could not establish command connection to {self.name}, '
192+
'(Merlin command port not responding).')
193+
except OSError:
194+
raise RuntimeError(
195+
f'Could not establish command connection to {self.name}, '
196+
'(Merlin command port already connected).')
197+
198+
for key, value in self.detector_config.items():
199+
s_cmd.sendall(MPX_CMD('SET', f'{key},{value}'))
200+
201+
self.s_cmd = s_cmd
202+
203+
def establishDataConnection(self) -> None:
204+
"""Establish connection to the dataport of the merlin software."""
205+
# Create command socket
206+
s_data = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
207+
# Connect sockets and probe for the detector status
208+
try:
209+
s_data.connect((self.host, self.dataport))
210+
except ConnectionRefusedError:
211+
raise RuntimeError(
212+
f'Could not establish data connection to {self.name}, '
213+
'(Merlin data port not responding).')
214+
215+
self.s_data = s_data
216+
217+
def releaseConnection(self) -> None:
218+
"""Release the connection to the camera."""
219+
self.s_cmd.close()
220+
221+
self.s_data.close()
222+
223+
name = self.getName()
224+
msg = f"Connection to camera '{name}' released"
225+
logger.info(msg)
226+
227+
228+
if __name__ == '__main__':
229+
logging.basicConfig(level=logging.INFO)
230+
logger.info('Testing merlin detector')
231+
232+
cam = CameraMerlin()
233+
234+
t0 = time.perf_counter()
235+
236+
n_frames = 1
237+
frames = cam.getMovie(n_frames, exposure=0.05)
238+
239+
t1 = time.perf_counter()
240+
241+
print(f'Total time: {t1-t0:.3f} s - {(t1-t0) / n_frames:.3f} per frame')
242+
243+
for frame in frames:
244+
print(frame.shape)
245+
246+
for i in range(10):
247+
frame = cam.getImage(exposure=0.05)
248+
print(i, frame.shape)
249+
250+
arr = frames[0]
251+
252+
import numpy as np
253+
254+
arr = arr.squeeze()
255+
arr = np.flipud(arr)
256+
257+
import matplotlib.pyplot as plt
258+
plt.imshow(arr.squeeze())
259+
plt.show()

0 commit comments

Comments
 (0)