def test_od_blank_being_non_zero(self) -> None:
        unit = get_unit_name()
        experiment = get_latest_experiment_name()
        with local_persistant_storage("od_blank") as cache:
            cache[experiment] = json.dumps({"1": 0.25, "2": 0.4})

        with local_persistant_storage("od_normalization_mean") as cache:
            cache[experiment] = json.dumps({"1": 0.5, "2": 0.8})

        with local_persistant_storage("od_normalization_variance") as cache:
            cache[experiment] = json.dumps({"1": 1e-6, "2": 1e-4})

        calc = GrowthRateCalculator(unit=unit, experiment=experiment)

        pause()
        pause()

        publish(
            f"pioreactor/{unit}/{experiment}/od_reading/od_raw_batched",
            create_od_raw_batched_json(
                ["1", "2"], [0.50, 0.80], ["90", "135"], timestamp="2010-01-01 12:02:00"
            ),
            retain=True,
        )

        pause()
        pause()

        assert calc.od_normalization_factors == {"2": 0.8, "1": 0.5}
        assert calc.od_blank == {"2": 0.4, "1": 0.25}
        results = calc.scale_raw_observations({"2": 1.0, "1": 0.6})
        print(results)
        assert abs(results["2"] - 1.5) < 0.00001
        assert abs(results["1"] - 1.4) < 0.00001
        calc.set_state(calc.DISCONNECTED)
    def test_single_observation(self) -> None:
        unit = get_unit_name()
        experiment = get_latest_experiment_name()

        with local_persistant_storage("od_normalization_mean") as cache:
            cache[experiment] = json.dumps({1: 1})

        with local_persistant_storage("od_normalization_variance") as cache:
            cache[experiment] = json.dumps({1: 1})

        publish(
            f"pioreactor/{unit}/{experiment}/od_reading/od_raw_batched",
            create_od_raw_batched_json(
                ["1"], [1.153], ["90"], timestamp="2010-01-01 12:00:30"
            ),
            retain=True,
        )

        calc = GrowthRateCalculator(unit=unit, experiment=experiment)

        publish(
            f"pioreactor/{unit}/{experiment}/od_reading/od_raw_batched",
            create_od_raw_batched_json(
                ["1"], [1.155], ["90"], timestamp="2010-01-01 12:00:35"
            ),
        )
        pause()

        assert True
        calc.set_state(calc.DISCONNECTED)
def test_changing_algo_over_mqtt_with_wrong_type_is_okay() -> None:
    with local_persistant_storage("media_throughput") as c:
        c[experiment] = "0.0"

    algo = DosingController(
        "pid_turbidostat",
        volume=1.0,
        target_od=0.4,
        duration=2 / 60,
        unit=unit,
        experiment=experiment,
    )
    assert algo.automation["automation_name"] == "pid_turbidostat"
    assert algo.automation_name == "pid_turbidostat"
    pause()
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/dosing_control/automation/set",
        '{"automation_name": "pid_turbidostat", "duration": "60", "target_od": "1.0", "volume": "1.0"}',
    )
    time.sleep(
        7
    )  # need to wait for all jobs to disconnect correctly and threads to join.
    assert isinstance(algo.automation_job, PIDTurbidostat)
    assert algo.automation_job.target_od == 1.0
    algo.set_state(algo.DISCONNECTED)
def test_single_observation():
    publish(
        f"pioreactor/{unit}/{experiment}/od_normalization/median",
        '{"135/0": 1}',
        retain=True,
    )
    publish(
        f"pioreactor/{unit}/{experiment}/od_normalization/variance",
        '{"135/0": 1}',
        retain=True,
    )

    publish(f"pioreactor/{unit}/{experiment}/growth_rate", None, retain=True)
    publish(
        f"pioreactor/{unit}/{experiment}/od_raw_batched",
        '{"135/0": 0.20944389172032837}',
        retain=True,
    )

    GrowthRateCalculator(unit=unit, experiment=experiment)

    publish(f"pioreactor/{unit}/{experiment}/od_raw_batched",
            '{"135/0": 0.20944389172032837}')
    pause()

    assert True
