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)
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)