Skip to content

Commit 8907317

Browse files
adding support for negative dosing events
1 parent d12eb62 commit 8907317

File tree

9 files changed

+176
-26
lines changed

9 files changed

+176
-26
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ rate_std=0.25
1616
- New image installs only:
1717
- updated base OS to the latest 25-05-06 Raspberry Pi OS. The big change is using Linux kernel 6.12.
1818
- Added the column `hours_since_experiment_created` to dataset exports that details hours since experiment was created.
19+
- When a pump runs, it _first_ fires off a dosing_event, which stores information about how much liquid is moved. However, if the pump is stopped early, there was no correction issued to the amount of liquid actually moved. Now, when a pump is stopped early, a _negative_ volume is sent s.t. the delta between the initial amount and new amount is equal to the dosed amount (so when you sum up the volume changes, you get the actual change, as expected).
1920
- Performance optimizations
2021

2122
#### Bug fixes
22-
- fixed stirrer not spinning on Pioreactor page (UI) in some cases
23+
- fixed stir bar not spinning on Pioreactor page (UI) in some cases
2324
- alert user if their OD reading is constant before starting the growth-rate calculator, which would break things.
2425
- alert user if their software is installed in a non-standard location. If so, try `pio uninstall pioreactor -y`.
2526
- Added a warning if the OD calibration is invalid (ex: a constant line)

pioreactor/actions/pump.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ def _get_pump_action(pump_device: PumpCalibrationDevices) -> str:
299299

300300
logger.info(_to_human_readable_action(None, duration, pump_device))
301301
elif continuously:
302-
duration = 2.5
302+
duration = 1.0
303303
ml = calibration.duration_to_ml(duration) # can be wrong if calibration is not defined
304304

305305
logger.info(f"Running {pump_device} continuously.")
@@ -328,11 +328,13 @@ def _get_pump_action(pump_device: PumpCalibrationDevices) -> str:
328328
if not continuously:
329329
pump.by_duration(duration, block=False)
330330
# how does this work? What's up with the (or True)?
331-
# exit_event.wait returns True iff the event is set, i.e by an interrupt. If we timeout (good path)
332-
# then we eval (False or True), hence we break out of this while loop.
331+
# exit_event.wait returns True iff the event is set, i.e by an interrupt.
332+
# If we timeout (good path), then we eval (False or True), hence we break out of this while loop.
333+
333334
while not (state.exit_event.wait(duration) or True):
334335
pump.interrupt.set()
335336
else:
337+
# continously path
336338
pump.continuously(block=False)
337339

338340
# we only break out of this while loop via a interrupt or MQTT signal => event.set()
@@ -348,9 +350,16 @@ def _get_pump_action(pump_device: PumpCalibrationDevices) -> str:
348350
logger.info(f"Stopped {pump_device}.")
349351

350352
if state.exit_event.is_set():
351-
# ended early
353+
# ended early. We should calculate how much _wasnt_ added, and update that.
352354
shortened_duration = time.monotonic() - pump_start_time
353-
return pump.duration_to_ml(shortened_duration)
355+
actually_dosed = pump.duration_to_ml(shortened_duration)
356+
logger.info(f"Stopped {pump_device} early.")
357+
358+
# need to reverse the data that was fired, dosing _can_ be negative, so we publish actually_dosed - ml s.t. ml + (actually_dosed - ml) = actually_dosed.
359+
_publish_pump_action(
360+
action_name, actually_dosed - ml, unit, experiment, mqtt_client, source_of_event
361+
)
362+
return actually_dosed
354363
return ml
355364

356365