Example #5
0
def test_pump_will_disconnect_via_mqtt() -> None:
    class ThreadWithReturnValue(threading.Thread):
        def __init__(self, *init_args, **init_kwargs):
            threading.Thread.__init__(self, *init_args, **init_kwargs)
            self._return = None

        def run(self):
            self._return = self._target(*self._args, **self._kwargs)

        def join(self):
            threading.Thread.join(self)
            return self._return

    expected_ml = 20
    t = ThreadWithReturnValue(target=add_media,
                              args=(unit, exp, expected_ml),
                              daemon=True)
    t.start()

    pause()
    pause()
    publish(f"pioreactor/{unit}/{exp}/add_media/$state/set", "disconnected")
    pause()
    pause()

    pause()

    resulting_ml = t.join()

    assert resulting_ml < expected_ml
def test_skip_180():
    publish(
        f"pioreactor/{unit}/{experiment}/od_normalization/median",
        '{"135/0": 1, "180/2": 1, "90/1": 1}',
        retain=True,
    )
    publish(
        f"pioreactor/{unit}/{experiment}/od_normalization/variance",
        '{"135/0": 1, "180/2": 1, "90/1": 1}',
        retain=True,
    )

    publish(f"pioreactor/{unit}/{experiment}/growth_rate", None, retain=True)
    publish(
        f"pioreactor/{unit}/{experiment}/od_raw_batched",
        '{"180/2": 0.778586260567034, "135/0": 0.20944389172032837, "90/1": 0.1}',
        retain=True,
    )

    calc = GrowthRateCalculator(unit=unit, experiment=experiment)

    publish(
        f"pioreactor/{unit}/{experiment}/od_raw_batched",
        '{"180/2": 0.778586260567034, "135/0": 0.20944389172032837, "90/1": 0.1}',
    )
    pause()

    assert "180/2" not in calc.angles
def test_mis_shapen_data(monkeypatch):

    publish(
        f"pioreactor/{unit}/{experiment}/od_normalization/median",
        '{"135/0": 1,  "90/0": 1}',
        retain=True,
    )
    publish(
        f"pioreactor/{unit}/{experiment}/od_normalization/variance",
        '{"135/0": 1, "90/0": 1}',
        retain=True,
    )

    publish(
        f"pioreactor/{unit}/{experiment}/od_raw_batched",
        '{"135/0": 0.778586260567034, "90/0": 0.1}',
        retain=True,
    )

    GrowthRateCalculator(unit=unit, experiment=experiment)

    publish(
        f"pioreactor/{unit}/{experiment}/od_raw_batched",
        '{"135/0": 0.778586260567034, "90/0": 0.1}',
    )
    pause()

    publish(f"pioreactor/{unit}/{experiment}/od_raw_batched",
            '{"135/0": 0.808586260567034}')
    pause()
def test_changing_algo_over_mqtt_with_wrong_type_is_okay():
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/throughput_calculating/media_throughput",
        None,
        retain=True,
    )

    algo = DosingController(
        "pid_turbidostat",
        volume=1.0,
        target_od=0.4,
        duration=2 / 60,
        unit=unit,
        experiment=experiment,
    )
    assert algo.dosing_automation == "pid_turbidostat"
    pause()
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/dosing_control/dosing_automation/set",
        '{"dosing_automation": "pid_turbidostat", "duration": "60", "target_od": "1.0", "volume": "1.0"}',
    )
    time.sleep(
        7
    )  # need to wait for all jobs to disconnect correctly and threads to join.
    assert isinstance(algo.dosing_automation_job, PIDTurbidostat)
    assert algo.dosing_automation_job.target_od == 1.0
def test_drops_retained_messages():

    publish(f"pioreactor/{unit}/exp1/growth_rate", None, retain=True)
    publish(f"pioreactor/{unit}/exp2/growth_rate", None, retain=True)

    def unit_from_topic(topic):
        return topic.split("/")[1]

    publish(f"pioreactor/{unit}/exp2/growth_rate", 0.9, retain=True)

    # NOTE: see the +, this is what is used in production
    ts = TimeSeriesAggregation(
        "pioreactor/+/+/growth_rate",
        output_dir="./",
        experiment=experiment,
        unit=leader,
        ignore_cache=True,
        extract_label=unit_from_topic,
        record_every_n_seconds=0.1,
    )
    pause()
    pause()
    pause()
    publish(f"pioreactor/{unit}/exp2/growth_rate", 1.0, retain=True)
    pause()
    publish(f"pioreactor/{unit}/exp2/growth_rate", 1.1, retain=True)
    pause()
    assert [_["y"] for _ in ts.aggregated_time_series["data"][0]] == [1.0, 1.1]
