Skip to content

Commit 003887a

Browse files
Adc and x_to_y
1 parent 36f669e commit 003887a

13 files changed

Lines changed: 86 additions & 77 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
- Specify which Pioreactor to update on the Updates page (option is only available with release archives.)
1919
- Choose the level of detail on the new Event Logs page.
2020
- Previously, when a worker's web server is down, it would halt an update from proceeding (since it can't send the command). Now, leader will try the webserver, and if it observes a 5xx error, will attempt an SSH communication.
21+
- stirring calibration is run as part of self-test now.
22+
- improvements to stirring job when OD readings have a long pause between.
2123

2224
#### Web API changes
2325

2426
- GET `/unit_api/jobs/running/<job>` introduced
2527
- GET `/api/experiment_profiles/running/experiments/<experiment>` introduced
2628

2729
#### Breaking changes
28-
30+
- Calbration structs `predict` is now `x_to_y`, `ipredict` is now `y_to_x`. This is just more clear!
2931
- (Eventually) plugins should migrate from `click_some_name` to autodiscover plugins, to importing `run`. Example:
3032
```
3133
import click
@@ -44,6 +46,7 @@
4446
- experiment profiles start now use the `unit_api/` directly. This may mitigate the issue where huey workers stampeding on each other when try to start many jobs.
4547
- fix `pio calibrations run ... -y` not saving as active.
4648
- fix manual dosing in the UI
49+
- fix recording logs manually via the UI.
4750

4851
### 25.1.21
4952

‎pioreactor/actions/pump.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,9 @@ def _get_pump_action(pump_device: PumpCalibrationDevices) -> str:
401401

402402
if not is_default_calibration(waste_calibration) and not is_default_calibration(media_calibration):
403403
# provided with calibrations, we can compute if media_rate > waste_rate, which is a danger zone!
404-
# `predict(1)` asks "how much lqd is moved in 1 second"
405-
if media_calibration.predict(1) > waste_calibration.predict(1):
406-
ratio = min(waste_calibration.predict(1) / media_calibration.predict(1), ratio)
404+
# `x_to_y(1)` asks "how much lqd is moved in 1 second"
405+
if media_calibration.x_to_y(1) > waste_calibration.x_to_y(1):
406+
ratio = min(waste_calibration.x_to_y(1) / media_calibration.x_to_y(1), ratio)
407407
else:
408408
logger.warning(
409409
"Calibrations don't exist for pump(s). Keep an eye on the liquid level to avoid overflowing!"

‎pioreactor/background_jobs/base.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,7 @@ def __init__(self, *args, source="app", **kwargs) -> None:
10511051
self._event_is_dodging_od = threading.Event()
10521052

10531053
def __post__init__(self):
1054+
# this method runs after the subclasses init
10541055
self.set_enable_dodging_od(
10551056
config.getboolean(f"{self.job_name}.config", "enable_dodging_od", fallback="False")
10561057
)

‎pioreactor/background_jobs/leader/mqtt_to_db_streaming.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ def parse_logs(topic: str, payload: pt.MQTTMessagePayload) -> dict:
323323
"timestamp": log.timestamp,
324324
"message": log.message,
325325
"task": log.task,
326-
"level": log.level,
326+
"level": log.level.upper(),
327327
"source": log.source, # should be app, ui, etc.
328328
}
329329

‎pioreactor/background_jobs/od_reading.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ def calibration(observed_voltage: pt.Voltage) -> pt.OD:
685685
)
686686

687687
try:
688-
return calibration_data.ipredict(observed_voltage, enforce_bounds=True)
688+
return calibration_data.y_to_x(observed_voltage, enforce_bounds=True)
689689
except exc.NoSolutionsFoundError:
690690
if observed_voltage <= min_voltage:
691691
return min_OD

‎pioreactor/background_jobs/stirring.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -321,13 +321,13 @@ def initialize_rpm_to_dc_lookup(self) -> Callable:
321321

322322
# since we have calibration data, and the initial_duty_cycle could be
323323
# far off, giving the below equation a bad "first step". We set it here.
324-
self._estimate_duty_cycle = calibration.ipredict(self.target_rpm)
324+
self._estimate_duty_cycle = calibration.y_to_x(self.target_rpm)
325325

326326
# we scale this by 90% to make sure the PID + prediction doesn't overshoot,
327327
# better to be conservative here.
328328
# equivalent to a weighted average: 0.1 * current + 0.9 * predicted
329329
return lambda rpm: self._estimate_duty_cycle - 0.90 * (
330-
self._estimate_duty_cycle - (calibration.ipredict(rpm))
330+
self._estimate_duty_cycle - (calibration.y_to_x(rpm))
331331
)
332332
else:
333333
return lambda rpm: self._estimate_duty_cycle
@@ -367,7 +367,7 @@ def kick_stirring(self) -> None:
367367
def kick_stirring_but_avoid_od_reading(self) -> None:
368368
"""
369369
This will determine when the next od reading occurs (if possible), and
370-
wait until it completes before kicking stirring.
370+
wait until it completes before kicking stirring or sneak in early.
371371
"""
372372
with JobManager() as jm:
373373
interval = float(jm.get_setting_from_running_job("od_reading", "interval", timeout=5))
@@ -376,9 +376,13 @@ def kick_stirring_but_avoid_od_reading(self) -> None:
376376
)
377377

378378
seconds_to_next_reading = interval - (time() - first_od_obs_time) % interval
379-
sleep(
380-
seconds_to_next_reading + 2
381-
) # add an additional 2 seconds to make sure we wait long enough for OD reading to complete.
379+
380+
# if seconds_to_next_reading is like 50s (high duration between ODs), let's kick now and not wait.
381+
if seconds_to_next_reading <= 2:
382+
sleep(
383+
seconds_to_next_reading + 2
384+
) # add an additional 2 seconds to make sure we wait long enough for OD reading to complete.
385+
382386
self.kick_stirring()
383387
return
384388

@@ -403,8 +407,10 @@ def poll(self, poll_for_seconds: float) -> Optional[structs.MeasuredRPM]:
403407
self.blink_error_code(error_codes.STIRRING_FAILED)
404408

405409
is_od_running = is_pio_job_running("od_reading")
410+
is_dodging = self.currently_dodging_od
406411

407-
if not is_od_running:
412+
if not is_od_running or is_dodging:
413+
# if dodging, poll only runs when needed (outside od readings), so it's always safe to kick.
408414
self.kick_stirring()
409415
else:
410416
self.kick_stirring_but_avoid_od_reading()
@@ -418,9 +424,9 @@ def poll_and_update_dc(self, poll_for_seconds: Optional[float] = None) -> None:
418424
if poll_for_seconds is None:
419425
target_n_data_points = 12
420426
rps = self.target_rpm / 60.0
421-
poll_for_seconds = min(
422-
target_n_data_points / rps, 5
423-
) # things can break if this function takes too long.
427+
poll_for_seconds = max(
428+
1, min(target_n_data_points / rps, 5)
429+
) # things can break if this function takes too long, but always get _some_ data.
424430

425431
self.poll(poll_for_seconds)
426432

‎pioreactor/calibrations/stirring_calibration.py‎

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
from time import sleep
1010

1111
from pioreactor.background_jobs import stirring
12+
from pioreactor.calibrations.utils import linspace
1213
from pioreactor.config import config
14+
from pioreactor.config import temporary_config_change
1315
from pioreactor.exc import JobPresentError
1416
from pioreactor.hardware import voltage_in_aux
1517
from pioreactor.logging import create_logger
1618
from pioreactor.structs import SimpleStirringCalibration
19+
from pioreactor.utils import clamp
1720
from pioreactor.utils import is_pio_job_running
1821
from pioreactor.utils import managed_lifecycle
1922
from pioreactor.utils.math_helpers import simple_linear_regression
@@ -28,7 +31,7 @@ def run_stirring_calibration(
2831
if max_dc is None and min_dc is None:
2932
# seed with initial_duty_cycle
3033
config_initial_duty_cycle = config.getfloat("stirring.config", "initial_duty_cycle", fallback=30)
31-
min_dc, max_dc = round(config_initial_duty_cycle * 0.75), round(config_initial_duty_cycle * 1.33)
34+
min_dc, max_dc = config_initial_duty_cycle * 0.75, clamp(0, config_initial_duty_cycle * 1.5, 100)
3235
elif (max_dc is not None) and (min_dc is not None):
3336
assert min_dc < max_dc, "min_dc >= max_dc"
3437
else:
@@ -49,46 +52,43 @@ def run_stirring_calibration(
4952
measured_rpms = []
5053

5154
# go up and down to observe any hysteresis.
52-
dcs = (
53-
list(range(round(max_dc), round(min_dc) - 2, -3))
54-
+ list(range(round(min_dc), round(max_dc) + 3, 3))
55-
+ list(range(round(max_dc), round(min_dc) - 2, -3))
56-
)
55+
dcs = linspace(max_dc, min_dc, 5) + linspace(min_dc, min_dc, 5) + linspace(max_dc, min_dc, 5)
5756
n_samples = len(dcs)
5857

59-
with stirring.RpmFromFrequency() as rpm_calc, stirring.Stirrer(
60-
target_rpm=0,
61-
unit=unit,
62-
experiment=experiment,
63-
rpm_calculator=None,
64-
) as st:
65-
rpm_calc.setup()
66-
st.duty_cycle = (
67-
max_dc + min_dc
68-
) / 2 # we start with a somewhat low value, s.t. the stir bar is caught.
69-
st.start_stirring()
70-
sleep(5)
71-
72-
for count, dc in enumerate(dcs, start=1):
73-
st.set_duty_cycle(dc)
74-
sleep(1.5)
75-
rpm = rpm_calc.estimate(2)
76-
measured_rpms.append(rpm)
77-
logger.debug(f"Detected {rpm=:.1f} RPM @ {dc=}%")
78-
79-
# log progress
80-
lc.mqtt_client.publish(
81-
f"pioreactor/{unit}/{experiment}/{action_name}/percent_progress",
82-
count / n_samples * 100,
83-
)
84-
logger.debug(f"Progress: {count/n_samples:.0%}")
58+
with temporary_config_change(config, "stirring.config", "enable_dodging_od", "False"):
59+
with stirring.RpmFromFrequency() as rpm_calc, stirring.Stirrer(
60+
target_rpm=0,
61+
unit=unit,
62+
experiment=experiment,
63+
rpm_calculator=None,
64+
) as st:
65+
rpm_calc.setup()
66+
st.duty_cycle = (
67+
max_dc + min_dc
68+
) / 2 # we start with a somewhat low value, s.t. the stir bar is caught.
69+
st.start_stirring()
70+
sleep(3)
71+
72+
for count, dc in enumerate(dcs, start=1):
73+
st.set_duty_cycle(dc)
74+
sleep(2.0)
75+
rpm = rpm_calc.estimate(2)
76+
measured_rpms.append(rpm)
77+
logger.debug(f"Detected {rpm=:.1f} RPM @ {dc=}%")
78+
79+
# log progress
80+
lc.mqtt_client.publish(
81+
f"pioreactor/{unit}/{experiment}/{action_name}/percent_progress",
82+
count / n_samples * 100,
83+
)
84+
logger.debug(f"Progress: {count/n_samples:.0%}")
8585

8686
# drop any 0 in RPM, too little DC
8787
try:
8888
filtered_dcs, filtered_measured_rpms = zip(*filter(lambda d: d[1] > 0, zip(dcs, measured_rpms)))
8989
except ValueError:
9090
# the above can fail if all measured rpms are 0
91-
logger.error("No RPMs were measured. Is the stirring spinning?")
91+
logger.warning("No RPMs were measured. Is the stirring spinning?")
9292
raise ValueError("No RPMs were measured. Is the stirring spinning?")
9393

9494
if len(filtered_dcs) <= n_samples * 0.75:

‎pioreactor/cli/calibrations.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,15 +219,16 @@ def delete_calibration(device: str, calibration_name: str) -> None:
219219
calibration delete --device od --name my_od_cal_v1
220220
"""
221221
target_file = CALIBRATION_PATH / device / f"{calibration_name}.yaml"
222+
222223
if not target_file.exists():
223224
click.echo(f"No such calibration file: {target_file}")
224225
raise click.Abort()
225226

226-
target_file.unlink()
227-
228227
cal = load_calibration(device, calibration_name)
229228
cal.remove_as_active_calibration_for_device(device)
230229

230+
target_file.unlink()
231+
231232
click.echo(f"Deleted calibration '{calibration_name}' of device '{device}'.")
232233

233234

‎pioreactor/cli/pios.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def _thread_function(unit: str) -> bool:
218218
logger.debug(f"Error occurred: {e}.", exc_info=True)
219219
return False
220220

221-
with ThreadPoolExecutor(max_workers=len(units)) as executor:
221+
with ThreadPoolExecutor(max_workers=min(len(units), 6)) as executor:
222222
results = executor.map(_thread_function, units)
223223

224224
if not all(results):
@@ -627,7 +627,7 @@ def _thread_function(unit: str) -> bool:
627627
# save config.inis to database
628628
save_config_files_to_db(units, shared, specific)
629629

630-
with ThreadPoolExecutor(max_workers=len(units)) as executor:
630+
with ThreadPoolExecutor(max_workers=min(len(units), 6)) as executor:
631631
results = executor.map(_thread_function, units)
632632

633633
if not all(results):

‎pioreactor/structs.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,16 +165,12 @@ def calibration_type(self):
165165

166166
def save_to_disk_for_device(self, device: str) -> str:
167167
from pioreactor.calibrations import CALIBRATION_PATH
168-
import shutil
169168

170169
logger = create_logger("calibrations")
171170

172171
calibration_dir = CALIBRATION_PATH / device
173172
calibration_dir.mkdir(parents=True, exist_ok=True)
174173

175-
# Set ownership to pioreactor:www-data using shutil
176-
# shutil.chown(calibration_dir, user="pioreactor", group="www-data")
177-
178174
out_file = calibration_dir / f"{self.calibration_name}.yaml"
179175

180176
# Serialize to YAML
@@ -214,14 +210,14 @@ def exists_on_disk_for_device(self, device: str) -> bool:
214210

215211
return target_file.exists()
216212

217-
def predict(self, x: X) -> Y:
213+
def x_to_y(self, x: X) -> Y:
218214
"""
219215
Predict y given x
220216
"""
221217
assert self.curve_type == "poly"
222218
return sum([c * x**i for i, c in enumerate(reversed(self.curve_data_))])
223219

224-
def ipredict(self, y: Y, enforce_bounds=False) -> X:
220+
def y_to_x(self, y: Y, enforce_bounds=False) -> X:
225221
"""
226222
predict x given y
227223
"""
@@ -297,10 +293,10 @@ class SimplePeristalticPumpCalibration(CalibrationBase, kw_only=True, tag="simpl
297293
y: str = "Volume"
298294

299295
def ml_to_duration(self, ml: pt.mL) -> pt.Seconds:
300-
return t.cast(pt.Seconds, self.ipredict(ml))
296+
return t.cast(pt.Seconds, self.y_to_x(ml))
301297

302298
def duration_to_ml(self, duration: pt.Seconds) -> pt.mL:
303-
return t.cast(pt.mL, self.predict(duration))
299+
return t.cast(pt.mL, self.x_to_y(duration))
304300

305301

306302
class SimpleStirringCalibration(CalibrationBase, kw_only=True, tag="simple_stirring"):

0 commit comments

Comments
 (0)