Skip to content

Commit

Permalink
duration is added in the super classes afterwards
Browse files Browse the repository at this point in the history
  • Loading branch information
CamDavidsonPilon committed Nov 30, 2024
1 parent dfb4a58 commit 4eb6b2b
Show file tree
Hide file tree
Showing 14 changed files with 103 additions and 69 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
- new export dataset API. The datasets on the Export Data UI page are now provided via YAML files on the leader's disk. This makes it easy to add new datasets to that UI to be exported. These YAML files can be added to `~/.pioreactor/exportable_datasets`.
- new Export Data page in the UI. Preview datasets before you export them, and new partition options for the exported CSVs.
- Plugins can now add datasets to the Export Data page. The plugin's datasets are automatically added to the Export Data page when installed.
- Stirring can now pause itself during an OD reading. This is accomplished by "dodging OD readings". You can activate this feature by setting the `enable_dodging_od` to `True` in config.ini, under `[stirring.config]`. The replaces an older, less reliable plugin that was on our forums. Users have wanted this feature to have a very fast RPM between OD measurements (to get more aeration), and avoid noisy OD measurements.
- Stirring can now pause itself during an OD reading. This is accomplished by "dodging OD readings". You can activate this feature by setting the `enable_dodging_od` to `True` in config.ini, under `[stirring.config]`. The replaces an older, less reliable plugin that was on our forums. Users have wanted this feature to have a very fast RPM between OD measurements (to get more aeration), and avoid noisy OD measurements. There's no reason to believe this will decrease the noise if using a "moderate" RPM though.