def test_subscribe_and_listen_to_clear_different_formatter():
    def single_sensor_label_from_topic(topic):
        split_topic = topic.split("/")
        return f"{split_topic[1]}-{split_topic[-1]}"

    ts = TimeSeriesAggregation(
        f"pioreactor/+/{experiment}/od_raw/135/+",
        output_dir="./",
        experiment=experiment,
        unit=leader,
        ignore_cache=True,
        record_every_n_seconds=0.1,
        extract_label=single_sensor_label_from_topic,
    )

    publish(f"pioreactor/{unit}1/{experiment}/od_raw/135/0", 1.0)
    publish(f"pioreactor/{unit}1/{experiment}/od_raw/135/0", 1.1)
    publish(f"pioreactor/{unit}1/{experiment}/od_raw/135/1", 1.0)
    publish(f"pioreactor/{unit}2/{experiment}/od_raw/135/0", 1.0)
    pause()
    assert ts.aggregated_time_series["series"] == [
        "testing_unit1-A",
        "testing_unit1-B",
        "testing_unit2-A",
    ]

    publish(
        f"pioreactor/{leader}/{experiment}/time_series_aggregating/aggregated_time_series/set",
        None,
    )
    pause()
    assert ts.aggregated_time_series["series"] == []
def test_duty_cycle_is_published_and_not_settable() -> None:

    dc_msgs = []

    def collect(msg) -> None:
        dc_msgs.append(msg.payload)

    pubsub.subscribe_and_callback(
        collect,
        f"pioreactor/{get_unit_name()}/{get_latest_experiment_name()}/temperature_control/heater_duty_cycle",
    )

    with temperature_control.TemperatureController("silent",
                                                   unit=unit,
                                                   experiment=experiment):
        # change to PID stable

        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/temperature_control/automation/set",
            '{"automation_name": "stable", "target_temperature": 35}',
        )

        pause(3)

        # should produce an "Unable to set heater_duty_cycle"
        pubsub.publish(
            f"pioreactor/{get_unit_name()}/{get_latest_experiment_name()}/temperature_control/heater_duty_cycle/set",
            10,
        )

        pause(1)

    assert len(dc_msgs) > 0
Example #12
0
def test_stirring_with_lookup_linear_v1() -> None:
    class FakeRpmCalculator:
        def __call__(self, *args):
            return 475

        def cleanup(self):
            pass

    with local_persistant_storage("stirring_calibration") as cache:
        cache["linear_v1"] = json.dumps({"rpm_coef": 0.1, "intercept": 20})

    target_rpm = 500
    current_dc = Stirrer.duty_cycle
    with Stirrer(target_rpm, unit, exp,
                 rpm_calculator=FakeRpmCalculator()) as st:  # type: ignore
        st.start_stirring()

        assert st.duty_cycle == current_dc - 0.9 * (current_dc -
                                                    (0.1 * target_rpm + 20))

        pause()
        pause()

        current_dc = st.duty_cycle
        target_rpm = 600
        publish(f"pioreactor/{unit}/{exp}/stirring/target_rpm/set", target_rpm)
        pause()
        pause()

        assert st.duty_cycle == current_dc - 0.9 * (current_dc -
                                                    (0.1 * target_rpm + 20))
def test_editing_readonly_attr_via_mqtt() -> None:
    class TestJob(BackgroundJob):

        published_settings = {
            "readonly_attr": {
                "datatype": "float",
                "settable": False,
            },
        }

    warning_logs = []

    def collect_logs(msg: MQTTMessage) -> None:
        if "readonly" in msg.payload.decode():
            warning_logs.append(msg)

    subscribe_and_callback(
        collect_logs,
        f"pioreactor/{get_unit_name()}/{get_latest_experiment_name()}/logs/app",
    )

    with TestJob(job_name="job",
                 unit=get_unit_name(),
                 experiment=get_latest_experiment_name()):
        publish(
            f"pioreactor/{get_unit_name()}/{get_latest_experiment_name()}/job/readonly_attr/set",
            1.0,
        )
        pause()

    assert len(warning_logs) > 0