pioreactor/background_jobs/dosing_automation.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pioreactor.automations.base import AutomationJob
2323
from pioreactor.config import config
2424
from pioreactor.logging import create_logger
25+
from pioreactor.utils import clamp
2526
from pioreactor.utils import is_pio_job_running
2627
from pioreactor.utils import local_persistent_storage
2728
from pioreactor.utils import SummableDict
@@ -92,23 +93,24 @@ def update(
9293
cls, new_dosing_event: structs.DosingEvent, current_liquid_volume: float, max_volume: float
9394
) -> float:
9495
assert current_liquid_volume >= 0
96+
assert max_volume >= 0
9597
volume, event = float(new_dosing_event.volume_change), new_dosing_event.event
9698
if event == "add_media":
97-
return current_liquid_volume + volume
99+
return max(current_liquid_volume + volume, 0)
98100
elif event == "add_alt_media":
99-
return current_liquid_volume + volume
101+
return max(current_liquid_volume + volume, 0)
100102
elif event == "remove_waste":
101103
if new_dosing_event.source_of_event == "manually":
102104
# we assume the user has extracted what they want, regardless of level or tube height.
103105
return max(current_liquid_volume - volume, 0.0)
104106
elif current_liquid_volume <= max_volume:
105107
# if the current volume is less than the outflow tube, no liquid is removed
106-
return current_liquid_volume
108+
return max(current_liquid_volume, 0)
107109
else:
108110
# since we do some additional "removing" after adding, we don't want to
109111
# count that as being removed (total volume is limited by position of outflow tube).
110112
# hence we keep an lowerbound here.
111-
return max(current_liquid_volume - volume, max_volume)
113+
return max(current_liquid_volume - volume, max_volume, 0)
112114
else:
113115
raise ValueError("Unknown event type")
114116

@@ -127,6 +129,7 @@ def update(
127129
current_liquid_volume: float,
128130
) -> float:
129131
assert 0.0 <= current_alt_media_fraction <= 1.0
132+
assert current_liquid_volume >= 0
130133
volume, event = float(new_dosing_event.volume_change), new_dosing_event.event
131134

132135
if event == "add_media":
@@ -153,14 +156,16 @@ def _update_alt_media_fraction(
153156
alt_media_delta: float,
154157
current_liquid_volume: float,
155158
) -> float:
156-
assert media_delta >= 0
157-
assert alt_media_delta >= 0
158159
total_addition = media_delta + alt_media_delta
159160