#### Enhancements
- improvements to Dodging background job code, including the ability to initialize the class based on dodging or not.
- better error handling for failed OD blanks.
- better button state management in the UI.
- you can add addresses to the (new) `[cluster.addresses]` section to specify IPs for pioreactors. Example:
- job YAMLs' published_settings can have a new field, `editable` (bool), which controls whether it shows up on the Settings dialog or not. (False means it won't show up since it's not editable!). Default is true. This _should_ align with the `published_setting` in Python's job classes.
- you can add IPv4 addresses to the (new) `[cluster.addresses]` section to specify IPs for pioreactors. Example:
```
[cluster.addresses]
Expand Down
2 changes: 1 addition & 1 deletion config.dev.ini
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Kd=0.0

[od_reading.config]
# how many samples should the ADC publish per second?
samples_per_second=0.2
samples_per_second=0.1

pd_reference_ema=0.4

Expand Down
28 changes: 13 additions & 15 deletions pioreactor/actions/self_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,25 +411,23 @@ def test_positive_correlation_between_rpm_and_stirring(
dcs = []
measured_rpms = []
n_samples = 8
start = initial_dc * 1.2
end = initial_dc * 0.8
start = min(initial_dc * 1.2, 100)
end = max(initial_dc * 0.8, 5)

with stirring.Stirrer(
target_rpm=0, unit=unit, experiment=experiment, rpm_calculator=None
) as st, stirring.RpmFromFrequency() as rpm_calc:
with stirring.RpmFromFrequency() as rpm_calc:
rpm_calc.setup()
st.duty_cycle = initial_dc
st.start_stirring()
sleep(0.75)
with stirring.Stirrer(target_rpm=None, unit=unit, experiment=experiment, rpm_calculator=None) as st:
st.set_duty_cycle(initial_dc)
sleep(0.75)

for i in range(n_samples):
p = i / n_samples
dc = start * (1 - p) + p * end
for i in range(n_samples):
p = i / n_samples
dc = start * (1 - p) + p * end

st.set_duty_cycle(dc)
sleep(0.75)
measured_rpms.append(rpm_calc.estimate(3.0))
dcs.append(dc)
st.set_duty_cycle(dc)
sleep(0.75)
measured_rpms.append(rpm_calc.estimate(3.0))
dcs.append(dc)

measured_correlation = round(correlation(dcs, measured_rpms), 2)
logger.debug(f"Correlation between stirring RPM and duty cycle: {measured_correlation}")
Expand Down
1 change: 0 additions & 1 deletion pioreactor/automations/dosing/chemostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ class Chemostat(DosingAutomationJob):
automation_name = "chemostat"
published_settings = {
"volume": {"datatype": "float", "settable": True, "unit": "mL"},
"duration": {"datatype": "float", "settable": True, "unit": "min"},
}

def __init__(self, volume: float | str, **kwargs) -> None:
Expand Down
1 change: 0 additions & 1 deletion pioreactor/automations/dosing/fed_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ class FedBatch(DosingAutomationJob):
automation_name = "fed_batch"
published_settings = {
"volume": {"datatype": "float", "unit": "mL", "settable": True},
"duration": {"datatype": "float", "settable": True, "unit": "min"},
}

def __init__(self, volume, **kwargs):
Expand Down
1 change: 0 additions & 1 deletion pioreactor/automations/dosing/pid_morbidostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class PIDMorbidostat(DosingAutomationJob):
"volume": {"datatype": "float", "settable": True, "unit": "mL"},
"target_normalized_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: float | str, target_normalized_od: float | str, **kwargs):
Expand Down
1 change: 0 additions & 1 deletion pioreactor/automations/dosing/turbidostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ class Turbidostat(DosingAutomationJob):
"volume": {"datatype": "float", "settable": True, "unit": "mL"},
"target_normalized_od": {"datatype": "float", "settable": True, "unit": "AU"},
"target_od": {"datatype": "float", "settable": True, "unit": "OD"},
"duration": {"datatype": "float", "settable": True, "unit": "min"},
}
target_od = None
target_normalized_od = None
Expand Down
34 changes: 20 additions & 14 deletions pioreactor/background_jobs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -981,10 +981,19 @@ def __init__(self, unit: str, experiment: str, plugin_name: str) -> None:
class BackgroundJobWithDodging(_BackgroundJob):
"""
This utility class allows for a change in behaviour when an OD reading is about to taken. Example: shutting
off a air-bubbler, or shutting off a pump or valve, with appropriate delay between.
off a air-bubbler, or shutting off an LED, with appropriate delay between.
The methods `action_to_do_before_od_reading` and `action_to_do_after_od_reading` need to be overwritten, and
config needs to be added:
optional initialize_dodging_operation and initialize_continuous_operation can be overwritten.
If dodging is enabled, and OD reading is present then:
1. initialize_dodging_operation runs immediately. Use this to set up important state for dodging
2. before an OD reading is taken, action_to_do_before_od_reading is run
3. after an OD reading is taken, action_to_do_after_od_reading is run
If dodging is enabled, but OD reading is not present OR dodging is NOT enabled:
1. initialize_continuous_operation runs immediately. Use this to set up important state for continuous operation.
Config parameters needs to be added:
[<job_name>.config]
post_delay_duration=
Expand Down Expand Up @@ -1036,20 +1045,16 @@ def __init__(self, *args, source="app", **kwargs) -> None:
self.add_to_published_settings("currently_dodging_od", {"datatype": "boolean", "settable": False})

def __post__init__(self):
super().__post__init__()
self.set_enable_dodging_od(
config.getboolean(f"{self.job_name}.config", "enable_dodging_od", fallback="False")
)
self.start_passive_listeners()
super().__post__init__()

def _noop(self):
pass

def set_currently_dodging_od(self, value: bool):
if self.currently_dodging_od == value:
# noop
return

self.currently_dodging_od = value
if self.currently_dodging_od:
self.initialize_dodging_operation() # user defined
Expand All @@ -1058,14 +1063,12 @@ def set_currently_dodging_od(self, value: bool):
self._setup_timer()
else:
self.initialize_continuous_operation() # user defined

self._action_to_do_before_od_reading = self._noop
self._action_to_do_after_od_reading = self._noop
self.sneak_in_timer.cancel()

def set_enable_dodging_od(self, value: bool):
self.enable_dodging_od = value

if self.enable_dodging_od:
if self.is_od_job_running():
self.logger.debug("Will attempt to dodge OD readings.")
Expand Down Expand Up @@ -1100,15 +1103,16 @@ def start_passive_listeners(self) -> None:

def _od_reading_changed_status(self, msg):
if self.enable_dodging_od:
if msg.payload:
# only act if our internal state is discordant with the external state
if msg.payload and not self.currently_dodging_od:
# turned off
self.logger.debug("OD reading present. Dodging!")
self.set_currently_dodging_od(True)
else:
elif not msg.payload and self.currently_dodging_od:
self.logger.debug("OD reading turned off. Stop dodging.")
self.set_currently_dodging_od(False)

def _setup_timer(self) -> None:
self.logger.debug("OD reading present. Dodging!")

self.sneak_in_timer.cancel()

post_delay = config.getfloat(f"{self.job_name}.config", "post_delay_duration", fallback=0.5)
Expand Down Expand Up @@ -1151,13 +1155,15 @@ def sneak_in(ads_interval, post_delay, pre_delay) -> None:
)
self.clean_up()

time_to_next_ads_reading = ads_interval - ((time() - ads_start_time) % ads_interval)

self.sneak_in_timer = RepeatedTimer(
ads_interval,
sneak_in,
job_name=self.job_name,
args=(ads_interval, post_delay, pre_delay),
run_immediately=True,
run_after=ads_interval - ((time() - ads_start_time) % ads_interval),
run_after=time_to_next_ads_reading + (post_delay + self.OD_READING_DURATION),
logger=self.logger,
)
self.sneak_in_timer.start()
Expand Down
18 changes: 15 additions & 3 deletions pioreactor/background_jobs/dosing_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,7 @@ class DosingAutomationJob(AutomationJob):

automation_name = "dosing_automation_base" # is overwritten in subclasses
job_name = "dosing_automation"
published_settings: dict[str, pt.PublishableSetting] = {
"duration": {"datatype": "float", "settable": True}
}
published_settings: dict[str, pt.PublishableSetting] = {}

previous_normalized_od: Optional[float] = None
previous_growth_rate: Optional[float] = None
Expand Down Expand Up @@ -240,6 +238,20 @@ def __init__(
) -> None:
super(DosingAutomationJob, self).__init__(unit, experiment)

if not is_pio_job_running("stirring"):
self.logger.warning(
"It's recommended to have stirring on to improve mixing during dosing events."
)

self.add_to_published_settings(
"duration",
{
"datatype": "float",
"settable": True,
"unit": "min",
},
)

self.skip_first_run = skip_first_run

self.latest_normalized_od_at = current_utc_datetime()
Expand Down
18 changes: 10 additions & 8 deletions pioreactor/background_jobs/led_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,12 @@ class LEDAutomationJob(AutomationJob):
This is the super class that LED automations inherit from. The `run` function will
execute every `duration` minutes (selected at the start of the program), and call the `execute` function
which is what subclasses define.
To change setting over MQTT:
`pioreactor/<unit>/<experiment>/led_automation/<setting>/set` value
"""

automation_name = "led_automation_base" # is overwritten in subclasses
job_name = "led_automation"

published_settings: dict[str, pt.PublishableSetting] = {
"duration": {"datatype": "float", "settable": True},
}
published_settings: dict[str, pt.PublishableSetting] = {}

_latest_growth_rate: Optional[float] = None
_latest_normalized_od: Optional[float] = None
Expand Down Expand Up @@ -76,6 +69,15 @@ def __init__(
) -> None:
super(LEDAutomationJob, self).__init__(unit, experiment)

self.add_to_published_settings(
"duration",
{
"datatype": "float",
"settable": True,
"unit": "min",
},
)

self.skip_first_run = skip_first_run
self.latest_normalized_od_at: datetime = current_utc_datetime()
self.latest_growth_rate_at: datetime = current_utc_datetime()
Expand Down
29 changes: 8 additions & 21 deletions pioreactor/background_jobs/stirring.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,16 +271,6 @@ def __init__(
output_limits=(-7.5, 7.5), # avoid whiplashing
)

# set up thread to periodically check the rpm
self.rpm_check_repeated_thread = RepeatedTimer(
config.getfloat("stirring.config", "duration_between_updates_seconds", fallback=23.0),
self.poll_and_update_dc,
job_name=self.job_name,
run_immediately=True,
run_after=6,
logger=self.logger,
)

def action_to_do_before_od_reading(self):
self.stop_stirring()

Expand All @@ -295,13 +285,13 @@ def initialize_dodging_operation(self):
"Recommended to decrease `samples_per_second` to ensure there is time to start/stop stirring. Try 0.12 or less."
)

self.stop_stirring()
self.rpm_check_repeated_thread = RepeatedTimer(
1_000,
lambda *args: None,
job_name=self.job_name,
logger=self.logger,
)
self.stop_stirring() # we'll start it in action_to_do_after_od_reading

def initialize_continuous_operation(self):
# set up thread to periodically check the rpm
Expand All @@ -312,7 +302,8 @@ def initialize_continuous_operation(self):
run_immediately=True,
run_after=6,
logger=self.logger,
)
).start()
self.start_stirring()

def initialize_rpm_to_dc_lookup(self) -> Callable:
if self.rpm_calculator is None:
Expand Down Expand Up @@ -355,20 +346,18 @@ def start_stirring(self) -> None:
self.set_duty_cycle(100) # get momentum to start
sleep(0.35)
self.set_duty_cycle(self._estimate_duty_cycle)

if self.rpm_calculator is not None:
self.rpm_check_repeated_thread.start() # .start is idempotent
self.rpm_check_repeated_thread.unpause()

def stop_stirring(self) -> None:
self.set_duty_cycle(0) # get momentum to start
self.rpm_check_repeated_thread.pause()
if self.rpm_calculator is not None:
self.measured_rpm = structs.MeasuredRPM(timestamp=current_utc_datetime(), measured_rpm=0)
self.rpm_check_repeated_thread.pause() # .start is idempotent

def kick_stirring(self) -> None:
self.logger.debug("Kicking stirring")
self.set_duty_cycle(0)
sleep(0.5)
sleep(0.75)
self.set_duty_cycle(100)
sleep(0.5)
self.set_duty_cycle(
Expand Down Expand Up @@ -491,7 +480,7 @@ def block_until_rpm_is_close_to_target(
return False

sleep_time = 0.2
poll_time = 2 # usually 4, but we don't need high accuracy here,
poll_time = 1.5
self.logger.debug(f"{self.job_name} is blocking until RPM is near {self.target_rpm}.")

self.rpm_check_repeated_thread.pause()
Expand Down Expand Up @@ -522,7 +511,7 @@ def block_until_rpm_is_close_to_target(


def start_stirring(
target_rpm: float = config.getfloat("stirring.config", "target_rpm", fallback=400),
target_rpm: Optional[float] = config.getfloat("stirring.config", "target_rpm", fallback=400),
unit: Optional[str] = None,
experiment: Optional[str] = None,
use_rpm: bool = config.getboolean("stirring.config", "use_rpm", fallback="true"),
Expand All @@ -545,8 +534,6 @@ def start_stirring(
experiment=experiment,
rpm_calculator=rpm_calculator,
)
if not stirrer.currently_dodging_od:
stirrer.start_stirring()
return stirrer


Expand Down
27 changes: 27 additions & 0 deletions pioreactor/tests/test_dosing_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1431,3 +1431,30 @@ def __init__(self, volume: float | str, **kwargs) -> None:
f"pioreactor/{get_unit_name()}/{experiment}/dosing_automation/duration", timeout=2
)
assert result is None


def test_custom_class_without_duration() -> None:
experiment = "test_custom_class_without_duration"

class NaiveTurbidostat(DosingAutomationJob):
automation_name = "_test_naive_turbidostat"
published_settings = {
"target_od": {"datatype": "float", "settable": True, "unit": "AU"},
}

def __init__(self, target_od: float, **kwargs: Any) -> None:
super(NaiveTurbidostat, self).__init__(**kwargs)
self.target_od = target_od

def execute(self) -> None:
if self.latest_normalized_od > self.target_od:
self.execute_io_action(media_ml=1.0, waste_ml=1.0)

with NaiveTurbidostat(
unit=get_unit_name(),
experiment=experiment,
target_od=2.0,
duration=10,
):
msg = pubsub.subscribe(f"pioreactor/{unit}/{experiment}/dosing_automation/duration", timeout=1)
assert msg is not None
1 change: 1 addition & 0 deletions pioreactor/tests/test_stirring.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def test_change_target_rpm_mid_cycle() -> None:
def test_pause_stirring_mid_cycle() -> None:
exp = "test_pause_stirring_mid_cycle"
with Stirrer(500, unit, exp, rpm_calculator=None) as st:
st.stop_stirring()
assert st.duty_cycle == 0
st.start_stirring()
original_dc = st.duty_cycle
Expand Down
Loading

0 comments on commit 4eb6b2b

Please sign in to comment.