def test_changing_parameters_over_mqtt_with_unknown_parameter() -> None:

    with DosingAutomation(
            target_growth_rate=0.05,
            target_od=1.0,
            duration=60,
            unit=unit,
            experiment=experiment,
    ):

        logs = []

        def append_logs(msg):
            if "garbage" in msg.payload.decode():
                logs.append(msg.payload)

        pubsub.subscribe_and_callback(
            append_logs, f"pioreactor/{unit}/{experiment}/logs/app")

        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/dosing_automation/garbage/set",
            0.07)
        # there should be a log published with "Unable to set garbage in dosing_automation"
        pause()

        assert len(logs) > 0
def test_watchdog_will_try_to_fix_lost_job() -> None:
    wd = WatchDog(leader_hostname, UNIVERSAL_EXPERIMENT)
    pause()

    # start a monitor job
    monitor = Monitor(leader_hostname, UNIVERSAL_EXPERIMENT)
    pause()
    pause()

    # suppose it disconnects from broker for long enough that the last will is sent
    publish(
        f"pioreactor/{leader_hostname}/{UNIVERSAL_EXPERIMENT}/monitor/$state",
        "lost")

    pause()
    pause()
    pause()
    pause()
    pause()
    pause()
    pause()
    assert monitor.sub_client._will

    wd.set_state(wd.DISCONNECTED)
    monitor.set_state(monitor.DISCONNECTED)
Example #16
0
def test_execute_io_action():
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/throughput_calculating/media_throughput",
        None,
        retain=True,
    )
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/throughput_calculating/alt_media_throughput",
        None,
        retain=True,
    )
    ca = DosingAutomation(unit=unit, experiment=experiment)
    ca.execute_io_action(media_ml=0.65,
                         alt_media_ml=0.35,
                         waste_ml=0.65 + 0.35)
    pause()
    assert ca.throughput_calculator.media_throughput == 0.65
    assert ca.throughput_calculator.alt_media_throughput == 0.35

    ca.execute_io_action(media_ml=0.15, alt_media_ml=0.15, waste_ml=0.3)
    pause()
    assert ca.throughput_calculator.media_throughput == 0.80
    assert ca.throughput_calculator.alt_media_throughput == 0.50

    ca.execute_io_action(media_ml=1.0, alt_media_ml=0, waste_ml=1)
    pause()
    assert ca.throughput_calculator.media_throughput == 1.80
    assert ca.throughput_calculator.alt_media_throughput == 0.50

    ca.execute_io_action(media_ml=0.0, alt_media_ml=1.0, waste_ml=1)
    pause()
    assert ca.throughput_calculator.media_throughput == 1.80
    assert ca.throughput_calculator.alt_media_throughput == 1.50
    ca.set_state("disconnected")
def test_throughput_calculator_manual_set() -> None:

    with local_persistant_storage("media_throughput") as c:
        c[experiment] = str(1.0)

    with local_persistant_storage("alt_media_throughput") as c:
        c[experiment] = str(1.5)

    with DosingController(
            "turbidostat",
            target_od=1.0,
            duration=5 / 60,
            volume=1.0,
            unit=unit,
            experiment=experiment,
    ) as algo:

        pause()
        assert algo.automation_job.media_throughput == 1.0
        assert algo.automation_job.alt_media_throughput == 1.5

        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/dosing_automation/alt_media_throughput/set",
            0,
        )
        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/dosing_automation/media_throughput/set",
            0)
        pause()
        pause()
        assert algo.automation_job.media_throughput == 0
        assert algo.automation_job.alt_media_throughput == 0
