Exemplo n.º 1
0
    def __init__(self,
                 target_growth_rate=None,
                 target_od=None,
                 volume=None,
                 **kwargs):
        super(PIDMorbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"
        assert target_growth_rate is not None, "`target_growth_rate` must be set"

        self.set_target_growth_rate(target_growth_rate)
        self.target_od = float(target_od)

        Kp = config.getfloat("pid_morbidostat", "Kp")
        Ki = config.getfloat("pid_morbidostat", "Ki")
        Kd = config.getfloat("pid_morbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_growth_rate,
            output_limits=(0, 1),
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
        )

        if volume is not None:
            self.logger.info(
                "Ignoring volume parameter; volume set by target growth rate and duration."
            )

        self.volume = round(
            self.target_growth_rate * VIAL_VOLUME * (self.duration / 60), 4)
Exemplo n.º 2
0
    def __init__(self, target_od: float, **kwargs) -> None:
        super(PIDTurbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"

        with local_persistant_storage("pump_calibration") as cache:
            if "media_ml_calibration" not in cache:
                raise RuntimeError("Media pump calibration must be performed first.")
            elif "waste_ml_calibration" not in cache:
                raise RuntimeError("Waste pump calibration must be performed first.")

        self.set_target_od(target_od)
        self.volume_to_cycle = None

        # PID%20controller%20turbidostat.ipynb
        Kp = config.getfloat("dosing_automation.pid_turbidostat", "Kp")
        Ki = config.getfloat("dosing_automation.pid_turbidostat", "Ki")
        Kd = config.getfloat("dosing_automation.pid_turbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_od,
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="od",
        )
Exemplo n.º 3
0
    def __init__(
        self,
        target_rpm: float,
        unit: str,
        experiment: str,
        rpm_calculator: Optional[RpmCalculator],
        hertz: float = 150,
    ) -> None:
        super(Stirrer, self).__init__(job_name="stirring",
                                      unit=unit,
                                      experiment=experiment)
        self.logger.debug(f"Starting stirring with initial {target_rpm} RPM.")
        self.rpm_calculator = rpm_calculator

        if not hardware.is_HAT_present():
            self.logger.error("Pioreactor HAT must be present.")
            self.set_state(self.DISCONNECTED)
            raise exc.HardwareNotFoundError("Pioreactor HAT must be present.")

        if (self.rpm_calculator
                is not None) and not hardware.is_heating_pcb_present():
            self.logger.error("Heating PCB must be present to measure RPM.")
            self.set_state(self.DISCONNECTED)
            raise exc.HardwareNotFoundError(
                "Heating PCB must be present to measure RPM.")

        pin = hardware.PWM_TO_PIN[config.get("PWM_reverse", "stirring")]
        self.pwm = PWM(pin, hertz)
        self.pwm.lock()

        self.rpm_to_dc_lookup = self.initialize_rpm_to_dc_lookup()
        self.target_rpm = target_rpm
        self.duty_cycle = self.rpm_to_dc_lookup(self.target_rpm)

        # set up PID
        self.pid = PID(
            Kp=config.getfloat("stirring.pid", "Kp"),
            Ki=config.getfloat("stirring.pid", "Ki"),
            Kd=config.getfloat("stirring.pid", "Kd"),
            setpoint=self.target_rpm,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="rpm",
            output_limits=(-20, 20),  # avoid whiplashing
        )

        # set up thread to periodically check the rpm
        self.rpm_check_repeated_thread = RepeatedTimer(
            17,  # 17 and 5 are coprime
            self.poll_and_update_dc,
            job_name=self.job_name,
            run_immediately=True,
            run_after=5,
            poll_for_seconds=
            4,  # technically should be a function of the RPM: lower RPM, longer to get sufficient data.
        )
Exemplo n.º 4
0
class Stable(TemperatureAutomation):
    """
    Uses a PID controller to change the DC% to match a target temperature.
    """

    automation_name = "stable"
    published_settings = {
        "target_temperature": {"datatype": "float", "unit": "℃", "settable": True}
    }

    def __init__(self, target_temperature: float, **kwargs) -> None:
        super(Stable, self).__init__(**kwargs)
        assert target_temperature is not None, "target_temperature must be set"
        self.target_temperature = float(target_temperature)

        self.pid = PID(
            Kp=config.getfloat("temperature_automation.stable", "Kp"),
            Ki=config.getfloat("temperature_automation.stable", "Ki"),
            Kd=config.getfloat("temperature_automation.stable", "Kd"),
            setpoint=self.target_temperature,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="temperature",
        )

    def execute(self) -> None:
        while not hasattr(self, "pid"):
            # sometimes when initializing, this execute can run before the sublasses __init__ is resolved.
            pass

        output = self.pid.update(
            self.latest_temperature, dt=1
        )  # 1 represents an arbitrary unit of time. The PID values will scale such that 1 makes sense.
        self.update_heater_with_delta(output)
        self.logger.debug(f"delta={output}")
        return

    def set_target_temperature(self, value) -> None:
        if float(value) > 50:
            self.logger.warning("Values over 50℃ are not supported. Setting to 50℃.")

        target_temperature = clamp(0, float(value), 50)
        self.target_temperature = target_temperature
        self.pid.set_setpoint(self.target_temperature)

        # when set_target_temperature is executed, and we wish to update the DC to some new value,
        # it's possible that it isn't updated immediately if set during the `evaluate` routine.
        if not self.is_heater_pwm_locked():
            output = self.pid.update(
                self.latest_temperature, dt=1
            )  # 1 represents an arbitrary unit of time. The PID values will scale such that 1 makes sense.
            self.update_heater_with_delta(
                output / 2
            )  # the change occurs, on average, half way into the cycle.
Exemplo n.º 5
0
    def __init__(self, target_temperature: float, **kwargs) -> None:
        super(Stable, self).__init__(**kwargs)
        assert target_temperature is not None, "target_temperature must be set"
        self.target_temperature = float(target_temperature)

        self.pid = PID(
            Kp=config.getfloat("temperature_automation.stable", "Kp"),
            Ki=config.getfloat("temperature_automation.stable", "Ki"),
            Kd=config.getfloat("temperature_automation.stable", "Kd"),
            setpoint=self.target_temperature,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="temperature",
        )
Exemplo n.º 6
0
    def __init__(self,
                 target_growth_rate=None,
                 target_od=None,
                 volume=None,
                 **kwargs):
        super(PIDMorbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"
        assert target_growth_rate is not None, "`target_growth_rate` must be set"

        with local_persistant_storage("pump_calibration") as cache:
            if "media_ml_calibration" not in cache:
                raise RuntimeError(
                    "Media pump calibration must be performed first.")
            elif "waste_ml_calibration" not in cache:
                raise RuntimeError(
                    "Waste pump calibration must be performed first.")
            elif "alt_media_ml_calibration" not in cache:
                raise RuntimeError(
                    "Alt-Media pump calibration must be performed first.")

        self.set_target_growth_rate(target_growth_rate)
        self.target_od = float(target_od)

        Kp = config.getfloat("dosing_automation.pid_morbidostat", "Kp")
        Ki = config.getfloat("dosing_automation.pid_morbidostat", "Ki")
        Kd = config.getfloat("dosing_automation.pid_morbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_growth_rate,
            output_limits=(0, 1),
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="growth_rate",
        )

        if volume is not None:
            self.logger.info(
                "Ignoring volume parameter; volume set by target growth rate and duration."
            )

        assert isinstance(self.duration, float)
        self.volume = round(
            self.target_growth_rate * VIAL_VOLUME * (self.duration / 60), 4)
Exemplo n.º 7
0
    def __init__(self, target_od=None, volume=None, **kwargs):
        super(PIDTurbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"
        assert volume is not None, "`volume` must be set"
        self.set_target_od(target_od)
        self.volume = float(volume)

        # PID%20controller%20turbidostat.ipynb
        Kp = config.getfloat("pid_turbidostat", "Kp")
        Ki = config.getfloat("pid_turbidostat", "Ki")
        Kd = config.getfloat("pid_turbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_od,
            output_limits=(0, 1),
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
        )
Exemplo n.º 8
0
class PIDTurbidostat(DosingAutomation):
    """
    turbidostat mode - try to keep cell density constant using a PID target at the OD.

    """

    automation_name = "pid_turbidostat"
    published_settings = {
        "target_od": {"datatype": "float", "settable": True, "unit": "AU"},
        "duration": {"datatype": "float", "settable": True, "unit": "min"},
    }

    def __init__(self, target_od: float, **kwargs) -> None:
        super(PIDTurbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"

        with local_persistant_storage("pump_calibration") as cache:
            if "media_ml_calibration" not in cache:
                raise RuntimeError("Media pump calibration must be performed first.")
            elif "waste_ml_calibration" not in cache:
                raise RuntimeError("Waste pump calibration must be performed first.")

        self.set_target_od(target_od)
        self.volume_to_cycle = None

        # PID%20controller%20turbidostat.ipynb
        Kp = config.getfloat("dosing_automation.pid_turbidostat", "Kp")
        Ki = config.getfloat("dosing_automation.pid_turbidostat", "Ki")
        Kd = config.getfloat("dosing_automation.pid_turbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_od,
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="od",
        )

    def execute(self) -> events.Event:
        import numpy as np

        if self.latest_od <= self.min_od:
            return events.NoEvent(
                f"current OD, {self.latest_od:.2f}, less than OD to start diluting, {self.min_od:.2f}"
            )
        else:
            assert self.duration is not None
            if self.volume_to_cycle is None:
                self.volume_to_cycle = (
                    14
                    - (
                        14
                        * np.exp(-(self.duration * self.latest_growth_rate) / 60)
                        * self.target_od
                    )
                    / self.latest_od
                )

            assert isinstance(self.volume_to_cycle, float)

            pid_output = self.pid.update(self.latest_od, dt=self.duration)
            self.volume_to_cycle = max(0, self.volume_to_cycle + pid_output)

            if self.volume_to_cycle < 0.01:
                return events.NoEvent("Practically no volume to cycle")
            else:
                volumes_actually_moved = self.execute_io_action(
                    media_ml=self.volume_to_cycle, waste_ml=self.volume_to_cycle
                )
                return events.DilutionEvent(
                    f"Volume cycled={volumes_actually_moved[0]:.2f}mL"
                )

    @property
    def min_od(self) -> float:
        return 0.75 * self.target_od

    def set_target_od(self, value: float) -> None:
        self.target_od = float(value)
        with suppress(AttributeError):
            # may not be defined yet...
            self.pid.set_setpoint(self.target_od)
Exemplo n.º 9
0
class PIDTurbidostat(DosingAutomation):
    """
    turbidostat mode - try to keep cell density constant using a PID target at the OD.

    The PID tells use what fraction of volume we should limit. For example, of PID
    returns 0.03, then we should remove ~97% of the volume. Choose volume to be about 1.5ml - 2.0ml.
    """

    def __init__(self, target_od=None, volume=None, **kwargs):
        super(PIDTurbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"
        assert volume is not None, "`volume` must be set"
        self.set_target_od(target_od)
        self.volume = float(volume)

        # PID%20controller%20turbidostat.ipynb
        Kp = config.getfloat("pid_turbidostat", "Kp")
        Ki = config.getfloat("pid_turbidostat", "Ki")
        Kd = config.getfloat("pid_turbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_od,
            output_limits=(0, 1),
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
        )

    def execute(self, *args, **kwargs) -> events.Event:
        if self.latest_od <= self.min_od:
            return events.NoEvent(
                f"current OD, {self.latest_od:.2f}, less than OD to start diluting, {self.min_od:.2f}"
            )
        else:
            output = self.pid.update(self.latest_od, dt=self.duration)

            volume_to_cycle = output * self.volume

            if volume_to_cycle < 0.01:
                return events.NoEvent(
                    f"PID output={output:.2f}, so practically no volume to cycle"
                )
            else:
                self.execute_io_action(media_ml=volume_to_cycle, waste_ml=volume_to_cycle)
                e = events.DilutionEvent(
                    f"PID output={output:.2f}, volume to cycle={volume_to_cycle:.2f}mL"
                )
                e.volume_to_cycle = volume_to_cycle
                e.pid_output = output
                return e

    @property
    def min_od(self):
        return 0.75 * self.target_od

    def set_target_od(self, value):
        self.target_od = float(value)
        try:
            # may not be defined yet...
            self.pid.set_setpoint(self.target_od)
        except AttributeError:
            pass
Exemplo n.º 10
0
class PIDMorbidostat(DosingAutomation):
    """
    As defined in Zhong 2020
    """

    automation_name = "pid_morbidostat"
    published_settings = {
        "volume": {
            "datatype": "float",
            "settable": True,
            "unit": "mL"
        },
        "target_od": {
            "datatype": "float",
            "settable": True,
            "unit": "AU"
        },
        "target_growth_rate": {
            "datatype": "float",
            "settable": True,
            "unit": "h⁻¹"
        },
        "duration": {
            "datatype": "float",
            "settable": True,
            "unit": "min"
        },
    }

    def __init__(self,
                 target_growth_rate=None,
                 target_od=None,
                 volume=None,
                 **kwargs):
        super(PIDMorbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"
        assert target_growth_rate is not None, "`target_growth_rate` must be set"

        with local_persistant_storage("pump_calibration") as cache:
            if "media_ml_calibration" not in cache:
                raise RuntimeError(
                    "Media pump calibration must be performed first.")
            elif "waste_ml_calibration" not in cache:
                raise RuntimeError(
                    "Waste pump calibration must be performed first.")
            elif "alt_media_ml_calibration" not in cache:
                raise RuntimeError(
                    "Alt-Media pump calibration must be performed first.")

        self.set_target_growth_rate(target_growth_rate)
        self.target_od = float(target_od)

        Kp = config.getfloat("dosing_automation.pid_morbidostat", "Kp")
        Ki = config.getfloat("dosing_automation.pid_morbidostat", "Ki")
        Kd = config.getfloat("dosing_automation.pid_morbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_growth_rate,
            output_limits=(0, 1),
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="growth_rate",
        )

        if volume is not None:
            self.logger.info(
                "Ignoring volume parameter; volume set by target growth rate and duration."
            )

        assert isinstance(self.duration, float)
        self.volume = round(
            self.target_growth_rate * VIAL_VOLUME * (self.duration / 60), 4)

    def execute(self) -> events.Event:
        if self.latest_od <= self.min_od:
            return events.NoEvent(
                f"latest OD less than OD to start diluting, {self.min_od:.2f}")
        else:
            assert isinstance(self.duration, float)
            fraction_of_alt_media_to_add = self.pid.update(
                self.latest_growth_rate, dt=self.duration / 60
            )  # duration is measured in hours, not seconds (as simple_pid would want)

            # dilute more if our OD keeps creeping up - we want to stay in the linear range.
            if self.latest_od > self.max_od:
                self.logger.info(
                    f"executing triple dilution since we are above max OD, {self.max_od:.2f}AU."
                )
                volume = 2.5 * self.volume
            else:
                volume = self.volume

            alt_media_ml = fraction_of_alt_media_to_add * volume
            media_ml = (1 - fraction_of_alt_media_to_add) * volume

            self.execute_io_action(alt_media_ml=alt_media_ml,
                                   media_ml=media_ml,
                                   waste_ml=volume)
            return events.AddAltMediaEvent(
                f"PID output={fraction_of_alt_media_to_add:.2f}, alt_media_ml={alt_media_ml:.2f}mL, media_ml={media_ml:.2f}mL"
            )

    @property
    def min_od(self):
        return 0.7 * self.target_od

    @property
    def max_od(self):
        return 1.25 * self.target_od

    def set_target_growth_rate(self, value):
        self.target_growth_rate = float(value)
        with suppress(AttributeError):
            self.pid.set_setpoint(self.target_growth_rate)
Exemplo n.º 11
0
class Stirrer(BackgroundJob):
    """
    Parameters
    ------------

    target_rpm: float
        Send message to "pioreactor/{unit}/{experiment}/stirring/target_rpm/set" to change the stirring speed.
    rpm_calculator: RpmCalculator
        See RpmCalculator and examples below.

    Notes
    -------

    The create a feedback loop between the duty-cycle level and the RPM, we set up a polling algorithm. We set up
    an edge detector on the hall sensor pin, and count the number of pulses in N seconds. We convert this count to RPM, and
    then use a PID system to update the amount of duty cycle to apply.

    We perform the above every N seconds. That is, there is PID controller that checks every N seconds and nudges the duty cycle
    to match the requested RPM.


    Examples
    ---------

    > st = Stirrer(500, unit, experiment)
    > st.start_stirring()
    """

    published_settings = {
        "target_rpm": {
            "datatype": "json",
            "settable": True,
            "unit": "RPM"
        },
        "measured_rpm": {
            "datatype": "json",
            "settable": False,
            "unit": "RPM"
        },
        "duty_cycle": {
            "datatype": "float",
            "settable": True,
            "unit": "%"
        },
    }
    _previous_duty_cycle: float = 0
    duty_cycle: float = config.getint(
        "stirring", "initial_duty_cycle",
        fallback=60.0)  # only used if calibration isn't defined.
    _measured_rpm: Optional[float] = None

    def __init__(
        self,
        target_rpm: float,
        unit: str,
        experiment: str,
        rpm_calculator: Optional[RpmCalculator],
        hertz: float = 150,
    ) -> None:
        super(Stirrer, self).__init__(job_name="stirring",
                                      unit=unit,
                                      experiment=experiment)
        self.logger.debug(f"Starting stirring with initial {target_rpm} RPM.")
        self.rpm_calculator = rpm_calculator

        if not hardware.is_HAT_present():
            self.logger.error("Pioreactor HAT must be present.")
            self.set_state(self.DISCONNECTED)
            raise exc.HardwareNotFoundError("Pioreactor HAT must be present.")

        if (self.rpm_calculator
                is not None) and not hardware.is_heating_pcb_present():
            self.logger.error("Heating PCB must be present to measure RPM.")
            self.set_state(self.DISCONNECTED)
            raise exc.HardwareNotFoundError(
                "Heating PCB must be present to measure RPM.")

        pin = hardware.PWM_TO_PIN[config.get("PWM_reverse", "stirring")]
        self.pwm = PWM(pin, hertz)
        self.pwm.lock()

        self.rpm_to_dc_lookup = self.initialize_rpm_to_dc_lookup()
        self.target_rpm = target_rpm
        self.duty_cycle = self.rpm_to_dc_lookup(self.target_rpm)

        # set up PID
        self.pid = PID(
            Kp=config.getfloat("stirring.pid", "Kp"),
            Ki=config.getfloat("stirring.pid", "Ki"),
            Kd=config.getfloat("stirring.pid", "Kd"),
            setpoint=self.target_rpm,
            unit=self.unit,
            experiment=self.experiment,
            job_name=self.job_name,
            target_name="rpm",
            output_limits=(-20, 20),  # avoid whiplashing
        )

        # set up thread to periodically check the rpm
        self.rpm_check_repeated_thread = RepeatedTimer(
            17,  # 17 and 5 are coprime
            self.poll_and_update_dc,
            job_name=self.job_name,
            run_immediately=True,
            run_after=5,
            poll_for_seconds=
            4,  # technically should be a function of the RPM: lower RPM, longer to get sufficient data.
        )

    def initialize_rpm_to_dc_lookup(self) -> Callable:
        if self.rpm_calculator is None:
            # if we can't track RPM, no point in adjusting DC
            return lambda rpm: self.duty_cycle

        with local_persistant_storage("stirring_calibration") as cache:

            if "linear_v1" in cache:
                parameters = json.loads(cache["linear_v1"])
                coef = parameters["rpm_coef"]
                intercept = parameters["intercept"]
                # we scale this by 90% to make sure the PID + prediction doesn't overshoot,
                # better to be conservative here.
                # equivalent to a weighted average: 0.1 * current + 0.9 * predicted
                return lambda rpm: self.duty_cycle - 0.90 * (
                    self.duty_cycle - (coef * rpm + intercept))
            else:
                return lambda rpm: self.duty_cycle

    def on_disconnected(self) -> None:

        with suppress(AttributeError):
            self.rpm_check_repeated_thread.cancel()

        with suppress(AttributeError):
            self.stop_stirring()
            self.pwm.cleanup()

        with suppress(AttributeError):
            if self.rpm_calculator:
                self.rpm_calculator.cleanup()

    def start_stirring(self) -> None:
        self.pwm.start(100)  # get momentum to start
        sleep(0.25)
        self.set_duty_cycle(self.duty_cycle)
        sleep(0.75)
        self.rpm_check_repeated_thread.start()  # .start is idempotent

    def poll(self, poll_for_seconds: float) -> Optional[float]:
        """
        Returns an RPM, or None if not measuring RPM.
        """
        if self.rpm_calculator is None:
            return None

        recent_rpm = self.rpm_calculator(poll_for_seconds)
        if recent_rpm == 0:
            # TODO: attempt to restart stirring
            self.publish(
                f"pioreactor/{self.unit}/{self.experiment}/monitor/flicker_led_with_error_code",
                error_codes.STIRRING_FAILED_ERROR_CODE,
            )
            self.logger.warning("Stirring RPM is 0 - has it failed?")

        if self._measured_rpm is not None:
            # use a simple EMA, alpha chosen arbitrarily, but should be a function of delta time.
            self._measured_rpm = 0.025 * self._measured_rpm + 0.975 * recent_rpm
        else:
            self._measured_rpm = recent_rpm

        self.measured_rpm = {
            "timestamp": current_utc_time(),
            "rpm": self._measured_rpm
        }
        return self._measured_rpm

    def poll_and_update_dc(self, poll_for_seconds: float) -> None:
        self.poll(poll_for_seconds)

        if self._measured_rpm is None:
            return

        result = self.pid.update(self._measured_rpm, dt=1)
        self.set_duty_cycle(self.duty_cycle + result)

    def stop_stirring(self) -> None:
        # if the user unpauses, we want to go back to their previous value, and not the default.
        self.set_duty_cycle(0)

    def on_ready_to_sleeping(self) -> None:
        self.rpm_check_repeated_thread.pause()
        self.stop_stirring()

    def on_sleeping_to_ready(self) -> None:
        self.duty_cycle = self._previous_duty_cycle
        self.rpm_check_repeated_thread.unpause()
        self.start_stirring()

    def set_duty_cycle(self, value: float) -> None:
        self._previous_duty_cycle = self.duty_cycle
        self.duty_cycle = clamp(0, round(float(value), 5), 100)
        self.pwm.change_duty_cycle(self.duty_cycle)

    def set_target_rpm(self, value: float) -> None:
        self.target_rpm = float(value)
        self.set_duty_cycle(self.rpm_to_dc_lookup(self.target_rpm))
        self.pid.set_setpoint(self.target_rpm)

    def block_until_rpm_is_close_to_target(self,
                                           abs_tolerance: float = 15) -> None:
        """
        This function blocks until the stirring is "close enough" to the target RPM.

        """
        if self.rpm_calculator is None:
            # can't block if we aren't recording the RPM
            return

        while (self._measured_rpm is not None
               ) and abs(self._measured_rpm - self.target_rpm) > abs_tolerance:
            sleep(0.25)
Exemplo n.º 12
0
class PIDMorbidostat(DosingAutomation):
    """
    As defined in Zhong 2020
    """
    def __init__(self,
                 target_growth_rate=None,
                 target_od=None,
                 volume=None,
                 **kwargs):
        super(PIDMorbidostat, self).__init__(**kwargs)
        assert target_od is not None, "`target_od` must be set"
        assert target_growth_rate is not None, "`target_growth_rate` must be set"

        self.set_target_growth_rate(target_growth_rate)
        self.target_od = float(target_od)

        Kp = config.getfloat("pid_morbidostat", "Kp")
        Ki = config.getfloat("pid_morbidostat", "Ki")
        Kd = config.getfloat("pid_morbidostat", "Kd")

        self.pid = PID(
            -Kp,
            -Ki,
            -Kd,
            setpoint=self.target_growth_rate,
            output_limits=(0, 1),
            sample_time=None,
            unit=self.unit,
            experiment=self.experiment,
        )

        if volume is not None:
            self.logger.info(
                "Ignoring volume parameter; volume set by target growth rate and duration."
            )

        self.volume = round(
            self.target_growth_rate * VIAL_VOLUME * (self.duration / 60), 4)

    def execute(self, *args, **kwargs) -> events.Event:
        if self.latest_od <= self.min_od:
            return events.NoEvent(
                f"latest OD less than OD to start diluting, {self.min_od:.2f}")
        else:
            fraction_of_alt_media_to_add = self.pid.update(
                self.latest_growth_rate, dt=self.duration / 60
            )  # duration is measured in hours, not seconds (as simple_pid would want)

            # dilute more if our OD keeps creeping up - we want to stay in the linear range.
            if self.latest_od > self.max_od:
                self.logger.info(
                    f"executing triple dilution since we are above max OD, {self.max_od:.2f}."
                )
                volume = 2.5 * self.volume
            else:
                volume = self.volume

            alt_media_ml = fraction_of_alt_media_to_add * volume
            media_ml = (1 - fraction_of_alt_media_to_add) * volume

            self.execute_io_action(alt_media_ml=alt_media_ml,
                                   media_ml=media_ml,
                                   waste_ml=volume)
            event = events.AltMediaEvent(
                f"PID output={fraction_of_alt_media_to_add:.2f}, alt_media_ml={alt_media_ml:.2f}mL, media_ml={media_ml:.2f}mL"
            )
            event.media_ml = media_ml  # can be used for testing later
            event.alt_media_ml = alt_media_ml
            return event

    @property
    def min_od(self):
        return 0.7 * self.target_od

    @property
    def max_od(self):
        return 1.25 * self.target_od

    def set_target_growth_rate(self, value):
        self.target_growth_rate = float(value)
        try:
            self.pid.set_setpoint(self.target_growth_rate)
        except AttributeError:
            pass