From bef525c5478fbb3c94afe30e8747b21f07a20082 Mon Sep 17 00:00:00 2001 From: Martin Chase Date: Tue, 10 Feb 2026 16:06:22 -0800 Subject: [PATCH 1/4] stub: WholeCell can transition to new Clear state --- acq4/devices/PatchPipette/statemanager.py | 1 + acq4/devices/PatchPipette/states/__init__.py | 2 + acq4/devices/PatchPipette/states/clear.py | 33 ++++++++++++++++ .../devices/PatchPipette/states/whole_cell.py | 38 ++++++++++++++++++- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 acq4/devices/PatchPipette/states/clear.py diff --git a/acq4/devices/PatchPipette/statemanager.py b/acq4/devices/PatchPipette/statemanager.py index 0ba4475bdc..f846a3d3ec 100644 --- a/acq4/devices/PatchPipette/statemanager.py +++ b/acq4/devices/PatchPipette/statemanager.py @@ -38,6 +38,7 @@ class PatchPipetteStateManager(Qt.QObject): states.CellAttachedState, states.BreakInState, states.WholeCellState, + states.ClearState, states.ResealState, states.BlowoutState, states.BrokenState, diff --git a/acq4/devices/PatchPipette/states/__init__.py b/acq4/devices/PatchPipette/states/__init__.py index 4196e8538e..2a16541f56 100644 --- a/acq4/devices/PatchPipette/states/__init__.py +++ b/acq4/devices/PatchPipette/states/__init__.py @@ -7,6 +7,7 @@ from .cell_attached import CellAttachedState from .cell_detect import CellDetectAnalysis, CellDetectState from .clean import CleanState +from .clear import ClearState from .fouled import FouledState from .move_nucleus_to_home import MoveNucleusToHomeState from .nucleus_collect import NucleusCollectState @@ -32,6 +33,7 @@ 'BreakInState', 'ResealAnalysis', 'ResealState', + 'ClearState', 'MoveNucleusToHomeState', 'BlowoutState', 'CleanState', diff --git a/acq4/devices/PatchPipette/states/clear.py b/acq4/devices/PatchPipette/states/clear.py new file mode 100644 index 0000000000..59edec028a --- /dev/null +++ b/acq4/devices/PatchPipette/states/clear.py @@ -0,0 +1,33 @@ +from acq4.devices.PatchPipette.states import PatchPipetteState +from acq4.util import ptime + + +class ClearState(PatchPipetteState): + """A cross between Break In and Seal that scans pressures for possible recovery of a cell loss. + + Parameters + ---------- + """ + + stateName = 'clear' + + _parameterDefaultOverrides = { + 'initialPressureSource': 'atmosphere', + 'initialClampMode': 'VC', + 'initialVCHolding': -70e-3, + 'initialTestPulseEnable': True, + } + _parameterTreeConfig = {} + + def run(self): + start = ptime.time() + tps = [] + while start + 2 > ptime.time(): + tps.extend(self.processAtLeastOneTestPulse()) + baseline_rss = sum(tp['steady_state_resistance'] for tp in tps) / len(tps) + # Test both positive and negative pulses to see what is the effect on access resistance. E.g.: + # -500 Pa pulse for 2 seconds, reading test pulses during this time + # If effect was good + permanent, repeat + # If effect was good but went away after releasing pressure, try -1 kPa + # If effect was bad, try +500 Pa + # etc.. diff --git a/acq4/devices/PatchPipette/states/whole_cell.py b/acq4/devices/PatchPipette/states/whole_cell.py index 14bf5353f0..ad8a94d7d5 100644 --- a/acq4/devices/PatchPipette/states/whole_cell.py +++ b/acq4/devices/PatchPipette/states/whole_cell.py @@ -5,6 +5,19 @@ class WholeCellState(PatchPipetteState): + """State representing a successful break-in, with the pipette dialed in for whole-cell recording. + + Parameters + ---------- + cellLossResistanceThreshold : float + If the pipette resistance rises above baseline plus this threshold (Ω), transition to + `cellLossState` (default 20 MΩ). + cellLossState : str + Name of state to transition to if possible cell loss is detected (default 'clear'). + cellLossSustainedTime : float + Time (s) that resistance must be above threshold before transitioning to `cellLossState` + (default 2 s). + """ stateName = 'whole cell' _parameterDefaultOverrides = { 'initialPressureSource': 'atmosphere', @@ -14,6 +27,11 @@ class WholeCellState(PatchPipetteState): 'initialAutoBiasEnable': True, 'initialAutoBiasTarget': -70e-3, } + _parameterTreeConfig = { + 'cellLossResistanceThreshold': {'type': 'float', 'value': 20e6, 'suffix': 'Ω', 'siPrefix': True, 'step': 1e6}, + 'cellLossState': {'type': 'str', 'value': 'clear'}, + 'cellLossSustainedTime': {'type': 'float', 'value': 2.0, 'suffix': 's', 'step': 0.5}, + } def run(self): patchrec = self.dev.patchRecord() @@ -22,8 +40,26 @@ def run(self): # TODO: Option to switch to I=0 for a few seconds to get initial RMP decay + tps = [] + start = ptime.time() + while start + 5 > ptime.time(): + tps.extend(self.processAtLeastOneTestPulse()) + + baseline_rss = sum(tp['steady_state_resistance'] for tp in tps) / len(tps) + threshold = baseline_rss + self.config['cellLossResistanceThreshold'] + first_loss_time = None while True: - # TODO: monitor for cell loss + tps = self.processAtLeastOneTestPulse() + rss = tps[-1]['steady_state_resistance'] + if rss > threshold: + if first_loss_time is None: + first_loss_time = ptime.time() + elif first_loss_time + self.config['cellLossSustainedTime'] < ptime.time(): + self.setState(f"cell loss in progress (resistance rose to {rss / 1e6:.1f} MΩ)") + # TODO make sure when this lands in `main` we use the dict-style return + return "clear" + else: + first_loss_time = None self.sleep(0.1) def cleanup(self): From 24866e67b49d39377550150dd8808aee5a211955 Mon Sep 17 00:00:00 2001 From: Martin Chase Date: Thu, 12 Feb 2026 09:41:23 -0800 Subject: [PATCH 2/4] flesh out the Clear state --- acq4/devices/PatchPipette/states/clear.py | 69 ++++++++++++++++--- .../devices/PatchPipette/states/whole_cell.py | 24 +++---- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/acq4/devices/PatchPipette/states/clear.py b/acq4/devices/PatchPipette/states/clear.py index 59edec028a..83a801c834 100644 --- a/acq4/devices/PatchPipette/states/clear.py +++ b/acq4/devices/PatchPipette/states/clear.py @@ -1,3 +1,6 @@ +import numpy as np +import scipy + from acq4.devices.PatchPipette.states import PatchPipetteState from acq4.util import ptime @@ -7,6 +10,11 @@ class ClearState(PatchPipetteState): Parameters ---------- + recoveryTimeout : float + Time (s) to spend trying to recover from a cell loss before giving up (default 30 s). + recoveryResistanceThresholdAbsolute : float + Resistance (Ohms) below which to consider the cell loss successfully reversed and + transition to 'whole cell' state (default 10 MΩ). """ stateName = 'clear' @@ -16,18 +24,63 @@ class ClearState(PatchPipetteState): 'initialClampMode': 'VC', 'initialVCHolding': -70e-3, 'initialTestPulseEnable': True, + 'fallbackState': 'fouled', + } + _parameterTreeConfig = { + 'recoveryTimeout': {'type': 'float', 'default': 60.0, 'suffix': 's'}, + 'recoveryResistanceThresholdAbsolute': {'type': 'float', 'default': 10e6, 'suffix': 'Ω', 'siPrefix': True}, + 'recoverySustainedTime': {'type': 'float', 'default': 2.0, 'suffix': 's'}, } - _parameterTreeConfig = {} def run(self): + # TODO relative R_acc threshold? start = ptime.time() tps = [] while start + 2 > ptime.time(): tps.extend(self.processAtLeastOneTestPulse()) - baseline_rss = sum(tp['steady_state_resistance'] for tp in tps) / len(tps) - # Test both positive and negative pulses to see what is the effect on access resistance. E.g.: - # -500 Pa pulse for 2 seconds, reading test pulses during this time - # If effect was good + permanent, repeat - # If effect was good but went away after releasing pressure, try -1 kPa - # If effect was bad, try +500 Pa - # etc.. + best_r_acc = min(tp['access_resistance'] for tp in tps) + pressure = -500 # Pa + start = ptime.time() + sign_has_flipped = False + while start + self.config['recoveryTimeout'] > ptime.time(): + first_recovery_time = None + while ( + self.processAtLeastOneTestPulse()[-1].analysis['access_resistance'] + < self.config['recoveryResistanceThresholdAbsolute'] + ): + if first_recovery_time is None: + first_recovery_time = ptime.time() + elif first_recovery_time + self.config['recoverySustainedTime'] < ptime.time(): + self.setState("cell recovered") + # todo make sure when this lands in `main` we use the dict-style return + return "whole cell" + + pulse_start = ptime.time() + tps_during = [] + self.waitFor(self.dev.pressureDevice.setPressure(pressure, source='regulator')) + while pulse_start + 2 > ptime.time(): + tps_during.extend(self.processAtLeastOneTestPulse()) + self.dev.pressureDevice.setPressure(0, source='atmosphere') + r_acc_during = np.asarray([tp['access_resistance'] for tp in tps_during]) + time_during = np.asarray([tp['event_time'] for tp in tps_during]) + slope = scipy.stats.linregress(r_acc_during, time_during).slope + if slope < 0: + tps_after = [] + while pulse_start + 2 > ptime.time(): + tps_after.extend(self.processAtLeastOneTestPulse()) + r_acc_after = np.asarray([tp['access_resistance'] for tp in tps_after]) + if r_acc_after.mean() < best_r_acc: + best_r_acc = r_acc_after.mean() + # and repeat the same pressure + else: + # increase the pressure magnitude + pressure = np.sign(pressure) * (abs(pressure) + 500) + else: + if sign_has_flipped: + # increase pressure magnitude and flip sign + pressure = -1 * np.sign(pressure) * (abs(pressure) + 500) + else: + # first flip keeps magnitude the same + sign_has_flipped = True + pressure = -1 * pressure + return self.config['fallbackState'] diff --git a/acq4/devices/PatchPipette/states/whole_cell.py b/acq4/devices/PatchPipette/states/whole_cell.py index ad8a94d7d5..c648b785d3 100644 --- a/acq4/devices/PatchPipette/states/whole_cell.py +++ b/acq4/devices/PatchPipette/states/whole_cell.py @@ -9,8 +9,8 @@ class WholeCellState(PatchPipetteState): Parameters ---------- - cellLossResistanceThreshold : float - If the pipette resistance rises above baseline plus this threshold (Ω), transition to + cellLossResistanceThresholdAbsolute : float + If the pipette resistance (Ra) rises above this threshold (Ω), transition to `cellLossState` (default 20 MΩ). cellLossState : str Name of state to transition to if possible cell loss is detected (default 'clear'). @@ -28,7 +28,7 @@ class WholeCellState(PatchPipetteState): 'initialAutoBiasTarget': -70e-3, } _parameterTreeConfig = { - 'cellLossResistanceThreshold': {'type': 'float', 'value': 20e6, 'suffix': 'Ω', 'siPrefix': True, 'step': 1e6}, + 'cellLossResistanceThresholdAbsolute': {'type': 'float', 'value': 20e6, 'suffix': 'Ω', 'siPrefix': True, 'step': 1e6}, 'cellLossState': {'type': 'str', 'value': 'clear'}, 'cellLossSustainedTime': {'type': 'float', 'value': 2.0, 'suffix': 's', 'step': 0.5}, } @@ -40,22 +40,22 @@ def run(self): # TODO: Option to switch to I=0 for a few seconds to get initial RMP decay - tps = [] - start = ptime.time() - while start + 5 > ptime.time(): - tps.extend(self.processAtLeastOneTestPulse()) + # TODO relative R_acc threshold? + # tps = [] + # start = ptime.time() + # while start + 5 > ptime.time(): + # tps.extend(self.processAtLeastOneTestPulse()) - baseline_rss = sum(tp['steady_state_resistance'] for tp in tps) / len(tps) - threshold = baseline_rss + self.config['cellLossResistanceThreshold'] + threshold = self.config['cellLossResistanceThresholdAbsolute'] first_loss_time = None while True: tps = self.processAtLeastOneTestPulse() - rss = tps[-1]['steady_state_resistance'] - if rss > threshold: + r_access = tps[-1].analysis['access_resistance'] + if r_access > threshold: if first_loss_time is None: first_loss_time = ptime.time() elif first_loss_time + self.config['cellLossSustainedTime'] < ptime.time(): - self.setState(f"cell loss in progress (resistance rose to {rss / 1e6:.1f} MΩ)") + self.setState(f"cell loss in progress (R_acc rose to {r_access / 1e6:.1f} MΩ)") # TODO make sure when this lands in `main` we use the dict-style return return "clear" else: From 5460aae29b6df65c312ea27f82d196820358fda7 Mon Sep 17 00:00:00 2001 From: Martin Chase Date: Tue, 17 Feb 2026 15:55:42 -0800 Subject: [PATCH 3/4] more better docs --- acq4/devices/PatchPipette/states/clear.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/acq4/devices/PatchPipette/states/clear.py b/acq4/devices/PatchPipette/states/clear.py index 83a801c834..aaf69e4584 100644 --- a/acq4/devices/PatchPipette/states/clear.py +++ b/acq4/devices/PatchPipette/states/clear.py @@ -13,8 +13,11 @@ class ClearState(PatchPipetteState): recoveryTimeout : float Time (s) to spend trying to recover from a cell loss before giving up (default 30 s). recoveryResistanceThresholdAbsolute : float - Resistance (Ohms) below which to consider the cell loss successfully reversed and + Access resistance (Ohms) below which to consider the cell loss successfully reversed and transition to 'whole cell' state (default 10 MΩ). + recoverySustainedTime : float + Time (s) that resistance must be below the recovery threshold before considering the cell loss successfully + reversed and transitioning to 'whole cell' state (default 2 s). """ stateName = 'clear' From 18c4509f3c786bb44f94dc6e5401c629f83a58b3 Mon Sep 17 00:00:00 2001 From: Martin Chase Date: Tue, 17 Feb 2026 18:18:43 -0800 Subject: [PATCH 4/4] new function ptime.loop for simple timed looping --- acq4/util/ptime.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acq4/util/ptime.py b/acq4/util/ptime.py index 99769ccd11..51e197c754 100644 --- a/acq4/util/ptime.py +++ b/acq4/util/ptime.py @@ -31,3 +31,14 @@ def unixTime(): time = winTime else: time = unixTime + + +def loop(duration=None, end_time=None): + """Generator that yields the current time in a loop until the specified duration has elapsed or + end_time is reached.""" + if duration is None and end_time is None: + raise ValueError("Must specify either duration or end_time") + if end_time is None: + end_time = time() + duration + while (now := time()) < end_time: + yield now