def test_what_happens_when_an_error_occurs_in_init_but_we_catch_and_disconnect(
) -> None:
    class TestJob(BackgroundJob):
        def __init__(self, unit: str, experiment: str) -> None:
            super(TestJob, self).__init__(job_name="testjob",
                                          unit=unit,
                                          experiment=experiment)
            try:
                1 / 0
            except Exception as e:
                self.logger.error("Error!")
                self.set_state("disconnected")
                raise e

    publish("pioreactor/unit/exp/testjob/$state", None, retain=True)
    state = []

    def update_state(msg: MQTTMessage) -> None:
        state.append(msg.payload.decode())

    subscribe_and_callback(update_state, "pioreactor/unit/exp/testjob/$state")

    with pytest.raises(ZeroDivisionError):
        with TestJob(unit="unit", experiment="exp"):
            pass

    pause()
    assert state[-1] == "disconnected"

    with local_intermittent_storage("pio_jobs_running") as cache:
        assert "testjob" not in cache
def test_error_in_subscribe_and_callback_is_logged() -> None:
    class TestJob(BackgroundJob):
        def __init__(self, *args, **kwargs) -> None:
            super(TestJob, self).__init__(*args, **kwargs)
            self.start_passive_listeners()

        def start_passive_listeners(self) -> None:
            self.subscribe_and_callback(self.callback,
                                        "pioreactor/testing/subscription")

        def callback(self, msg: MQTTMessage) -> None:
            print(1 / 0)

    error_logs = []

    def collect_error_logs(msg: MQTTMessage) -> None:
        if "ERROR" in msg.payload.decode():
            error_logs.append(msg)

    subscribe_and_callback(
        collect_error_logs,
        f"pioreactor/{get_unit_name()}/{get_latest_experiment_name()}/logs/app",
    )

    with TestJob(job_name="job",
                 unit=get_unit_name(),
                 experiment=get_latest_experiment_name()):
        publish("pioreactor/testing/subscription", "test")
        pause()
        pause()
        assert len(error_logs) > 0
        assert "division by zero" in error_logs[0].payload.decode()
def test_changing_duty_cycle_over_mqtt() -> None:
    with ContinuousCycle(unit=unit, experiment=experiment) as algo:

        assert algo.duty_cycle == 100
        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/dosing_automation/duty_cycle/set",
            50)
        pause()
        assert algo.duty_cycle == 50
Example #21
0
    def __exit__(self, *args) -> None:
        self.client.loop_stop()
        self.client.disconnect()

        publish(
            f"pioreactor/{self.unit}/{self.experiment}/{self.name}/$state",
            "disconnected",
            qos=QOS.AT_LEAST_ONCE,
            retain=True,
        )
        return
Example #22
0
def test_publish_duty_cycle():
    publish(f"pioreactor/{unit}/{exp}/stirring/duty_cycle", None, retain=True)
    pause()
    original_dc = 50

    st = Stirrer(original_dc, unit, exp)
    assert st.duty_cycle == original_dc

    pause()
    message = subscribe(f"pioreactor/{unit}/{exp}/stirring/duty_cycle")
    assert float(message.payload) == 50
Example #23
0
def test_silent():
    LEDController("silent", unit=unit, experiment=experiment)
    pause()
    pause()
    pubsub.publish(f"pioreactor/{unit}/{experiment}/growth_rate", "0.01")
    pubsub.publish(f"pioreactor/{unit}/{experiment}/od_filtered/135/0", "1.0")
    pause()
    r = pubsub.subscribe(
        f"pioreactor/{unit}/{experiment}/led_control/led_automation",
        timeout=1)
    assert r.payload.decode() == "silent"
Example #24
0
    def emit(self, record):
        msg = self.format(record)
        self.client.publish(self.topic, msg, qos=self.qos, retain=False)

        if config.getboolean("error_reporting",
                             "send_to_Pioreactor_com",
                             fallback=False):
            # turned off, by default
            if record.levelno == logging.ERROR:
                # TODO: build this service!
                publish(self.topic, msg, hostname="mqtt.pioreactor.com")
Example #25
0
def test_publish_target_rpm() -> None:
    publish(f"pioreactor/{unit}/{exp}/stirring/target_rpm", None, retain=True)
    pause()
    target_rpm = 500

    with Stirrer(target_rpm, unit, exp, rpm_calculator=RpmCalculator()) as st:
        assert st.target_rpm == target_rpm

        pause()
        message = subscribe(f"pioreactor/{unit}/{exp}/stirring/target_rpm")
        assert message is not None
        assert float(message.payload) == 500