160-
return round(
161-
(current_alt_media_fraction * current_liquid_volume + alt_media_delta)
162-
/ (current_liquid_volume + total_addition),
163-
10,
161+
return clamp(
162+
0.0,
163+
round(
164+
(current_alt_media_fraction * current_liquid_volume + alt_media_delta)
165+
/ (current_liquid_volume + total_addition),
166+
10,
167+
),
168+
1.0,
164169
)
165170

166171

pioreactor/logging.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,14 @@ class MQTTHandler(logging.Handler):
9999

100100
def __init__(
101101
self,
102-
topic: str,
102+
topic_prefix: str,
103103
client: Client,
104104
qos: int = 0,
105105
retain: bool = False,
106106
**mqtt_kwargs,
107107
) -> None:
108108
logging.Handler.__init__(self)
109-
self.topic = topic
109+
self.topic_prefix = topic_prefix
110110
self.qos = qos
111111
self.retain = retain
112112
self.mqtt_kwargs = mqtt_kwargs
@@ -124,7 +124,7 @@ def emit(self, record) -> None:
124124
return
125125

126126
mqtt_msg = self.client.publish(
127-
f"{self.topic}/{record.levelname.lower()}",
127+
f"{self.topic_prefix}/{record.levelname.lower()}",
128128
payload,
129129
qos=self.qos,
130130
retain=self.retain,
@@ -198,7 +198,7 @@ def create_logger(
198198
"NOTICE": "green",
199199
"WARNING": "yellow",
200200
"ERROR": "red",
201-
"CRITICAL": "red,bg_white",
201+
"CRITICAL": "red",
202202
},
203203
)
204204
)
@@ -217,10 +217,10 @@ def create_logger(
217217
assert pub_client is not None
218218

219219
# create MQTT handlers for logs table
220-
topic = (
220+
topic_prefix = (
221221
f"pioreactor/{unit}/{experiment}/logs/{source}" # NOTE: we later append the log-level, ex: /debug
222222
)
223-
mqtt_to_db_handler = MQTTHandler(topic, pub_client)
223+
mqtt_to_db_handler = MQTTHandler(topic_prefix, pub_client)
224224
mqtt_to_db_handler.setLevel(logging.DEBUG)
225225
mqtt_to_db_handler.setFormatter(CustomisedJSONFormatter())
226226

pioreactor/plugin_management/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
3535
Adding to ~/.pioreactor/plugins
3636
37-
1. Scripts placed in ~/.pioreactor/plugins are automagically loaded.
37+
1. Scripts placed in ~/.pioreactor/plugins are automagically loaded in lexicographical order.
3838
The authors can add metadata to their file with the following variables at the
3939
highest level in the file:
4040

pioreactor/tests/test_dosing_automation.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,53 @@ def test_liquid_volume_calculator() -> None:
12141214
assert current_volume == 5
12151215

12161216

1217+
def test_liquid_volume_calculator_with_negative_values() -> None:
1218+
# let's start from 0 volume, and start adding.
1219+
vc = LiquidVolumeCalculator
1220+
current_volume = 0.0
1221+
max_volume = 14
1222+
1223+
# adding 6ml of media
1224+
event = DosingEvent(
1225+
volume_change=6,
1226+
event="add_media",
1227+
timestamp=default_datetime_for_pioreactor(0),
1228+
source_of_event="test",
1229+
)
1230+
current_volume = vc.update(event, current_volume, max_volume)
1231+
assert current_volume == 6
1232+
1233+
# testing if we can "back track" the volume if we underdose / stop early.
1234+
event = DosingEvent(
1235+
volume_change=-3, # test stopping early half-way through, should we report -2?
1236+
event="add_media",
1237+
timestamp=default_datetime_for_pioreactor(1),
1238+
source_of_event="test",
1239+
)
1240+
current_volume = vc.update(event, current_volume, max_volume)
1241+
assert current_volume == 3
1242+
1243+
# okay keep removing?
1244+
event = DosingEvent(
1245+
volume_change=-3,
1246+
event="add_media",
1247+
timestamp=default_datetime_for_pioreactor(2),
1248+
source_of_event="test",
1249+
)
1250+
current_volume = vc.update(event, current_volume, max_volume)
1251+
assert current_volume == 0
1252+
1253+
# okay keep removing? Shouldn't go negative!
1254+
event = DosingEvent(
1255+
volume_change=-3,
1256+
event="add_media",
1257+
timestamp=default_datetime_for_pioreactor(3),
1258+
source_of_event="test",
1259+
)
1260+
current_volume = vc.update(event, current_volume, max_volume)
1261+
assert current_volume == 0
1262+
1263+
12171264
def test_alt_media_calculator_from_0_volume() -> None:
12181265
# let's start from 0, and start adding.
12191266
ac = AltMediaFractionCalculator
@@ -1268,6 +1315,76 @@ def test_alt_media_calculator_from_0_volume() -> None:
12681315
assert current_alt_media_fraction == 0.6
12691316

12701317

1318+
def test_alt_media_calculator_from_0_volume_with_negative_doses() -> None:
1319+
# let's start from 0, and start adding.
1320+
ac = AltMediaFractionCalculator
1321+
vc = LiquidVolumeCalculator
1322+
1323+
current_volume = 0.0
1324+
max_volume = 14
1325+
current_alt_media_fraction = 0.0 # this value doesn't matter, could be anything since volume = 0.
1326+
1327+
# adding 6ml of media
1328+
event = DosingEvent(
1329+
volume_change=6,
1330+
event="add_media",
1331+
timestamp=default_datetime_for_pioreactor(0),
1332+
source_of_event="test",
1333+
)
1334+
current_alt_media_fraction = ac.update(event, current_alt_media_fraction, current_volume)
1335+
current_volume = vc.update(event, current_volume, max_volume)
1336+
assert current_alt_media_fraction == 0.0
1337+
1338+
# add 6ml alt_media
1339+
event = DosingEvent(
1340+
volume_change=6,
1341+
event="add_alt_media",
1342+
timestamp=default_datetime_for_pioreactor(1),
1343+
source_of_event="test",
1344+
)
1345+
current_alt_media_fraction = ac.update(event, current_alt_media_fraction, current_volume)
1346+
current_volume = vc.update(event, current_volume, max_volume)
1347+
assert current_alt_media_fraction == 0.5
1348+
1349+
# two counterfactuals:
1350+
# A. We added 3ml more alt-media.
1351+
# B. We added 6ml more alt-media, but stop half way through. => +6ml + -3ml
1352+
1353+
# A. add 3ml alt_media
1354+
event = DosingEvent(
1355+
volume_change=3,
1356+
event="add_alt_media",
1357+
timestamp=default_datetime_for_pioreactor(2),
1358+
source_of_event="test",
1359+
)
1360+
A_current_alt_media_fraction = ac.update(event, current_alt_media_fraction, current_volume)
1361+
A_current_volume = vc.update(event, current_volume, max_volume)
1362+
assert A_current_alt_media_fraction == 0.6
1363+
1364+
# B. We added 6ml more alt-media, but stop half way through. => +6ml + -3ml
1365+
event = DosingEvent(
1366+
volume_change=6,
1367+
event="add_alt_media",
1368+
timestamp=default_datetime_for_pioreactor(2),
1369+
source_of_event="test",
1370+
)
1371+
B_current_alt_media_fraction = ac.update(event, current_alt_media_fraction, current_volume)
1372+
B_current_volume = vc.update(event, current_volume, max_volume)
1373+
1374+
event = DosingEvent(
1375+
volume_change=-3,
1376+
event="add_alt_media",
1377+
timestamp=default_datetime_for_pioreactor(2),
1378+
source_of_event="test",
1379+
)
1380+
B_current_alt_media_fraction = ac.update(event, B_current_alt_media_fraction, B_current_volume)
1381+
B_current_volume = vc.update(event, B_current_volume, max_volume)
1382+
assert B_current_alt_media_fraction == 0.6
1383+
1384+
assert B_current_alt_media_fraction == A_current_alt_media_fraction
1385+
assert B_current_volume == A_current_volume
1386+
1387+
12711388
def test_adding_pumps_and_calling_them_from_execute_io_action() -> None:
12721389
experiment = "test_adding_pumps_and_calling_them_from_execute_io_action"
12731390
unit = get_unit_name()

pioreactor/tests/test_pumps.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22
from __future__ import annotations
33

4+
import json
45
import threading
56
import time
67
from datetime import datetime
@@ -126,6 +127,13 @@ def join(self):
126127
threading.Thread.join(self)
127128
return self._return
128129

130+
volume_updates = []
131+
132+
def collect_updates(msg):
133+
volume_updates.append(json.loads(msg.payload.decode()))
134+
135+
subscribe_and_callback(collect_updates, f"pioreactor/{unit}/{exp}/dosing_events", allow_retained=False)
136+
129137
expected_ml = 20
130138
t = ThreadWithReturnValue(target=add_media, args=(unit, exp, expected_ml), daemon=True)
131139
t.start()
@@ -141,6 +149,10 @@ def join(self):
141149
resulting_ml = t.join()
142150

143151
assert resulting_ml < expected_ml
152+
assert len(volume_updates) == 2
153+
print(volume_updates[0])
154+
assert volume_updates[0]["volume_change"] == expected_ml
155+
assert -expected_ml < volume_updates[1]["volume_change"] < 0 # fire off a negative volume change
144156

145157

146158
def test_continuously_running_pump_will_disconnect_via_mqtt() -> None:

pioreactor/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ def __enter__(self) -> Self:
238238

239239
def __exit__(self, *args) -> None:
240240
self.state = "disconnected"
241+
self._exit()
241242
self.publish_setting("$state", self.state)
242243
if not self._externally_provided_client:
243244
assert self.mqtt_client is not None

pioreactor/utils/adcs.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,14 @@ def read_from_channel(self, channel: pt.AdcChannel) -> pt.AnalogValue:
113113
)
114114

115115
def get_firmware_version(self) -> tuple[int, int]:
116-
result = bytearray(2)
117-
self.i2c.writeto_then_readfrom(hardware.ADC, bytes([0x08]), result)
118-
return (result[1], result[0])
116+
try:
117+
result = bytearray(2)
118+
self.i2c.writeto_then_readfrom(hardware.ADC, bytes([0x08]), result)
119+
return (result[1], result[0])
120+
except OSError:
121+
raise exc.HardwareNotFoundError(
122+
f"Unable to find i2c channel {hardware.ADC}. Is the HAT attached? Is the firmware loaded?"
123+
)
119124

120125
def from_voltage_to_raw(self, voltage: pt.Voltage) -> pt.AnalogValue:
121126
return int((voltage / 3.3) * 4095 * self.scale)

0 commit comments

Comments
 (0)