Esempio n. 1
0
class ODReader(BackgroundJob):
    """
    Produce a stream of OD readings from the sensors.

    Parameters
    -----------

    channel_angle_map: dict
        dict of (channel: angle) pairs, ex: {1: "135", 2: "90"}
    interval: float
        seconds between readings
    adc_reader: ADCReader
    ir_led_reference_tracker: IrLedReferenceTracker

    Attributes
    ------------

    adc_reader: ADCReader
    latest_reading: dict
        represents the most recent dict from the adc_reader

    """

    published_settings = {
        "first_od_obs_time": {"datatype": "float", "settable": False},
        "led_intensity": {"datatype": "float", "settable": True, "unit": "%"},
        "interval": {"datatype": "float", "settable": False, "unit": "s"},
    }
    latest_reading: dict[pt.PdChannel, float]

    def __init__(
        self,
        channel_angle_map: dict[pt.PdChannel, pt.PdAngle],
        interval: float,
        adc_reader: ADCReader,
        ir_led_reference_tracker: IrLedReferenceTracker,
        unit: str,
        experiment: str,
    ) -> None:
        super(ODReader, self).__init__(
            job_name="od_reading", unit=unit, experiment=experiment
        )

        self.adc_reader = adc_reader
        self.channel_angle_map = channel_angle_map
        self.interval = interval
        self.ir_led_reference_tracker = ir_led_reference_tracker

        self.first_od_obs_time: Optional[float] = None

        self.ir_channel: pt.LedChannel = self.get_ir_channel_from_configuration()
        self.ir_led_intensity: float = config.getfloat("od_config", "ir_intensity")
        self.non_ir_led_channels: list[pt.LedChannel] = [
            ch for ch in ALL_LED_CHANNELS if ch != self.ir_channel
        ]

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

        self.logger.debug(
            f"Starting od_reading with PD channels {channel_angle_map}, with IR LED intensity {self.ir_led_intensity}% from channel {self.ir_channel}."
        )

        # setup the ADC and IrLedReference by turning off all LEDs.
        with change_leds_intensities_temporarily(
            ALL_LED_CHANNELS,
            [0.0, 0.0, 0.0, 0.0],
            unit=self.unit,
            experiment=self.experiment,
            source_of_event=self.job_name,
            pubsub_client=self.pub_client,
            verbose=False,
        ):
            with lock_leds_temporarily(self.non_ir_led_channels):

                # start IR led before ADC starts, as it needs it.
                self.start_ir_led()
                self.adc_reader.setup_adc()  # determine best gain, max-signal, etc.
                self.stop_ir_led()

                # get blank values of reference PD.
                # This slightly improves the accuracy of the IR LED output tracker,
                # See that class's docs.
                self.ir_led_reference_tracker.set_blank(self.adc_reader.take_reading())

        self.record_from_adc_timer = RepeatedTimer(
            self.interval,
            self.record_and_publish_from_adc,
            run_immediately=True,
        ).start()

    def get_ir_channel_from_configuration(self) -> pt.LedChannel:
        try:
            return cast(pt.LedChannel, config.get("leds_reverse", IR_keyword))
        except Exception:
            self.logger.error(
                """`leds` section must contain `IR` value. Ex:
        [leds]
        A=IR
            """
            )
            raise KeyError("`IR` value not found in section.")

    def record_and_publish_from_adc(self) -> None:

        if self.first_od_obs_time is None:
            self.first_od_obs_time = time()

        pre_duration = 0.01  # turn on LED prior to taking snapshot and wait

        # we put a soft lock on the LED channels - it's up to the
        # other jobs to make sure they check the locks.
        with change_leds_intensities_temporarily(
            ALL_LED_CHANNELS,
            [0.0, 0.0, 0.0, 0.0],
            unit=self.unit,
            experiment=self.experiment,
            source_of_event=self.job_name,
            pubsub_client=self.pub_client,
            verbose=False,
        ):
            with lock_leds_temporarily(self.non_ir_led_channels):

                self.start_ir_led()
                sleep(pre_duration)

                timestamp_of_readings = current_utc_time()
                batched_readings = self.adc_reader.take_reading()

        self.latest_reading = batched_readings
        self.ir_led_reference_tracker.update(batched_readings)

        self.publish_single(batched_readings, timestamp_of_readings)
        self.publish_batch(batched_readings, timestamp_of_readings)

    def start_ir_led(self) -> None:
        r = change_led_intensity(
            channels=self.ir_channel,
            intensities=self.ir_led_intensity,
            unit=self.unit,
            experiment=self.experiment,
            source_of_event=self.job_name,
            pubsub_client=self.pub_client,
            verbose=False,
        )
        if not r:
            raise OSError("IR LED could not be started. Stopping OD reading.")

        return

    def stop_ir_led(self) -> None:
        change_led_intensity(
            channels=self.ir_channel,
            intensities=0.0,
            unit=self.unit,
            experiment=self.experiment,
            source_of_event=self.job_name,
            pubsub_client=self.pub_client,
            verbose=False,
        )

    def on_sleeping(self) -> None:
        self.record_from_adc_timer.pause()

    def on_sleeping_to_ready(self) -> None:
        self.record_from_adc_timer.unpause()

    def on_disconnected(self) -> None:

        # turn off the LED after we have take our last ADC reading..
        try:
            self.record_from_adc_timer.cancel()
        except Exception:
            pass

    def publish_batch(
        self, batched_ads_readings: dict[pt.PdChannel, float], timestamp: str
    ) -> None:
        if self.state != self.READY:
            return

        output = {
            "od_raw": dict(),
            "timestamp": timestamp,
        }

        for channel, angle in self.channel_angle_map.items():
            output["od_raw"][channel] = {  # type: ignore
                "voltage": self.normalize_by_led_output(batched_ads_readings[channel]),
                "angle": angle,
            }

        self.publish(
            f"pioreactor/{self.unit}/{self.experiment}/{self.job_name}/od_raw_batched",
            output,
            qos=QOS.EXACTLY_ONCE,
        )

    def publish_single(
        self, batched_ads_readings: dict[pt.PdChannel, float], timestamp: str
    ) -> None:
        if self.state != self.READY:
            return

        for channel, angle in self.channel_angle_map.items():

            payload = {
                "voltage": self.normalize_by_led_output(batched_ads_readings[channel]),
                "angle": angle,
                "timestamp": timestamp,
            }

            self.publish(
                f"pioreactor/{self.unit}/{self.experiment}/{self.job_name}/od_raw/{channel}",
                payload,
                qos=QOS.EXACTLY_ONCE,
            )

    def normalize_by_led_output(self, od_signal: float) -> float:
        return self.ir_led_reference_tracker(od_signal)
Esempio n. 2
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)