def test_state_transition_callbacks() -> None:
    class TestJob(BackgroundJob):
        called_on_init = False
        called_on_ready = False
        called_on_sleeping = False
        called_on_ready_to_sleeping = False
        called_on_sleeping_to_ready = False
        called_on_init_to_ready = False

        def __init__(self, unit: str, experiment: str) -> None:
            super(TestJob, self).__init__(job_name="testjob",
                                          unit=unit,
                                          experiment=experiment)

        def on_init(self) -> None:
            self.called_on_init = True

        def on_ready(self) -> None:
            self.called_on_ready = True

        def on_sleeping(self) -> None:
            self.called_on_sleeping = True

        def on_ready_to_sleeping(self) -> None:
            self.called_on_ready_to_sleeping = True

        def on_sleeping_to_ready(self) -> None:
            self.called_on_sleeping_to_ready = True

        def on_init_to_ready(self) -> None:
            self.called_on_init_to_ready = True

    unit, exp = get_unit_name(), get_latest_experiment_name()
    with TestJob(unit, exp) as tj:
        assert tj.called_on_init
        assert tj.called_on_init_to_ready
        assert tj.called_on_ready
        publish(f"pioreactor/{unit}/{exp}/{tj.job_name}/$state/set",
                tj.SLEEPING)
        pause()
        pause()
        pause()
        pause()
        assert tj.called_on_ready_to_sleeping
        assert tj.called_on_sleeping

        publish(f"pioreactor/{unit}/{exp}/{tj.job_name}/$state/set", tj.READY)
        pause()
        pause()
        pause()
        pause()
        assert tj.called_on_sleeping_to_ready
Example #27
0
def test_changing_parameters_over_mqtt_with_unknown_parameter():

    algo = DosingAutomation(
        target_growth_rate=0.05,
        target_od=1.0,
        duration=60,
        unit=unit,
        experiment=experiment,
    )
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/dosing_automation/garbage/set", 0.07)
    pause()
    algo.set_state("disconnected")
            def __call__(self):
                self.od_reading *= np.exp(self.growth_rate / 60 / 60 / samples_per_second)

                voltage = 0.1 * self.od_reading
                payload = {
                    "od_raw": {"1": {"voltage": voltage, "angle": "90"}},
                    "timestamp": "2021-06-06T15:08:12.081153",
                }

                publish(
                    f"pioreactor/{unit}/{experiment}/od_reading/od_raw_batched",
                    json.dumps(payload),
                )
def test_duration_and_timer() -> None:
    algo = PIDMorbidostat(
        target_od=1.0,
        target_growth_rate=0.01,
        duration=5 / 60,
        unit=unit,
        experiment=experiment,
    )
    assert algo.latest_event is None
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/growth_rate_calculating/growth_rate",
        '{"growth_rate": 0.08}',
    )
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/growth_rate_calculating/od_filtered",
        '{"od_filtered": 0.5}',
    )
    time.sleep(10)
    pause()
    assert isinstance(algo.latest_event, events.NoEvent)

    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/growth_rate_calculating/growth_rate",
        '{"growth_rate": 0.08}',
    )
    pubsub.publish(
        f"pioreactor/{unit}/{experiment}/growth_rate_calculating/od_filtered",
        '{"od_filtered": 0.95}',
    )
    time.sleep(10)
    pause()
    assert isinstance(algo.latest_event, events.AddAltMediaEvent)
    algo.set_state(algo.DISCONNECTED)
def test_silent_automation() -> None:
    with Silent(volume=None, duration=60, unit=unit,
                experiment=experiment) as algo:
        pause()
        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/growth_rate_calculating/growth_rate",
            '{"growth_rate": 0.01}',
        )
        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/growth_rate_calculating/od_filtered",
            '{"od_filtered": 1.0}',
        )
        pause()
        assert algo.run() is None

        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/growth_rate_calculating/growth_rate",
            '{"growth_rate": 0.02}',
        )
        pubsub.publish(
            f"pioreactor/{unit}/{experiment}/growth_rate_calculating/od_filtered",
            '{"od_filtered": 1.1}',
        )
        pause()
        assert algo.run() is None