-
Notifications
You must be signed in to change notification settings - Fork 31
Expand file tree
/
Copy pathmain.py
More file actions
520 lines (439 loc) · 20.7 KB
/
Copy pathmain.py
File metadata and controls
520 lines (439 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
"""mqtt provides an MQTT worker to perform stop-flow image acquisition."""
import datetime
import json
import os
import threading
import time
import typing
from uuid import uuid4
import loguru
import integrity
import mqtt
from . import stopflow
from .camera import mqtt as camera
class Imager:
"""An MQTT API for the PlanktoScope's camera and image acquisition modules.
This launches the camera with an MQTT API for settings adjustments
and launches stop-flow acquisition routines in response to
commands received over the MQTT API.
"""
def __init__(self, configuration: dict[str, typing.Any]):
# Internal state
self._metadata: dict[str, typing.Any] = {}
self._active_routine: typing.Optional[ImageAcquisitionRoutine] = None
# I/O
self._mqtt: typing.Optional[mqtt.MQTT_Client] = None
self._pump: typing.Optional[_PumpClient] = None
# TODO(ethanjli): instead of having the ImagerWorker start the camera worker, this should
# be started from the main script; and then the camera object should be passed into the
# constructor.
self._camera: typing.Optional[camera.Worker] = None
self.configuration = configuration
loguru.logger.success("planktoscope.imager is initialized and ready to go!")
@loguru.logger.catch
def run(self) -> None:
loguru.logger.info(f"The imager control thread has been started in process {os.getpid()}")
self._mqtt = mqtt.MQTT_Client(topic="imager/#", name="imager_client")
self._mqtt.client.publish("status/imager", '{"status":"Starting up"}')
loguru.logger.info("Starting the pump RPC client...")
self._pump = _PumpClient()
self._pump.open()
loguru.logger.success("Pump RPC client is ready!")
loguru.logger.info("Starting the camera...")
self._camera = camera.Worker(self.configuration)
self._camera.start()
if self._camera.camera is None:
loguru.logger.error("Missing camera - maybe it's disconnected or it never started?")
# TODO(ethanjli): officially add this error status to the MQTT API!
self._mqtt.client.publish("status/imager", '{"status": "Error: missing camera"}')
self._cleanup()
return
loguru.logger.success("Camera is ready!")
self._mqtt.client.publish("status/imager", '{"status":"Ready"}')
try:
while True:
if self._active_routine is not None and not self._active_routine.is_alive():
# Garbage-collect any finished image-acquisition routine threads so that we're
# ready for the next configuration update command which arrives:
self._active_routine.stop()
self._active_routine = None
if not self._mqtt.new_message_received():
time.sleep(0.1)
continue
self._handle_new_message()
finally:
loguru.logger.info("Shutting down the imager process...")
self._mqtt.client.publish("status/imager", '{"status":"Dead"}')
self._cleanup()
loguru.logger.success("Imager process shut down!")
def _cleanup(self) -> None:
"""Clean up everything running in the background."""
if self._mqtt is not None:
self._mqtt.shutdown()
self._mqtt = None
if self._pump is not None:
self._pump.close()
self._pump = None
if self._camera is not None:
self._camera.shutdown()
self._camera.join()
self._camera = None
@loguru.logger.catch
def _handle_new_message(self) -> None:
"""Handle a new message received over MQTT."""
assert self._mqtt is not None
if self._mqtt.msg is None:
return
if not self._mqtt.msg["topic"].startswith("imager/"):
self._mqtt.read_message()
return
latest_message = self._mqtt.msg["payload"]
action = self._mqtt.msg["payload"]["action"]
self._mqtt.read_message()
if action == "update_config":
self._update_metadata(latest_message)
elif action == "image":
try:
self._start_acquisition(latest_message)
except RuntimeError:
loguru.logger.exception("Couldn't start image acquisition!")
elif action == "stop" and self._active_routine is not None:
self._active_routine.stop()
self._active_routine = None
def _update_metadata(self, latest_message: dict[str, typing.Any]) -> None:
"""Handle a new imager command to update the configuration (i.e. the metadata)."""
assert self._mqtt is not None
# TODO(ethanjli): it'll be simpler if we just take the configuration as part of the command
# to start image acquisition! This requires modifying the MQTT API (to remove the
# "update_config" action and require the client to pass the metadata with the "image"
# action), so we'll do it later.
if self._active_routine is not None and self._active_routine.is_alive():
loguru.logger.error("Can't update configuration during image acquisition!")
self._mqtt.client.publish("status/imager", '{"status":"Busy"}')
return
if "config" not in latest_message:
loguru.logger.error(f"Received message is missing field 'config': {latest_message}")
self._mqtt.client.publish("status/imager", '{"status":"Configuration message error"}')
return
loguru.logger.info("Updating configuration...")
self._metadata = latest_message["config"]
self._mqtt.client.publish("status/imager", '{"status":"Config updated"}')
loguru.logger.success("Updated configuration!")
def _start_acquisition(self, latest_message: dict[str, typing.Any]) -> None:
"""Handle a new imager command to start image acquisition."""
assert self._mqtt is not None
assert self._pump is not None
assert self._camera is not None
if (acquisition_settings := _parse_acquisition_settings(latest_message)) is None:
self._mqtt.client.publish("status/imager", '{"status":"Error"}')
return
if self._camera.camera is None:
loguru.logger.error("Missing camera - maybe it was closed?")
# TODO(ethanjli): officially add this error status to the MQTT API!
self._mqtt.client.publish("status/imager", '{"status": "Error: missing camera"}')
raise RuntimeError("Camera is not available")
assert (capture_size := self._camera.camera.stream_config.capture_size) is not None
camera_settings = self._camera.camera.settings
assert (image_gain := camera_settings.image_gain) is not None
calibration = camera.ISO_CALIBRATIONS.get(self._camera.camera.sensor_name, 100)
metadata = {
**self._metadata,
"acq_local_datetime": datetime.datetime.now().isoformat().split(".")[0],
"acq_camera_resolution": f"{capture_size[0]}x{capture_size[1]}",
"acq_camera_iso": int(image_gain * calibration),
"acq_camera_shutter_speed": camera_settings.exposure_time,
"acq_uuid": uuid4(),
"sample_uuid": uuid4(),
}
loguru.logger.debug(f"Saving metadata: {metadata}")
try:
output_path = _initialize_acquisition_directory(
"/home/pi/data/img",
metadata,
)
except ValueError as e:
self._mqtt.client.publish(
"status/imager",
json.dumps({"status": f"Configuration update error: {str(e)}"}),
)
return
if output_path is None:
# An error status was already reported, so we don't need to do anything else
return
self._active_routine = ImageAcquisitionRoutine(
stopflow.Routine(output_path, acquisition_settings, self._pump, self._camera.camera),
self._mqtt,
)
self._active_routine.start()
def _parse_acquisition_settings(
latest_message: dict[str, typing.Any],
) -> typing.Optional[stopflow.Settings]:
"""Parse a command to start acquisition into stop-flow settings.
Returns:
A [stopflow.Settings] with the parsed settings if input validation and parsing succeeded,
or `None` otherwise.
"""
for field in ("nb_frame", "sleep", "volume", "pump_direction"):
if field not in latest_message:
loguru.logger.error(
f"The received message is missing field '{field}': {latest_message}"
)
return None
if latest_message["pump_direction"] not in stopflow.PumpDirection.__members__:
loguru.logger.error(
"The received message has an invalid pump direction: "
+ f"{latest_message['pump_direction']}",
)
return None
try:
return stopflow.Settings(
total_images=int(latest_message["nb_frame"]),
stabilization_duration=float(latest_message["sleep"]),
pump=stopflow.DiscretePumpSettings(
direction=stopflow.PumpDirection(latest_message.get("pump_direction", "FORWARD")),
flowrate=float(latest_message.get("pump_flowrate", 2)),
volume=float(latest_message["volume"]),
),
)
except ValueError:
loguru.logger.exception("Invalid input")
return None
def _initialize_acquisition_directory(
base_path: str,
metadata: dict[str, typing.Any],
) -> typing.Optional[str]:
"""Make the directory where images will be saved for the current image-acquisition routine.
This also saves the metadata to a `metadata.json` file and initializes a file integrity log in
the directory.
Args:
base_path: directory under which a subdirectory tree will be created for the image
acquisition.
metadata: a dict of all metadata to be associated with the acquisition. Must contain
keys "object_date", "sample_id", and "acq_id".
Returns:
The directory where captured images will be saved if preparation finished successfully,
or `None` otherwise.
Raises:
ValueError: Acquisition directory initialization failed.
"""
loguru.logger.info("Setting up the directory structure for storing the pictures...")
if "object_date" not in metadata: # needed for the directory path
loguru.logger.error("The metadata did not contain object_date!")
raise ValueError("object_date is missing!")
loguru.logger.debug(f"Metadata: {metadata}")
acq_dir_path = os.path.join(
base_path,
metadata["object_date"],
str(metadata["sample_id"]).replace(" ", "_").strip("'"),
str(metadata["acq_id"]).replace(" ", "_").strip("'"),
)
if os.path.exists(acq_dir_path):
loguru.logger.error(f"Acquisition directory {acq_dir_path} already exists!")
raise ValueError("Chosen id are already in use!")
os.makedirs(acq_dir_path)
loguru.logger.info("Saving metadata...")
metadata_filepath = os.path.join(acq_dir_path, "metadata.json")
with open(metadata_filepath, "w", encoding="utf-8") as metadata_file:
json.dump(metadata, metadata_file, indent=4)
loguru.logger.debug(f"Saved metadata to {metadata_file}: {metadata}")
integrity.create_integrity_file(acq_dir_path)
integrity.append_to_integrity_file(metadata_filepath)
return acq_dir_path
class ImageAcquisitionRoutine(threading.Thread):
"""A thread to run a single image acquisition routine to completion, with MQTT updates."""
# TODO(ethanjli): instead of taking an arg of type mqtt.MQTT_CLIENT, just take an arg of
# whatever `mqtt_client.client`'s type is supposed to be. Or maybe we should just initialize
# our own MQTT client in here?
def __init__(self, routine: stopflow.Routine, mqtt_client: mqtt.MQTT_Client) -> None:
"""Initialize the thread.
Args:
routine: the image-acquisition routine to run.
mqtt_client: an MQTT client which will be used to broadcast updates.
"""
super().__init__()
self._routine = routine
self._mqtt_client = mqtt_client.client
def run(self) -> None:
"""Run a stop-flow image-acquisition routine until completion or interruption."""
self._mqtt_client.publish("status/imager", '{"status":"Started"}')
while True:
if (result := self._routine.run_step()) is None:
if self._routine.interrupted:
loguru.logger.debug("Image-acquisition routine was interrupted!")
self._mqtt_client.publish("status/imager", '{"status":"Interrupted"}')
break
loguru.logger.debug("Image-acquisition routine ran to completion!")
self._mqtt_client.publish(
"status/imager",
json.dumps(
{
"status": "Done",
"path": self._routine.output_path,
}
),
)
break
index, filename = result
path = os.path.join(self._routine.output_path, filename)
try:
integrity.append_to_integrity_file(path)
except FileNotFoundError:
self._mqtt_client.publish(
"status/imager",
f'{{"status":"Image {index + 1}/{self._routine.settings.total_images} '
+ 'WAS NOT CAPTURED! STOPPING THE PROCESS!"}}',
)
break
# FIXME: remove
self._mqtt_client.publish(
"status/imager",
f'{{"status":"Image {index + 1}/{self._routine.settings.total_images} '
+ f'saved to {filename}"}}',
)
self._mqtt_client.publish(
"status/imager",
json.dumps(
{
"type": "progress",
"path": path,
"current": index + 1,
"total": self._routine.settings.total_images,
}
),
)
def stop(self) -> None:
"""Stop the thread.
Blocks until the thread is done.
Raises:
RuntimeError: this method was called before the thread was started.
"""
self._routine.stop()
self.join()
# TODO(ethanjli): rearchitect the hardware controller so that the imager can directly call pump
# methods (by running all modules in the same process), so that we can just delete this entire class
# and simplify function calls between the imager and the pump! This will require launching the
# pump and the imager as threads in the same process, rather than launching them as separate
# processes.
class _PumpClient:
"""Thread-safe RPC stub for remotely controlling the pump over MQTT."""
def __init__(self) -> None:
"""Initialize the stub."""
# Note(ethanjli): We have to have our own MQTT client because we need to publish messages
# from a separate thread, and currently the MQTT client isn't thread-safe (it deadlocks
# if we don't have a separate MQTT client):
self._mqtt: typing.Optional[mqtt.MQTT_Client] = None
self._mqtt_receiver_thread: typing.Optional[threading.Thread] = None
self._stop_receiving_mqtt = threading.Event() # close() was called
self._done = threading.Event() # run_discrete() finished or stop() was called
self._discrete_run = threading.Lock() # mutex on starting the pump
self._waiting_for_pump = False # FIX: Only accept Done after pump command sent
def open(self) -> None:
"""Start the pump MQTT client.
Launches a thread to listen for MQTT updates from the pump. After this method is called,
the `run_discrete()` and `stop()` methods can be called.
"""
if self._mqtt is not None:
return
self._mqtt = mqtt.MQTT_Client(topic="status/pump", name="imager_pump_client")
self._mqtt_receiver_thread = threading.Thread(target=self._receive_messages)
self._mqtt_receiver_thread.start()
def _receive_messages(self) -> None:
"""Update internal state based on pump status updates received over MQTT."""
assert self._mqtt is not None
while not self._stop_receiving_mqtt.is_set():
if not self._mqtt.new_message_received():
time.sleep(0.1)
continue
if self._mqtt.msg is None or self._mqtt.msg["topic"] != "status/pump":
continue
if self._mqtt.msg["payload"]["status"] not in {"Done", "Interrupted"}:
loguru.logger.debug(f"Ignoring pump status update: {self._mqtt.msg['payload']}")
self._mqtt.read_message()
continue
# FIX: Only process Done if we are actually waiting for pump to finish
# This prevents retained/stale Done messages from triggering early return
if not self._waiting_for_pump:
loguru.logger.debug(
f"Ignoring pump Done (not waiting for pump): {self._mqtt.msg['payload']}"
)
self._mqtt.read_message()
continue
loguru.logger.debug(f"The pump has stopped: {self._mqtt.msg['payload']}")
self._waiting_for_pump = False # FIX: Clear waiting flag
self._mqtt.client.unsubscribe("status/pump")
self._mqtt.read_message()
self._done.set()
if self._discrete_run.locked():
self._discrete_run.release()
def run_discrete(self, settings: stopflow.DiscretePumpSettings) -> None:
"""Run the pump for a discrete volume at the specified flow rate and direction.
Blocks until the pump has finished pumping. Before starting the pump, this first blocks
until the previous `run_discrete()` call (if it was started in another thread and is still
running) has finished.
Raises:
RuntimeError: this method was called before the `open()` method was called, or after
the `close()` method was called.
"""
if self._mqtt is None:
raise RuntimeError("MQTT client was not initialized yet!")
# We ignore the pylint error here because the lock can only be released from a different
# thread (the thread which calls the `handle_status_update()` method):
self._discrete_run.acquire() # pylint: disable=consider-using-with
self._waiting_for_pump = False # FIX: Not waiting yet (ignore retained messages)
self._done.clear()
self._mqtt.client.subscribe("status/pump")
self._mqtt.client.publish(
"actuator/pump",
json.dumps(
{
"action": "move",
"direction": settings.direction.value,
"flowrate": settings.flowrate,
"volume": settings.volume,
"from_acquisition": True, # FIX: Tag as acquisition command
}
),
)
self._waiting_for_pump = True # FIX: NOW we're waiting for pump Done
self._done.wait()
def stop(self) -> None:
"""Stop the pump."""
if self._mqtt is None:
raise RuntimeError("MQTT client was not initialized yet!")
self._mqtt.client.subscribe("status/pump")
self._mqtt.client.publish("actuator/pump", '{"action": "stop", "from_acquisition": true}')
def close(self) -> None:
"""Close the pump MQTT client, if it's currently open.
Stops the MQTT receiver thread and blocks until it finishes. After this method is called,
no methods are allowed to be called.
"""
if self._mqtt is None:
return
self._stop_receiving_mqtt.set()
if self._mqtt_receiver_thread is not None:
self._mqtt_receiver_thread.join()
self._mqtt_receiver_thread = None
self._mqtt.shutdown()
self._mqtt = None
# We don't know if the run is done (or if it'll ever finish), but we'll release the lock to
# prevent deadlocks:
if not self._discrete_run.locked():
return
self._discrete_run.release()
def read_config() -> typing.Any:
config = {}
try:
with open("/home/pi/PlanktoScope/hardware.json", "r") as file:
try:
config = json.load(file)
except Exception:
return None
except Exception:
return None
return config
def main():
configuration = read_config()
imager = Imager(configuration)
imager.run()
if __name__ == "__main__":
main()