Summary
Holding a solution at a target pH takes frequent, small corrections. This note shows how to use a Daisy Process with a pH probe and two Peri P pumps to dose acid and base automatically, with a live chart so you can watch the controller work.
Why pH is hard to hold steady
pH is a logarithmic scale, so the same drop of acid or base can change very little at one pH and a great deal at another.
Many workflows depend on a steady pH, including cell culture and bioreactors, buffer preparation, enzyme reactions and titrations. In bioreactors, pH is commonly held by adding base or acid, or by sparging gases. The difficulty is that a solution does not respond evenly to each addition. For strong acids and bases, the titration curve is very steep near neutrality, and pH is far more sensitive to a change in composition around neutrality than elsewhere.
Real samples often contain buffers, which make the pH change more gradually than in Figure 1. The same approach still applies: dose a small amount, wait, then measure. Two delays make this harder. Reagent must mix and react before the probe can report the true pH, and the probe and its electronics respond with some lag. A controller that doses faster than the solution responds will overshoot, then correct in the other direction, which shows up as oscillation. Mixing the solution helps, because adding base without thorough mixing can create local regions of high pH.
This note covers the control loop and how it is tuned for a small, stirred volume. Reagent concentration, beaker volume and buffering all change how far each dose moves pH, so expect to retune the parameters for your own solution.
How the controller works
Each cycle the script reads pH, decides whether dosing is allowed, sizes a small dose, doses, and then waits.
A deadband that rests the pumps
Once pH is close enough to the target, there is no benefit in chasing sensor noise. The script stops dosing when the error is within ±0.1 pH units of the target. It does not start dosing again until pH has drifted beyond ±0.5. Between the two limits it keeps whatever state it was in, so a controller that is still correcting keeps going, and one that has settled stays quiet. Figure 3 shows how these zones map onto the live chart.
PID output with bounded doses
The controller output is u = Kp·e + Ki·I + Kd·D, where e is the target minus the measured pH, I is the integral of recent error and D is its rate of change [1]. With kp = 0.8, ki = 0.01 and kd = 0, the script is mainly a proportional controller with a small integral term that corrects a persistent offset. Set kd above zero to add a derivative term that damps the response when pH is already moving toward the target.
Keep ki small. The script sums the error in pH·seconds over the last 10 readings, which is about 72 s. If the error stays the same for that long, the integral term adds ki × 72 times the error, which is 0.72 times the error at ki = 0.01. That is already close to the proportional term (0.8), so the integral term roughly doubles the response when pH is stuck away from the target, and it fades once the error leaves the window. A ki of 0.1 would be nine times the proportional term and likely overshoot, because a dose takes time to show up in the reading. If pH drifts and will not settle, raise ki gradually. If it oscillates around the target, lower it. Narrowing epsilon is the other way to hold pH closer to the target.
A positive u means pH is below target, so the base pump runs. A negative u runs the acid pump. The dose volume is |u| × 0.10 mL, limited to between one drop (0.033 mL) and ten drops (0.33 mL). It is delivered at 7.3 mL/min, so the longest dose blocks the loop for about 2.7 s. Table 1 works through a few readings.
The loop timing is set by the script. Each cycle waits 8 s, takes a reading and, if a dose is needed, runs the pump and waits for it to finish. A cycle therefore lasts 8 s plus the dose time, which is up to about 11 s, so the solution always has at least 8 s to mix before the next reading. The 5 s cooldown is a safeguard between doses. With the 8 s interval it never delays a dose, and it only takes effect if you shorten INTERVAL_S below 5 s.
| Measured pH | Error e | Output u | Dose given | Dose time |
|---|---|---|---|---|
| 2.0 | +5.0 | +4.0 | Base, 0.330 mL (limited to 10 drops) | 2.7 s |
| 6.5 | +0.5 | +0.4 | Base, 0.040 mL | 0.3 s |
| 6.8 | +0.2 | +0.16 | Base, 0.033 mL (raised to 1 drop) | 0.3 s |
| 7.05 | −0.05 | Inside ±0.1 | No dose | – |
| 8.0 | −1.0 | −0.8 | Acid, 0.080 mL | 0.7 s |
The script delivers each dose at 50% of the pump’s maximum flow, which is 2.5 times the calibration value. With the 5.84 mL/min calibration used here, that is 7.3 mL/min. Slower delivery reduces overshoot, so increase the speed only if doses are too slow to register.
Daisy instruments for pH control
Daisy Process: the reading
Daisy Process connects to a Hamilton ARC pH probe and measures the pH of your solution. Each measurement returns the pH, the probe signal in millivolts and the temperature. The module has four probe channels, so it can also read other ARC probes such as dissolved oxygen, dissolved CO2, ORP, conductivity or cell density on its other channels. This note reads one pH probe, and the script’s pH_channel setting tells the Daisy Process which channel to read. The Hamilton Polilyte Plus Arc used in this note is specified for pH 0 to 14 with a sensitivity of about 57 to 59 mV/pH at 25 °C [2].
Daisy Peri P: the dose
Daisy Peri P is a precision peristaltic pump with six stainless-steel rollers and pre-stretched tubing for reproducible delivery. Each pump is calibrated by entering the flow you measure at 60 rpm for the installed tubing, and the script then dispenses by volume. Recalibrate whenever the tubing is changed or its cartridge pressure shifts. Use tubing that is compatible with your acid and base, which for Peri P is PharMed BPT or silicone.
At 7.3 mL/min a single drop (0.033 mL) takes about a quarter of a second, so the pumps can deliver very small amounts and the loop stays responsive between doses.
Why use Daisy for pH control
A dedicated pH controller is a good choice when you need a standard loop and nothing else. Daisy is a good fit when you want equipment that can be reconfigured. The Daisy Process and two Peri P pumps in this note can run a pH protocol today, then be set up for a different protocol tomorrow. Here is how it compares with common alternatives.
| Approach | Where it falls short | What Daisy adds |
|---|---|---|
| Manual titration or a timed syringe pump | Doses are not tied to the measured pH, so overshoot is common near neutral | Every dose is sized from the latest reading, down to a single 0.033 mL drop |
| Dedicated pH or bioreactor controller | Control logic is usually fixed, so unusual rules are hard to add | The control law is a short Python script, so deadband, dose limits and cooldown are all editable |
| Custom rig (microcontroller, separate meter and pumps) | Each device needs its own driver, wiring and plotting code | Probe and both pumps share one USB chain and one script, with a live chart included |
Because the modules are separate, nothing is locked to pH. The same Peri P pumps, for example, can be set up for droplet generation, and the Daisy Process can read other ARC probes on its other channels.
Example setup
The Daisy Process and both Peri P pumps are daisy-chained together, and the leader Daisy connects to the computer by USB (Figure 4). Each Peri P draws from its own reservoir and doses into the same stirred beaker, where the pH probe measures the result.
What you need
Daisies
| Item | Quantity | Notes |
|---|---|---|
| Daisy Process (SKU DPROC-04) | 1 | Reads pH, mV and temperature from the Hamilton Polilyte Plus Arc pH probe |
| Daisy Peri P (SKU DPERP) | 2 | One for acid and one for base |
Other items
| Item | Quantity | Notes |
|---|---|---|
| Hamilton Polilyte Plus Arc pH probe and VP8 cable | 1 | Connects to a channel of the Daisy Process |
| Peri P tubing and fittings | 2 sets | PharMed BPT or silicone, compatible with your acid and base |
| Acid and base reservoirs | 2 | Concentrations chosen for your solution |
| Beaker | 1 | Stir bar or another way to mix |
Connect and check
- Connect the probe first. Connect the Hamilton ARC pH probe to the VP8 cable, and the cable to a channel of the Daisy Process. If it is not on channel 1, update
pH_channelin the script. Always connect a probe before powering the module to prevent an error, and power down before disconnecting it. - Set up the beaker and tubing. Load the tubing into each Peri P first, then place the input end in its reservoir and the output end in the beaker. Place the probe in the stirred solution and aim the dosing tubes at a well-mixed region, away from the probe tip. Make all tubing connections before powering on the pumps.
- Chain the modules, then power on. Daisy-chain the Daisy Process and both Peri P pumps together before powering anything on, with the Daisy Process as the leader. Connect the leader Daisy to the computer with the micro USB to USB-C cable. Then power on the modules. The chain order sets each instrument’s index in the script:
Index Daisy Script name 0 Daisy Process pH_meter1 Daisy Peri P, acid acid_pump2 Daisy Peri P, base base_pump - Check the instrument list and probe. Open the Daisy Controller Software, then click the instrument refresh button. Confirm the pH probe reports a satisfactory quality indication and check when its last calibration occurred.
Script
The script has four parts, and each part builds on the one before it. The first sets the output file, connects to the instruments, calibrates and primes the pumps and takes a test reading. The second sets the control parameters. The third sets up the live plot, and the fourth runs the control loop. Table 2 lists the settings to check before you run it, and the control parameters are described in the sections above.
| Parameter | Value | What it does |
|---|---|---|
file_path, file_name | Your folder, pH_output.txt | Where the log file and the saved plot are written |
pH_channel | 1 | Daisy Process channel the pH probe is connected to. Default is 1, so set it to 2, 3 or 4 if you use another channel |
calibrate() | 5.84 mL/min | Flow you measure at 60 rpm for each pump and its tubing. See below |
target_pH | 7.0 | The pH to hold |
1. Connect, calibrate and prime
Before you run it, change file_path from the placeholder to a real folder on your computer. The log file pH_output.txt and the final plot image pH_plot.png are saved there. Set pH_channel to the Daisy Process channel your probe is on.
The value you pass to calibrate() is the flow each pump delivers at 60 rpm with your tubing installed. To find the calibration value for your tubing and solution, use the Daisy Peristaltic Pump quick start guide. The 5.84 mL/min here is only an example. Once the pumps are calibrated, prime each line so reagent reaches the beaker before the loop starts.
"""Real-time pH control with Daisy and Peri P pumps Reads pH from a Hamilton ARC probe on the Daisy Process, then doses acid or base with two Peri P pumps to hold a target pH. A live matplotlib chart shows the readings, and every reading is logged to a text file. Runs in the Daisy Controller Software's Python notebook (uses self.my_interface). """ # ---- Import and set the output file ---- # Packages to install import time from IPython.display import display, HTML import matplotlib.pyplot as plt import matplotlib.patches as mpatches from datetime import datetime from collections import deque # Set your output file path and output file name file_path = "/Users/yourusername/Documents/" file_name = "pH_output.txt" # ---- Print instruments found ---- print("Instruments found:") for position, (code, inst) in enumerate(zip(self.my_interface.instruments_list[0], self.my_interface.groups[0].inst)): print(f" [{position}] {code}: {inst.name}") # ---- Assign your instruments ---- # Define your instruments pH_meter = self.my_interface.groups[0].inst[0] # Daisy Process acid_pump = self.my_interface.groups[0].inst[1] # Daisy Peri P, acid base_pump = self.my_interface.groups[0].inst[2] # Daisy Peri P, base # ---- Calibrate the Peri P pumps (ml/min measured at 60 RPM) ---- # Peri P # Set the calibration (ml/min @ 60 rpm) for each Daisy Peri P acid_pump.calibrate(5.84) base_pump.calibrate(5.84) print(f"Acid pump calibration set to {acid_pump.calibration_speed} ml/min @ 60 RPM") print(f"Base pump calibration set to {base_pump.calibration_speed} ml/min @ 60 RPM") # ---- Prime both Peri P pumps ---- # Parameters for the Daisy Peri Ps are flow rate (ml/min), total volume (ml), and wait # Wait allows for the pump to finish running before running the next line acid_pump.run(6.0, 1.5, True) base_pump.run(6.0, 1.5, True) # ---- Take a test pH reading ---- # pH meter # Set the channel your pH meter is connected to # Default is channel 1, if using 2-4 specify channel pH_channel = 1 # Take a test measurement of your pH meter pH, mV, T = pH_meter.measure_parameters(pH_channel) print(f"pH: {pH:.3f} mV: {mV:.3f} Temperature: {T:.2f} °C")
2. Set the control parameters
# ---- Set control parameters ---- # Set your desired pH target_pH = 7.0 # Set your inner band, epsilon, the margin for error epsilon = 0.1 # Set your outer band, dosing re-arms outside this band, must be ≥ epsilon outer_band = 0.5 # Set your PID gains kp = 0.8 ki = 0.01 # small integral term corrects steady-state offset; increase gradually if pH drifts and won't settle kd = 0.0 # Set your dosing parameters DROP_ML = 0.033 # ~1 drop in ml ACID_MAX_ML_MIN = 2.5 * acid_pump.calibration_speed # Peri P max: 2.5 × calibration speed BASE_MAX_ML_MIN = 2.5 * base_pump.calibration_speed scale_ml_per_unit = 0.10 # dose volume = |PID output| * scale max_ml = 10.0 * DROP_ML # maximum dose per event (~0.33 ml) min_ml = 1.0 * DROP_ML # minimum dose per event (~0.033 ml) req_rate_ml_min = 0.5 * min(ACID_MAX_ML_MIN, BASE_MAX_ML_MIN) # ~50% of max; slower delivery reduces overshoot, increase if doses are too slow to register cooldown_s = 5.0 # minimum seconds between doses # Current pH control parameters configured print("Parameters configured.") print(f" Target pH: {target_pH} ± {epsilon} (re-arms at ±{outer_band})") print(f" PID: kp={kp}, ki={ki}, kd={kd}") print(f" Acid pump max: {ACID_MAX_ML_MIN:.2f} ml/min | Base pump max: {BASE_MAX_ML_MIN:.2f} ml/min") print(f" Dose range: {min_ml:.4f} – {max_ml:.4f} ml at {req_rate_ml_min:.2f} ml/min")
3. Set up the live plot
This cell builds a matplotlib chart that updates with each reading. It plots pH as a green trace over a sliding 300 s window, with the target in yellow, the ±0.1 deadband in gray dashes and the ±0.5 re-arm band in cyan. This cell only sets up the chart.
# ---- Set up the real-time pH plot ---- # X-axis will shows 300 seconds (5 minutes) of dispensing events WINDOW_SEC = 300.0 fig, ax = plt.subplots(figsize=(10, 8)) ax.set_facecolor('black') fig.patch.set_facecolor('black') # Graph axis labels and settings ax.set_xlabel('Time (s)', color='white') ax.set_ylabel('pH', color='white') ax.set_title('Real-Time pH Control', color='white') ax.tick_params(colors='white') for spine in ax.spines.values(): spine.set_edgecolor('white') ax.set_ylim(0, 14) ax.set_xlim(0, WINDOW_SEC) ax.grid(True, alpha=0.3, color='white') # Guide lines including: target, epsilons, outer bands line_target = ax.axhline(target_pH, color='yellow', linewidth=1, linestyle='-') line_upper_eps = ax.axhline(target_pH + epsilon, color='lightgray', linewidth=0.8, linestyle='--') line_lower_eps = ax.axhline(target_pH - epsilon, color='lightgray', linewidth=0.8, linestyle='--') line_upper_outer = ax.axhline(target_pH + outer_band, color='cyan', linewidth=0.8, linestyle='--') line_lower_outer = ax.axhline(target_pH - outer_band, color='cyan', linewidth=0.8, linestyle='--') # Plot pH data ph_line, = ax.plot([], [], 'o-', color='lime', linewidth=1, markersize=3) # Graph legend legend_elements = [ mpatches.Patch(color='yellow', label=f'Target pH ({target_pH})'), mpatches.Patch(color='lightgray', label=f'Epsilon (±{epsilon})'), mpatches.Patch(color='cyan', label=f'Outer band (±{outer_band})'), ] ax.legend(handles=legend_elements, loc='upper right', facecolor='black', labelcolor='white', fontsize=8) plt.tight_layout() plt.close(fig) # suppress auto-display; loop cell creates the display handles print("Chart configured. Run the control loop cell to start.")
4. Run the controller
Each reading and dose is shown in a log below the chart, written to the output file and added to the plot. To stop early, click the stop button in the toolbar. The output file and a pH_plot.png image of the graph are saved whether the loop finishes or is stopped.
# ---- Run the real-time pH controller ---- # ---------- Control loop state ---------- err_hist = deque(maxlen=16) # stores (time, error) for PID integral/derivative last_dose_ts = 0.0 can_dose = True # hysteresis flag xs = deque() # time values for plot ys = deque() # pH values for plot INTERVAL_S = 8 # seconds between measurements N_ITERATIONS = 300 # total number of measurements t0 = time.monotonic() log_lines = [] chart_handle = display(fig, display_id=True) log_handle = display(HTML(""), display_id=True) with open(file_path + file_name, "a") as f: log_lines.append(f"Starting pH control loop ({N_ITERATIONS} readings, {INTERVAL_S}s interval)") log_lines.append(f"Target: pH {target_pH} ± {epsilon} | Output: {file_path + file_name}") log_lines.append("-" * 60) log_handle.update(HTML("<pre style='background:#111;color:#ccc;padding:10px;border-radius:4px;font-size:12px;font-family:monospace;margin-top:6px'>" + "\n".join(log_lines) + "</pre>")) try: for i in range(N_ITERATIONS): # ---- Wait ---- time.sleep(INTERVAL_S) # ---- Measure ---- pH, mV, T = pH_meter.measure_parameters(pH_channel) t = time.monotonic() - t0 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_line = f"[{timestamp}] t={t:.1f}s pH={pH:.3f} mV={mV:.3f} T={T:.2f}°C" log_lines.append(log_line) f.write(log_line + " ") # ---- PID error ---- e = target_pH - pH # positive = need base, negative = need acid abs_err = abs(e) # ---- Hysteresis flag update ---- # Disarm when inside epsilon; re-arm only when beyond outer_band if abs_err <= epsilon: can_dose = False elif abs_err >= outer_band: can_dose = True # else: between epsilon and outer_band, keep previous state # ---- Error history ---- err_hist.append((t, e)) # ---- Integral: trapezoid rule over last 10 points ---- I = 0.0 pts = list(err_hist)[-10:] for j in range(1, len(pts)): (t0_p, e0_p), (t1_p, e1_p) = pts[j-1], pts[j] dt = max(1e-6, t1_p - t0_p) I += 0.5 * (e0_p + e1_p) * dt # ---- Derivative: average of last two finite differences ---- D = 0.0 if len(err_hist) >= 3: (t2, e2), (t1, e1), (t0_h, e0_h) = err_hist[-1], err_hist[-2], err_hist[-3] d1 = (e2 - e1) / max(1e-6, t2 - t1) d0 = (e1 - e0_h) / max(1e-6, t1 - t0_h) D = 0.5 * (d1 + d0) elif len(err_hist) == 2: (t1, e1), (t0_h, e0_h) = err_hist[-1], err_hist[-2] D = (e1 - e0_h) / max(1e-6, t1 - t0_h) # ---- PID output ---- u = kp * e + ki * I + kd * D # ---- Hysteresis-gated dosing ---- need_dose = can_dose and (abs_err > epsilon) now = time.monotonic() if need_dose and (now - last_dose_ts) >= cooldown_s: direction = "base" if u > 0 else "acid" vol = min(max_ml, max(min_ml, abs(u) * scale_ml_per_unit)) last_dose_ts = now if direction == "acid": eff_rate = min(req_rate_ml_min, ACID_MAX_ML_MIN) acid_pump.run(eff_rate, vol, wait=True) dose_msg = f"ACID {vol:.4f} ml @ {eff_rate:.2f} ml/min" else: eff_rate = min(req_rate_ml_min, BASE_MAX_ML_MIN) base_pump.run(eff_rate, vol, wait=True) dose_msg = f"BASE {vol:.4f} ml @ {eff_rate:.2f} ml/min" log_lines.append(f" → Dose {dose_msg}") f.write(f"dispensing {dose_msg}\n") else: f.write("pH in range\n") # ---- Update plot ---- xs.append(t) ys.append(pH) # Trim to window while xs and xs[0] < t - WINDOW_SEC: xs.popleft() ys.popleft() ph_line.set_data(list(xs), list(ys)) if t < WINDOW_SEC: ax.set_xlim(0, WINDOW_SEC) else: ax.set_xlim(t - WINDOW_SEC, t) chart_handle.update(fig) log_handle.update(HTML("<pre style='background:#111;color:#ccc;padding:10px;border-radius:4px;font-size:12px;font-family:monospace;margin-top:6px'>" + "\n".join(log_lines) + "</pre>")) except KeyboardInterrupt: print("\nLoop stopped manually.") finally: print("\nControl loop complete.") fig.savefig(file_path + "pH_plot.png", dpi=150, bbox_inches='tight', facecolor='black') print(f"Graph saved to {file_path}pH_plot.png")
This version runs in the Daisy Controller Software’s Python notebook. For standalone Python, use the daisylab library.
Clean the tubing after the run
Flush each line. First remove the input tubing from the solution and run the pump to empty the line. Then place the tubing in clean water and run the pump to rinse. Finally remove it from the water and run the pump to clear the line with air. Each step uses the same call as priming, for example acid_pump.run(6.0, 1.5, wait=True).
Key takeaways
- Small doses, steady pHBounded drops, a deadband and a wait between readings keep the controller from overshooting a sensitive solution.
- A live view built inThe script draws a live plot and saves a log file and a plot image for every run, so you can watch the controller work and review the run afterwards.
- Reconfigurable hardwareThe same Process and Peri P modules go on to buffer preparation, titration and other fluid-handling tasks after the experiment.
Questions or want to try this with your solution? Email [email protected] to request a demo or a quote.
References
- Åström K.J. and Murray R.M. (2008), Feedback Systems: An Introduction for Scientists and Engineers, Chapter 10, PID Control. Princeton University Press, ISBN 978-0-691-13576-2. Free online via the Caltech library: https://authors.library.caltech.edu/records/yzs24-xsx88 (accessed 22 October 2026).
- Hamilton Company. Polilyte Plus Arc pH sensor product page. hamiltoncompany.com/sensors/ph-sensors/polilyte-plus/polilyte-plus-arc (accessed 22 October 2026).