diff --git a/control/planktoscopehat/main.py b/control/planktoscopehat/main.py index 45a91fbe..4533ec66 100644 --- a/control/planktoscopehat/main.py +++ b/control/planktoscopehat/main.py @@ -6,10 +6,12 @@ from loguru import logger +import planktoscope.focus import planktoscope.mqtt -import planktoscope.stepper + import planktoscope.light # Fan HAT LEDs import planktoscope.identity +import planktoscope.pump import planktoscope.uuidName # Note: this is deprecated. import planktoscope.display # Fan HAT OLED screen from planktoscope.imagernew import mqtt as imagernew @@ -87,10 +89,15 @@ def handler_stop_signals(signum, frame): shutdown_event = multiprocessing.Event() shutdown_event.clear() - # Starts the stepper process for actuators - logger.info("Starting the stepper control process (step 2/5)") - stepper_thread = planktoscope.stepper.StepperProcess(shutdown_event) - stepper_thread.start() + # Starts the focus process for actuators + logger.info("Starting the focus control process (step 2/5)") + focus_thread = planktoscope.focus.FocusProcess(shutdown_event) + focus_thread.start() + + # Starts the pump process for actuators + logger.info("Starting the focus control process (step 2/5)") + pump_thread = planktoscope.pump.PumpProcess(shutdown_event) + pump_thread.start() # TODO try to isolate the imager thread (or another thread) # Starts the imager control process @@ -118,29 +125,43 @@ def handler_stop_signals(signum, frame): logger.success("Looks like everything is set up and running, have fun!") + # With the creation of this dictionary to keep track of running threads, we can easily + running_threads = { + "pump": pump_thread, + "focus": focus_thread, + "light": light_thread, + "imager": imager_thread + } + while run: # TODO look into ways of restarting the dead threads logger.trace("Running around in circles while waiting for someone to die!") - if not stepper_thread.is_alive(): - logger.error("The stepper process died unexpectedly! Oh no!") - break - if not imager_thread or not imager_thread.is_alive(): - logger.error("The imager process died unexpectedly! Oh no!") + # Check if any threads have terminated unexpectedly and log the error without exiting + for thread_name, thread in running_threads.items(): + if not thread or not thread.is_alive(): + logger.error(f"The {thread_name} process terminated unexpectedly!") + del running_threads[thread_name] # Remove the dead thread from the dictionary + # Check if all threads have terminated so we can exit the program + if not running_threads: #checks if there is no running thread left + logger.error("All processes terminated unexpectedly! Exiting...") break time.sleep(1) + display.display_text("Bye Bye!") logger.info("Shutting down the shop") shutdown_event.set() time.sleep(1) - stepper_thread.join() + focus_thread.join() + pump_thread.join() if imager_thread: imager_thread.join() if light_thread: light_thread.join() - stepper_thread.close() + focus_thread.close() + pump_thread.close() if imager_thread: imager_thread.close() if light_thread: diff --git a/control/planktoscopehat/planktoscope/focus.py b/control/planktoscopehat/planktoscope/focus.py new file mode 100644 index 00000000..d3871030 --- /dev/null +++ b/control/planktoscopehat/planktoscope/focus.py @@ -0,0 +1,381 @@ +""" +This module provides the functionality to control the focus mechanism +of the Planktoscope. +""" + +# Libraries to control the steppers for focusing +import json +import multiprocessing +import os +import time +import typing + +import loguru + +import shush +from planktoscope import mqtt + +loguru.logger.info("planktoscope.stepper is loaded") + +FORWARD = 1 +BACKWARD = 2 +STEPPER1 = 0 +STEPPER2 = 1 + + +class Stepper: + """ + This class controls the stepper motor used for adjusting the focus. + """ + + def __init__(self, stepper, size): # pylint: disable=unused-argument + """ + Initialize the stepper class + + Args: + stepper (either STEPPER1 or STEPPER2): reference to the object that controls the stepper + size (int): maximum number of steps of this stepper (aka stage size). Can be 0 if not + applicable + """ + self.__stepper = shush.Motor(stepper) + self.__stepper.disable_motor() + self.__goal = 0 + self.__direction: typing.Optional[int] = None + + def at_goal(self): + """Is the motor at its goal + + Returns: + Bool: True if position and goal are identical + """ + return self.__stepper.get_position() == self.__goal + + def is_moving(self): + """is the stepper in movement? + + Returns: + Bool: True if the stepper is moving + """ + return self.__stepper.get_velocity() != 0 + + def go(self, direction, distance): + """ + Move in the given direction for the given distance. + + Args: + direction (int): The movement direction (FORWARD or BACKWARD). + distance (int): The distance to move. + """ + self.__direction = direction + if self.__direction == FORWARD: + self.__goal = int(self.__stepper.get_position() + distance) + elif self.__direction == BACKWARD: + self.__goal = int(self.__stepper.get_position() - distance) + else: + loguru.logger.error(f"The given direction is wrong {direction}") + self.__stepper.enable_motor() + self.__stepper.go_to(self.__goal) + + def shutdown(self): + """ + Shutdown everything ASAP. + """ + self.__stepper.stop_motor() + self.__stepper.disable_motor() + self.__goal = self.__stepper.get_position() + + def release(self): + """ + Disable the stepper motor. + """ + self.__stepper.disable_motor() + + @property + def speed(self): + """ + Returns: + int: The maximum speed (ramp_VMAX) of the stepper motor. + """ + return self.__stepper.ramp_VMAX + + @speed.setter + def speed(self, speed): + """Change the stepper speed + + Args: + speed (int): speed of the movement by the stepper, in microsteps unit/s + """ + loguru.logger.debug(f"Setting stepper speed to {speed}") + self.__stepper.ramp_VMAX = int(speed) + + @property + def acceleration(self): + """ + Returns: + int: The maximum acceleration (ramp_AMAX) of the stepper motor. + """ + return self.__stepper.ramp_AMAX + + @acceleration.setter + def acceleration(self, acceleration): + """Change the stepper acceleration + + Args: + acceleration (int): acceleration reachable by the stepper, in microsteps unit/s² + """ + loguru.logger.debug(f"Setting stepper acceleration to {acceleration}") + self.__stepper.ramp_AMAX = int(acceleration) + + @property + def deceleration(self): + """ + Returns: + int: The maximum deceleration (ramp_DMAX) of the stepper motor. + """ + return self.__stepper.ramp_DMAX + + @deceleration.setter + def deceleration(self, deceleration): + """Change the stepper deceleration + + Args: + deceleration (int): deceleration reachable by the stepper, in microsteps unit/s² + """ + loguru.logger.debug(f"Setting stepper deceleration to {deceleration}") + self.__stepper.ramp_DMAX = int(deceleration) + + +class FocusProcess(multiprocessing.Process): + """ + This class manages the focusing process using a stepper motor. + """ + + # 507 steps per ml for PlanktoScope standard + focus_steps_per_mm = 40 + + # focus max speed is in mm/sec and is limited by the maximum number of pulses per second the + # PlanktoScope can send + focus_max_speed = 5 + + def __init__(self, event): + """ + Initialize the FocusProcess. + + Args: + event (multiprocessing.Event): Event to signal the process to stop. + """ + super().__init__() + loguru.logger.info("Initialising the stepper process") + + self.stop_event = event + self.focus_started = False + self.actuator_client: typing.Optional[mqtt.MQTT_Client] = ( + None # Initialize actuator_client to None + ) + + if os.path.exists("/home/pi/PlanktoScope/hardware.json"): + # load hardware.json + with open("/home/pi/PlanktoScope/hardware.json", "r", encoding="utf-8") as config_file: + # TODO #100 insert guard for config_file empty + configuration = json.load(config_file) + loguru.logger.debug(f"Hardware configuration loaded is {configuration}") + else: + loguru.logger.info("The hardware configuration file doesn't exists, using defaults") + configuration = {} + + reverse = False + + # parse the config data. If the key is absent, we are using the default value + reverse = configuration.get("stepper_reverse", reverse) + self.focus_steps_per_mm = configuration.get("focus_steps_per_mm", self.focus_steps_per_mm) + self.focus_max_speed = configuration.get("focus_max_speed", self.focus_max_speed) + + # define the names for the 2 exsting steppers + if reverse: + + self.focus_stepper = Stepper(STEPPER1, size=45) + else: + + self.focus_stepper = Stepper(STEPPER2, size=45) + + # Set stepper controller max speed + + self.focus_stepper.acceleration = 1000 + self.focus_stepper.deceleration = self.focus_stepper.acceleration + self.focus_stepper.speed = self.focus_max_speed * self.focus_steps_per_mm * 256 + + loguru.logger.info("the focus stepper initialisation is over") + + def __message_focus(self, last_message): + """ + Handle a focusing request. + + Args: + last_message (dict): The last received message. + """ + loguru.logger.debug("We have received a focusing request") + action = last_message.get("action") + if action == "stop": + self.__handle_stop_action() + elif action == "move": + self.__handle_move_action(last_message) + else: + loguru.logger.warning(f"The received message was not understood {last_message}") + + def __handle_stop_action(self): + loguru.logger.debug("We have received a stop focus command") + self.focus_stepper.shutdown() + + loguru.logger.info("The focus has been interrupted") + if self.actuator_client: + self.actuator_client.client.publish("status/focus", '{"status":"Interrupted"}') + + def __handle_move_action(self, last_message): + loguru.logger.debug("We have received a move focus command") + if "direction" not in last_message or "distance" not in last_message: + loguru.logger.error(f"The received message has the wrong argument {last_message}") + if self.actuator_client: + self.actuator_client.client.publish("status/focus", '{"status":"Error"}') + return + direction = last_message["direction"] + distance = float(last_message["distance"]) + speed = float(last_message["speed"]) if "speed" in last_message else 0 + + loguru.logger.info("The focus movement is started.") + if speed: + self.focus(direction, distance, speed) + else: + self.focus(direction, distance) + + def treat_command(self): + """ + Process a received command. + """ + command = "" + if not self.actuator_client: + loguru.logger.error("Actuator client is not initialized") + return + + loguru.logger.info("We received a new message") + last_message = self.actuator_client.msg["payload"] # type: ignore + loguru.logger.debug(last_message) + command = self.actuator_client.msg["topic"].split("/", 1)[1] # type: ignore + loguru.logger.debug(command) + self.actuator_client.read_message() + + if command == "focus": + self.__message_focus(last_message) + elif command != "": + loguru.logger.warning( + f"We did not understand the received request {command} - {last_message}" + ) + + def focus(self, direction, distance, speed=focus_max_speed): + """Moves the focus stepper + + direction is either UP or DOWN + distance is received in mm + speed is in mm/sec + + Args: + direction (string): either UP or DOWN + distance (int): distance to move the stage, in mm + speed (int, optional): max speed of the stage, in mm/sec. Defaults to focus_max_speed. + """ + + loguru.logger.info( + f"The focus stage will move {direction} for {distance}mm at {speed}mm/sec" + ) + + # Validation of inputs + if direction not in ["UP", "DOWN"]: + loguru.logger.error("The direction command is not recognised") + loguru.logger.error("It should be either UP or DOWN") + return + + if distance > 45: + loguru.logger.error("You are trying to move more than the stage physical size") + return + + # We are going to use 256 microsteps, so we need to multiply by 256 the steps number + nb_steps = round(self.focus_steps_per_mm * distance * 256, 0) + loguru.logger.debug(f"The number of microsteps that will be applied is {nb_steps}") + if speed > self.focus_max_speed: + speed = self.focus_max_speed + loguru.logger.warning( + f"Focus stage speed has been clamped to a maximum safe speed of {speed} mm/sec" + ) + steps_per_second = speed * self.focus_steps_per_mm * 256 + loguru.logger.debug(f"There will be a speed of {steps_per_second} steps per second") + self.focus_stepper.speed = int(steps_per_second) + + # Publish the status "Started" to via MQTT to Node-RED + if self.actuator_client: + self.actuator_client.client.publish( + "status/focus", + f'{{"status":"Started", "duration":{nb_steps / steps_per_second}}}', + ) + + # Depending on direction, select the right direction for the focus + if direction == "UP": + self.focus_started = True + self.focus_stepper.go(FORWARD, nb_steps) + return + + if direction == "DOWN": + self.focus_started = True + self.focus_stepper.go(BACKWARD, nb_steps) + return + + # The pump max speed will be at about 400 full steps per second + # This amounts to 0.9mL per seconds maximum, or 54mL/min + # NEMA14 pump with 3 rollers is 0.509 mL per round, actual calculation at + # Stepper is 200 steps/round, or 393steps/ml + # https://www.wolframalpha.com/input/?i=pi+*+%280.8mm%29%C2%B2+*+54mm+*+3 + + @loguru.logger.catch + def run(self): + loguru.logger.info(f"The stepper control process has been started in process {os.getpid()}") + + # Creates the MQTT Client + self.actuator_client = mqtt.MQTT_Client(topic="actuator/#", name="actuator_client") + if self.actuator_client: + self.actuator_client.client.publish("status/focus", '{"status":"Ready"}') + + loguru.logger.success("Stepper is READY!") + while not self.stop_event.is_set(): + if self.actuator_client.new_message_received(): + self.treat_command() + if self.focus_started and self.focus_stepper.at_goal(): + loguru.logger.success("The focus movement is over!") + self.actuator_client.client.publish( + "status/focus", + '{"status":"Done"}', + ) + self.focus_started = False + self.focus_stepper.release() + time.sleep(0.01) + + loguru.logger.info("Shutting down the stepper process") + if self.actuator_client: + self.actuator_client.client.publish("status/focus", '{"status":"Dead"}') + self.focus_stepper.shutdown() + + if self.actuator_client: + self.actuator_client.shutdown() + loguru.logger.success("Stepper process shut down! See you!") + + +# This is called if this script is launched directly +if __name__ == "__main__": + # TODO This should be a test suite for this library + # Starts the stepper thread for actuators + # This needs to be in a threading or multiprocessing wrapper + stop_event = multiprocessing.Event() + focus_thread = FocusProcess(event=stop_event) + focus_thread.start() + try: + focus_thread.join() + except KeyboardInterrupt: + stop_event.set() + focus_thread.join() diff --git a/control/planktoscopehat/planktoscope/pump.py b/control/planktoscopehat/planktoscope/pump.py new file mode 100644 index 00000000..78401a29 --- /dev/null +++ b/control/planktoscopehat/planktoscope/pump.py @@ -0,0 +1,374 @@ +""" +This module provides the functionality to control the pump mechanism +of the Planktoscope. +""" + +# Libraries to control the steppers for pumping +import json +import multiprocessing +import os +import time +import typing + +import loguru + +import shush +from planktoscope import mqtt + +loguru.logger.info("planktoscope.stepper is loaded") + +FORWARD = 1 +BACKWARD = 2 +STEPPER1 = 0 +STEPPER2 = 1 + + +class Stepper: + """ + This class controls the stepper motor used for adjusting the pump. + """ + + def __init__(self, stepper): + """Initialize the stepper class + + Args: + stepper (either STEPPER1 or STEPPER2): reference to the object that controls the stepper + size (int): maximum number of steps of this stepper (aka stage size). Can be 0 if not + applicable + """ + self.__stepper = shush.Motor(stepper) + self.__stepper.disable_motor() + self.__goal = 0 + self.__direction: typing.Optional[int] = None + + def at_goal(self): + """Is the motor at its goal + + Returns: + Bool: True if position and goal are identical + """ + return self.__stepper.get_position() == self.__goal + + def is_moving(self): + """is the stepper in movement? + + Returns: + Bool: True if the stepper is moving + """ + return self.__stepper.get_velocity() != 0 + + def go(self, direction, distance): + """ + Move in the given direction for the given distance. + + Args: + direction (int): The movement direction (FORWARD or BACKWARD). + distance (int): The distance to move. + """ + self.__direction = direction + if self.__direction == FORWARD: + self.__goal = int(self.__stepper.get_position() + distance) + elif self.__direction == BACKWARD: + self.__goal = int(self.__stepper.get_position() - distance) + else: + loguru.logger.error(f"The given direction is wrong {direction}") + self.__stepper.enable_motor() + self.__stepper.go_to(self.__goal) + + def shutdown(self): + """ + Shutdown everything ASAP. + """ + self.__stepper.stop_motor() + self.__stepper.disable_motor() + self.__goal = self.__stepper.get_position() + + def release(self): + """ + Disable the stepper motor. + """ + self.__stepper.disable_motor() + + @property + def speed(self): + """ + Returns: + int: The maximum speed (ramp_VMAX) of the stepper motor. + """ + return self.__stepper.ramp_VMAX + + @speed.setter + def speed(self, speed): + """Change the stepper speed + + Args: + speed (int): speed of the movement by the stepper, in microsteps unit/s + """ + loguru.logger.debug(f"Setting stepper speed to {speed}") + self.__stepper.ramp_VMAX = int(speed) + + @property + def acceleration(self): + """ + Returns: + int: The maximum acceleration (ramp_AMAX) of the stepper motor. + """ + return self.__stepper.ramp_AMAX + + @acceleration.setter + def acceleration(self, acceleration): + """Change the stepper acceleration + + Args: + acceleration (int): acceleration reachable by the stepper, in microsteps unit/s² + """ + loguru.logger.debug(f"Setting stepper acceleration to {acceleration}") + self.__stepper.ramp_AMAX = int(acceleration) + + @property + def deceleration(self): + """ + Returns: + int: The maximum deceleration (ramp_DMAX) of the stepper motor. + """ + return self.__stepper.ramp_DMAX + + @deceleration.setter + def deceleration(self, deceleration): + """Change the stepper deceleration + + Args: + deceleration (int): deceleration reachable by the stepper, in microsteps unit/s² + """ + loguru.logger.debug(f"Setting stepper deceleration to {deceleration}") + self.__stepper.ramp_DMAX = int(deceleration) + + +class PumpProcess(multiprocessing.Process): + """ + This class manages the pumping process using a stepper motor. + """ + + # 5200 for custom NEMA14 pump with 0.8mm ID Tube + pump_steps_per_ml = 507 + + # pump max speed is in ml/min + pump_max_speed = 50 + + def __init__(self, event): + """ + Initialize the pump process. + + Args: + event (multiprocessing.Event): Event to control the stopping of the process + """ + super().__init__() + loguru.logger.info("Initialising the stepper process") + + self.stop_event = event + self.pump_started = False + self.actuator_client = None # Initialize actuator_client to None + + if os.path.exists("/home/pi/PlanktoScope/hardware.json"): + # load hardware.json + with open("/home/pi/PlanktoScope/hardware.json", "r", encoding="utf-8") as config_file: + # TODO #100 insert guard for config_file empty + configuration = json.load(config_file) + loguru.logger.debug(f"Hardware configuration loaded is {configuration}") + else: + loguru.logger.info("The hardware configuration file doesn't exists, using defaults") + configuration = {} + + reverse = False + + # parse the config data. If the key is absent, we are using the default value + reverse = configuration.get("stepper_reverse", reverse) + + self.pump_steps_per_ml = configuration.get("pump_steps_per_ml", self.pump_steps_per_ml) + self.pump_max_speed = configuration.get("pump_max_speed", self.pump_max_speed) + + # define the names for the 2 exsting steppers + if reverse: + self.pump_stepper = Stepper(STEPPER2) + + else: + self.pump_stepper = Stepper(STEPPER1) + + # Set pump controller max speed + self.pump_stepper.acceleration = 2000 + self.pump_stepper.deceleration = self.pump_stepper.acceleration + self.pump_stepper.speed = self.pump_max_speed * self.pump_steps_per_ml * 256 / 60 + + loguru.logger.info("Stepper initialisation is over") + + def __message_pump(self, last_message): + """ + Handle the pump message received from the actuator client. + + Args: + last_message (dict): The last message received. + """ + loguru.logger.debug("We have received a pumping command") + action = last_message.get("action") + + if action == "stop": + self._handle_stop_action() + elif action == "move": + self._handle_move_action(last_message) + else: + loguru.logger.warning(f"The received message was not understood {last_message}") + + def _handle_stop_action(self): + """ + Handle the 'stop' action for the pump. + """ + loguru.logger.debug("We have received a stop pump command") + self.pump_stepper.shutdown() + loguru.logger.info("The pump has been interrupted") + if self.actuator_client: + self.actuator_client.client.publish("status/pump", '{"status":"Interrupted"}') + + def _handle_move_action(self, last_message): + """ + Handle the 'move' action for the pump. + + Args: + last_message (dict): The last message received. + """ + loguru.logger.debug("We have received a move pump command") + if ( + "direction" not in last_message + or "volume" not in last_message + or "flowrate" not in last_message + ): + loguru.logger.error(f"The received message has the wrong argument {last_message}") + if self.actuator_client: + self.actuator_client.client.publish( + "status/pump", '{"status":"Error, the message is missing an argument"}' + ) + return + + direction = last_message["direction"] + volume = float(last_message["volume"]) + flowrate = float(last_message["flowrate"]) + + if (flowrate := float(last_message["flowrate"])) == 0: + loguru.logger.error("The flowrate should not be == 0") + if self.actuator_client: + self.actuator_client.client.publish( + "status/pump", '{"status":"Error, The flowrate should not be == 0"}' + ) + return + + loguru.logger.info("The pump is started.") + self.pump(direction, volume, flowrate) + + def treat_command(self): + """ + Treat the received command. + """ + loguru.logger.info("We received a new message") + if not self.actuator_client: + loguru.logger.error("Actuator client is not initialized") + return + + last_message = self.actuator_client.msg["payload"] + loguru.logger.debug(last_message) + command = self.actuator_client.msg["topic"].split("/", 1)[1] + loguru.logger.debug(command) + self.actuator_client.read_message() + + if command == "pump": + self.__message_pump(last_message) + elif command != "": + loguru.logger.warning( + f"We did not understand the received request {command} - {last_message}" + ) + + def pump(self, direction, volume, speed=pump_max_speed): + """Moves the pump stepper + + Args: + direction (string): direction of the pumping + volume (int): volume to pump, in mL + speed (int, optional): speed of pumping, in mL/min. Defaults to pump_max_speed. + """ + + loguru.logger.info(f"The pump will move {direction} for {volume}mL at {speed}mL/min") + + if direction not in ["FORWARD", "BACKWARD"]: + loguru.logger.error("The direction command is not recognised") + loguru.logger.error("It should be either FORWARD or BACKWARD") + return + + nb_steps = round(self.pump_steps_per_ml * volume * 256, 0) + loguru.logger.debug(f"The number of microsteps that will be applied is {nb_steps}") + if speed > self.pump_max_speed: + speed = self.pump_max_speed + loguru.logger.warning( + f"Pump speed has been clamped to a maximum safe speed of {speed}mL/min" + ) + steps_per_second = speed * self.pump_steps_per_ml * 256 / 60 + loguru.logger.debug(f"There will be a speed of {steps_per_second} steps per second") + self.pump_stepper.speed = int(steps_per_second) + + if self.actuator_client: + self.actuator_client.client.publish( + "status/pump", + f'{{"status":"Started", "duration":{nb_steps / steps_per_second}}}', + ) + + if direction == "FORWARD": + self.pump_started = True + self.pump_stepper.go(FORWARD, nb_steps) + return + + if direction == "BACKWARD": + self.pump_started = True + self.pump_stepper.go(BACKWARD, nb_steps) + return + + @loguru.logger.catch + def run(self): + loguru.logger.info(f"The stepper control process has been started in process {os.getpid()}") + + self.actuator_client = mqtt.MQTT_Client(topic="actuator/#", name="actuator_client") + self.actuator_client.client.publish("status/pump", '{"status":"Ready"}') + + loguru.logger.success("The pump is READY!") + while not self.stop_event.is_set(): + if self.actuator_client and self.actuator_client.new_message_received(): + self.treat_command() + if self.pump_started and self.pump_stepper.at_goal(): + loguru.logger.success("The pump movement is over!") + self.actuator_client.client.publish( + "status/pump", + '{"status":"Done"}', + ) + self.pump_started = False + self.pump_stepper.release() + + time.sleep(0.01) + loguru.logger.info("Shutting down the stepper process") + if self.actuator_client: + self.actuator_client.client.publish("status/pump", '{"status":"Dead"}') + self.pump_stepper.shutdown() + + if self.actuator_client: + self.actuator_client.shutdown() + loguru.logger.success("Stepper process shut down! See you!") + + +# This is called if this script is launched directly +if __name__ == "__main__": + # TODO This should be a test suite for this library + # Starts the stepper thread for actuators + # This needs to be in a threading or multiprocessing wrapper + stop_event = multiprocessing.Event() + pump_thread = PumpProcess(event=stop_event) + pump_thread.start() + try: + pump_thread.join() + except KeyboardInterrupt: + stop_event.set() + pump_thread.join() diff --git a/control/planktoscopehat/planktoscope/stepper.py b/control/planktoscopehat/planktoscope/stepper.py deleted file mode 100644 index 4c93b0f5..00000000 --- a/control/planktoscopehat/planktoscope/stepper.py +++ /dev/null @@ -1,465 +0,0 @@ -# Libraries to control the steppers for focusing and pumping -import time -import json -import os -import planktoscope.mqtt -import multiprocessing -import RPi.GPIO - -import shush - -# Logger library compatible with multiprocessing -from loguru import logger - -logger.info("planktoscope.stepper is loaded") - - -"""Step forward""" -FORWARD = 1 -""""Step backward""" -BACKWARD = 2 -"""Stepper controller 1""" -STEPPER1 = 0 -""""Stepper controller 2""" -STEPPER2 = 1 - - -class stepper: - def __init__(self, stepper, size=0): - """Initialize the stepper class - - Args: - stepper (either STEPPER1 or STEPPER2): reference to the object that controls the stepper - size (int): maximum number of steps of this stepper (aka stage size). Can be 0 if not applicable - """ - self.__stepper = shush.Motor(stepper) - self.__size = size - self.__goal = 0 - self.__direction = "" - self.__stepper.disable_motor() - - def at_goal(self): - """Is the motor at its goal - - Returns: - Bool: True if position and goal are identical - """ - return self.__stepper.get_position() == self.__goal - - def is_moving(self): - """is the stepper in movement? - - Returns: - Bool: True if the stepper is moving - """ - return self.__stepper.get_velocity() != 0 - - def go(self, direction, distance): - """move in the given direction for the given distance - - Args: - direction: gives the movement direction - distance: - """ - self.__direction = direction - if self.__direction == FORWARD: - self.__goal = int(self.__stepper.get_position() + distance) - elif self.__direction == BACKWARD: - self.__goal = int(self.__stepper.get_position() - distance) - else: - logger.error(f"The given direction is wrong {direction}") - self.__stepper.enable_motor() - self.__stepper.go_to(self.__goal) - - def shutdown(self): - """Shutdown everything ASAP""" - self.__stepper.stop_motor() - self.__stepper.disable_motor() - self.__goal = self.__stepper.get_position() - - def release(self): - self.__stepper.disable_motor() - - @property - def speed(self): - return self.__stepper.ramp_VMAX - - @speed.setter - def speed(self, speed): - """Change the stepper speed - - Args: - speed (int): speed of the movement by the stepper, in microsteps unit/s - """ - logger.debug(f"Setting stepper speed to {speed}") - self.__stepper.ramp_VMAX = int(speed) - - @property - def acceleration(self): - return self.__stepper.ramp_AMAX - - @acceleration.setter - def acceleration(self, acceleration): - """Change the stepper acceleration - - Args: - acceleration (int): acceleration reachable by the stepper, in microsteps unit/s² - """ - logger.debug(f"Setting stepper acceleration to {acceleration}") - self.__stepper.ramp_AMAX = int(acceleration) - - @property - def deceleration(self): - return self.__stepper.ramp_DMAX - - @deceleration.setter - def deceleration(self, deceleration): - """Change the stepper deceleration - - Args: - deceleration (int): deceleration reachable by the stepper, in microsteps unit/s² - """ - logger.debug(f"Setting stepper deceleration to {deceleration}") - self.__stepper.ramp_DMAX = int(deceleration) - - -class StepperProcess(multiprocessing.Process): - focus_steps_per_mm = 40 - # 507 steps per ml for PlanktoScope standard - # 5200 for custom NEMA14 pump with 0.8mm ID Tube - pump_steps_per_ml = 507 - # focus max speed is in mm/sec and is limited by the maximum number of pulses per second the PlanktoScope can send - focus_max_speed = 5 - # pump max speed is in ml/min - pump_max_speed = 50 - - def __init__(self, event): - super(StepperProcess, self).__init__() - logger.info("Initialising the stepper process") - - self.stop_event = event - self.focus_started = False - self.pump_started = False - - if os.path.exists("/home/pi/PlanktoScope/hardware.json"): - # load hardware.json - with open("/home/pi/PlanktoScope/hardware.json", "r") as config_file: - # TODO #100 insert guard for config_file empty - configuration = json.load(config_file) - logger.debug(f"Hardware configuration loaded is {configuration}") - else: - logger.info( - "The hardware configuration file doesn't exists, using defaults" - ) - configuration = {} - - reverse = False - - # parse the config data. If the key is absent, we are using the default value - reverse = configuration.get("stepper_reverse", reverse) - self.focus_steps_per_mm = configuration.get( - "focus_steps_per_mm", self.focus_steps_per_mm - ) - self.pump_steps_per_ml = configuration.get( - "pump_steps_per_ml", self.pump_steps_per_ml - ) - self.focus_max_speed = configuration.get( - "focus_max_speed", self.focus_max_speed - ) - self.pump_max_speed = configuration.get("pump_max_speed", self.pump_max_speed) - - # define the names for the 2 exsting steppers - if reverse: - self.pump_stepper = stepper(STEPPER2) - self.focus_stepper = stepper(STEPPER1, size=45) - else: - self.pump_stepper = stepper(STEPPER1) - self.focus_stepper = stepper(STEPPER2, size=45) - - # Set stepper controller max speed - - self.focus_stepper.acceleration = 1000 - self.focus_stepper.deceleration = self.focus_stepper.acceleration - self.focus_stepper.speed = self.focus_max_speed * self.focus_steps_per_mm * 256 - - self.pump_stepper.acceleration = 2000 - self.pump_stepper.deceleration = self.pump_stepper.acceleration - self.pump_stepper.speed = ( - self.pump_max_speed * self.pump_steps_per_ml * 256 / 60 - ) - - logger.info("Stepper initialisation is over") - - def __message_pump(self, last_message): - logger.debug("We have received a pumping command") - if last_message["action"] == "stop": - logger.debug("We have received a stop pump command") - self.pump_stepper.shutdown() - - # Print status - logger.info("The pump has been interrupted") - - # Publish the status "Interrupted" to via MQTT to Node-RED - self.actuator_client.client.publish( - "status/pump", '{"status":"Interrupted"}' - ) - - elif last_message["action"] == "move": - logger.debug("We have received a move pump command") - - if ( - "direction" not in last_message - or "volume" not in last_message - or "flowrate" not in last_message - ): - logger.error( - f"The received message has the wrong argument {last_message}" - ) - self.actuator_client.client.publish( - "status/pump", - '{"status":"Error, the message is missing an argument"}', - ) - return - # Get direction from the different received arguments - direction = last_message["direction"] - # Get delay (in between steps) from the different received arguments - volume = float(last_message["volume"]) - # Get number of steps from the different received arguments - flowrate = float(last_message["flowrate"]) - if flowrate == 0: - logger.error("The flowrate should not be == 0") - self.actuator_client.client.publish( - "status/pump", '{"status":"Error, The flowrate should not be == 0"}' - ) - return - - # Print status - logger.info("The pump is started.") - self.pump(direction, volume, flowrate) - else: - logger.warning(f"The received message was not understood {last_message}") - - def __message_focus(self, last_message): - logger.debug("We have received a focusing request") - # If a new received command is "focus" but args contains "stop" we stop! - if last_message["action"] == "stop": - logger.debug("We have received a stop focus command") - self.focus_stepper.shutdown() - - # Print status - logger.info("The focus has been interrupted") - - # Publish the status "Interrupted" to via MQTT to Node-RED - self.actuator_client.client.publish( - "status/focus", '{"status":"Interrupted"}' - ) - - elif last_message["action"] == "move": - logger.debug("We have received a move focus command") - - if "direction" not in last_message or "distance" not in last_message: - logger.error( - f"The received message has the wrong argument {last_message}" - ) - self.actuator_client.client.publish( - "status/focus", '{"status":"Error"}' - ) - # Get direction from the different received arguments - direction = last_message["direction"] - # Get number of steps from the different received arguments - distance = float(last_message["distance"]) - - speed = float(last_message["speed"]) if "speed" in last_message else 0 - - # Print status - logger.info("The focus movement is started.") - if speed: - self.focus(direction, distance, speed) - else: - self.focus(direction, distance) - else: - logger.warning(f"The received message was not understood {last_message}") - - def treat_command(self): - command = "" - logger.info("We received a new message") - last_message = self.actuator_client.msg["payload"] - logger.debug(last_message) - command = self.actuator_client.msg["topic"].split("/", 1)[1] - logger.debug(command) - self.actuator_client.read_message() - - if command == "pump": - self.__message_pump(last_message) - elif command == "focus": - self.__message_focus(last_message) - elif command != "": - logger.warning( - f"We did not understand the received request {command} - {last_message}" - ) - - def focus(self, direction, distance, speed=focus_max_speed): - """Moves the focus stepper - - direction is either UP or DOWN - distance is received in mm - speed is in mm/sec - - Args: - direction (string): either UP or DOWN - distance (int): distance to move the stage, in mm - speed (int, optional): max speed of the stage, in mm/sec. Defaults to focus_max_speed. - """ - - logger.info( - f"The focus stage will move {direction} for {distance}mm at {speed}mm/sec" - ) - - # Validation of inputs - if direction not in ["UP", "DOWN"]: - logger.error("The direction command is not recognised") - logger.error("It should be either UP or DOWN") - return - - if distance > 45: - logger.error("You are trying to move more than the stage physical size") - return - - # We are going to use 256 microsteps, so we need to multiply by 256 the steps number - nb_steps = round(self.focus_steps_per_mm * distance * 256, 0) - logger.debug(f"The number of microsteps that will be applied is {nb_steps}") - if speed > self.focus_max_speed: - speed = self.focus_max_speed - logger.warning( - f"Focus stage speed has been clamped to a maximum safe speed of {speed} mm/sec" - ) - steps_per_second = speed * self.focus_steps_per_mm * 256 - logger.debug(f"There will be a speed of {steps_per_second} steps per second") - self.focus_stepper.speed = int(steps_per_second) - - # Publish the status "Started" to via MQTT to Node-RED - self.actuator_client.client.publish( - "status/focus", - f'{{"status":"Started", "duration":{nb_steps / steps_per_second}}}', - ) - - # Depending on direction, select the right direction for the focus - if direction == "UP": - self.focus_started = True - self.focus_stepper.go(FORWARD, nb_steps) - return - - if direction == "DOWN": - self.focus_started = True - self.focus_stepper.go(BACKWARD, nb_steps) - return - - # The pump max speed will be at about 400 full steps per second - # This amounts to 0.9mL per seconds maximum, or 54mL/min - # NEMA14 pump with 3 rollers is 0.509 mL per round, actual calculation at - # Stepper is 200 steps/round, or 393steps/ml - # https://www.wolframalpha.com/input/?i=pi+*+%280.8mm%29%C2%B2+*+54mm+*+3 - def pump(self, direction, volume, speed=pump_max_speed): - """Moves the pump stepper - - Args: - direction (string): direction of the pumping - volume (int): volume to pump, in mL - speed (int, optional): speed of pumping, in mL/min. Defaults to pump_max_speed. - """ - - logger.info(f"The pump will move {direction} for {volume}mL at {speed}mL/min") - - # Validation of inputs - if direction not in ["FORWARD", "BACKWARD"]: - logger.error("The direction command is not recognised") - logger.error("It should be either FORWARD or BACKWARD") - return - - # TMC5160 is configured for 256 microsteps - nb_steps = round(self.pump_steps_per_ml * volume * 256, 0) - logger.debug(f"The number of microsteps that will be applied is {nb_steps}") - if speed > self.pump_max_speed: - speed = self.pump_max_speed - logger.warning( - f"Pump speed has been clamped to a maximum safe speed of {speed}mL/min" - ) - steps_per_second = speed * self.pump_steps_per_ml * 256 / 60 - logger.debug(f"There will be a speed of {steps_per_second} steps per second") - self.pump_stepper.speed = int(steps_per_second) - - # Publish the status "Started" to via MQTT to Node-RED - self.actuator_client.client.publish( - "status/pump", - f'{{"status":"Started", "duration":{nb_steps / steps_per_second}}}', - ) - - # Depending on direction, select the right direction for the focus - if direction == "FORWARD": - self.pump_started = True - self.pump_stepper.go(FORWARD, nb_steps) - return - - if direction == "BACKWARD": - self.pump_started = True - self.pump_stepper.go(BACKWARD, nb_steps) - return - - @logger.catch - def run(self): - """This is the function that needs to be started to create a thread""" - logger.info( - f"The stepper control process has been started in process {os.getpid()}" - ) - - # Creates the MQTT Client - # We have to create it here, otherwise when the process running run is started - # it doesn't see changes and calls made by self.actuator_client because this one - # only exist in the master process - # see https://stackoverflow.com/questions/17172878/using-pythons-multiprocessing-process-class - self.actuator_client = planktoscope.mqtt.MQTT_Client( - topic="actuator/#", name="actuator_client" - ) - # Publish the status "Ready" to via MQTT to Node-RED - self.actuator_client.client.publish("status/pump", '{"status":"Ready"}') - # Publish the status "Ready" to via MQTT to Node-RED - self.actuator_client.client.publish("status/focus", '{"status":"Ready"}') - - logger.success("Stepper is READY!") - while not self.stop_event.is_set(): - if self.actuator_client.new_message_received(): - self.treat_command() - if self.pump_started and self.pump_stepper.at_goal(): - logger.success("The pump movement is over!") - self.actuator_client.client.publish( - "status/pump", - '{"status":"Done"}', - ) - self.pump_started = False - self.pump_stepper.release() - if self.focus_started and self.focus_stepper.at_goal(): - logger.success("The focus movement is over!") - self.actuator_client.client.publish( - "status/focus", - '{"status":"Done"}', - ) - self.focus_started = False - self.pump_stepper.release() - time.sleep(0.01) - logger.info("Shutting down the stepper process") - self.actuator_client.client.publish("status/pump", '{"status":"Dead"}') - self.actuator_client.client.publish("status/focus", '{"status":"Dead"}') - self.pump_stepper.shutdown() - self.focus_stepper.shutdown() - self.actuator_client.shutdown() - logger.success("Stepper process shut down! See you!") - - -# This is called if this script is launched directly -if __name__ == "__main__": - # TODO This should be a test suite for this library - # Starts the stepper thread for actuators - # This needs to be in a threading or multiprocessing wrapper - stepper_thread = StepperProcess() - stepper_thread.start() - stepper_thread.join() diff --git a/control/pyproject.toml b/control/pyproject.toml index 280b0a0a..c16d592e 100644 --- a/control/pyproject.toml +++ b/control/pyproject.toml @@ -240,6 +240,13 @@ exclude = [ 'planktoscopehat/planktoscope/imager/.*', ] +[[tool.mypy.overrides]] +# the skip module is an externally-provided package which we don't want to touch: +module = [ + 'shush.*', +] +follow_imports = 'skip' + [tool.pylama] # We are gradually introducing linting as we rewrite each module; we haven't rewritten the following # files yet: