From 6f61578a2c47e0259e6d0d3025825ee97c83519b Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Mon, 19 Sep 2022 22:59:15 -0700 Subject: [PATCH] feature: mouse hover when paused updates shematic state --- README.md | 2 +- neurodemo/main_window.py | 91 ++++++++++++++++++++++++++++++---------- neurodemo/neuronsim.py | 17 +++++++- neurodemo/runner.py | 28 +++---------- 4 files changed, 92 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 943d76f..ac9d503 100755 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Requirements * Python 3.10 or higher * NumPy, SciPy -* PyQt6 +* PyQt5 or 6 * PyQtGraph * lmfit diff --git a/neurodemo/main_window.py b/neurodemo/main_window.py index 13e9164..e9d1aa5 100644 --- a/neurodemo/main_window.py +++ b/neurodemo/main_window.py @@ -66,10 +66,12 @@ def __init__(self, multiprocessing=False): else: print(sys.platform, "running with mp") self.ndemo = self.proc._import('neurodemo') - + + self.scrolling_plot_duration = 1.0 * NU.s + self.result_buffer = ResultBuffer(max_duration=self.scrolling_plot_duration) + self.dt = 20e-6 * NU.s self.integrator = 'solve_ivp' - self.scrolling_plot_duration = 1.0 * NU.s self.sim = self.ndemo.Sim(temp=6.3, dt=self.dt) if self.proc is not None: self.sim._setProxyOptions(deferGetattr=True) # only if using remote process @@ -92,15 +94,14 @@ def __init__(self, multiprocessing=False): mechanisms = [self.clamp, self.hhna, self.leak, self.hhk, self.dexh, self.lgna, self.lgkf, self.lgks] # loop to run the simulation indefinitely - self.running = False self.runner = self.ndemo.SimRunner(self.sim) self.runner.set_speed(0.2) - self.runner.add_request('t') + # if using remote process (only on Windows): if self.proc is not None: self.runner.new_result.connect(mp.proxy(self.new_result, autoProxy=False, callSync='off')) else: # Darwin (macOS) and Linux: - self.runner.new_result.connect(self.new_result,) + self.runner.new_result.connect(self.new_result) # set up GUI QtGui.QWidget.__init__(self) @@ -199,17 +200,14 @@ def __init__(self, multiprocessing=False): #self.fullscreen_shortcut.setContext().Qt.ShortcutContext(QtCore.Qt.ApplicationShortcut) self.show() - def params_changed(self, root, changes): for param, change, val in changes: path = self.params.childPath(param) if path[0] == "Run/Stop": - if self.running is True: + if self.running() is True: self.stop() - self.running = False else: self.start() - self.running = True if change != 'value': continue @@ -298,10 +296,14 @@ def add_plot(self, key, pname, name): plt.enableAutoRange(y=True) else: plt.setYRange(*yrange) - + + # Add a vertical line + plt.hover_line = pg.InfiniteLine(pos=0, angle=90, movable=False) + plt.addItem(plt.hover_line, ignoreBounds=True) + plt.hover_line.setVisible(False) + # register this plot for later.. self.channel_plots[key] = plt - self.runner.add_request(key) # add new plot to splitter and resize all accordingly sizes = self.plot_splitter.sizes() @@ -313,26 +315,54 @@ def add_plot(self, key, pname, name): # Ask sequence plotter to update as well self.clamp_param.add_plot(key, label) + + # Track mouse over plot + plt.plotItem.scene().sigMouseHover.connect(self.mouse_moved_over_plot) + return plt def remove_plot(self, key): plt = self.channel_plots.pop(key) - self.runner.remove_request(key) self.clamp_param.remove_plot(key) + plt.plotItem.scene().sigMouseHover.disconnect(self.mouse_moved_over_plot) plt.setParent(None) plt.close() + def mouse_moved_over_plot(self, items): + # only process hover events while paused + if self.running(): + return + item = items[0] + widget = item.getViewWidget() + globalPos = pg.QtGui.QCursor.pos() + localPos = widget.mapFromGlobal(globalPos) + scenePos = item.mapFromDevice(localPos) + viewPos = item.vb.mapSceneToView(scenePos) + self.set_hover_time(viewPos.x()) + + def set_hover_time(self, t): + """Move vertical lines to time *t* and update the schematic accordingly. + """ + for plt in self.channel_plots.values(): + plt.hover_line.setVisible(True) + plt.hover_line.setPos(t) + state = self.result_buffer.get_state_at_time(t) + if state is not None: + self.neuronview.update_state(state) + + def running(self): + return self.runner.running() + def start(self): self.runner.start(blocksize=2048) - # set button color + for plt in self.channel_plots.values(): + plt.hover_line.setVisible(False) def stop(self): self.runner.stop() - # reset button color def reset_dt(self, val): - - was_running = self.running + was_running = self.running() if was_running: self.stop() self.dt = val @@ -345,7 +375,7 @@ def reset_dt(self, val): self.set_scrolling_plot_dt(self.dt) def set_scrolling_plot_dt(self, val): - was_running = self.running + was_running = self.running() if was_running: self.stop() for k in self.channel_plots.keys(): @@ -382,7 +412,7 @@ def fullscreen(self): self.plot_splitter.insertWidget(self.fs_widget_index, self.fullscreen_widget) self.fullscreen_widget = None - def new_result(self, final_state, result): + def new_result(self, result): for k, plt in self.channel_plots.items(): if k not in result: continue @@ -396,7 +426,10 @@ def new_result(self, final_state, result): self.clamp_param.new_result(result) # update the schematic - self.neuronview.update_state(final_state) + self.neuronview.update_state(result.get_final_state()) + + # store a running buffer of results + self.result_buffer.add(result) def _get_Eh(self): ENa = self.params.child('Ions', 'Na') @@ -453,7 +486,6 @@ def use_default_erev(self): self.set_hh_erev(ENa_revs["INa"], EK_revs["IK"], -55*NU.mV, Eh_revs["IH"]) self.set_lg_erev(ENa_revs["INa1"], EK_revs["IKf"], EK_revs["IKs"], -55*NU.mV, ) - def set_hh_erev(self, ENa_erev=50*NU.mV, EK_erev=-74*NU.mV, Eleak_erev=-55*NU.mV, Eh_erev=-43*NU.mV): """Set new reversal potentials for the HH currents @@ -588,7 +620,6 @@ def set_duration(self, dur): def append(self, data): # print("len data, len self.data: ", len(data), len(self.data)) - #self.data = np.append(self.data, data) self.data = np.concatenate((self.data, data), axis=0) if len(self.data) >= self.npts: self.data = self.data[-self.npts:] @@ -597,3 +628,21 @@ def append(self, data): # print("appending npts: ", len(self.data), self.npts, self.dt, self.plot_duration) self.data_curve.setData(t, self.data) + +class ResultBuffer: + def __init__(self, max_duration=10): + self.max_duration = max_duration + self.results = [] + + def add(self, result): + self.results.append(result) + + def get_state_at_time(self, t): + if len(self.results) == 0: + return None + if t < 0: + t = self.results[-1]['t'][-1] + t + for result in self.results: + if result['t'][0] <= t <= result['t'][-1]: + return result.get_state_at_time(t) + return None diff --git a/neurodemo/neuronsim.py b/neurodemo/neuronsim.py index 0a02aa5..a347e55 100755 --- a/neurodemo/neuronsim.py +++ b/neurodemo/neuronsim.py @@ -215,6 +215,12 @@ def __getitem__(self, key): else: return self.extra[key] + def __contains__(self, key): + # allow lookup by (object, var) + if isinstance(key, tuple): + key = key[0].name + "." + key[1] + return key in self.indexes or key in self.dep_vars or key in self.extra + def __str__(self): rep = "SimState:\n" for i, k in enumerate(self.difeq_vars): @@ -225,6 +231,13 @@ def get_final_state(self): """Return a dictionary of all diff. eq. state variables and dependent variables for all objects in the simulation. """ + return self.get_state_at_index(-1) + + def get_state_at_time(self, t): + index = np.searchsorted(self['t'], t) + return self.get_state_at_index(index) + + def get_state_at_index(self, index): state = {} s = self.copy() clip = not np.isscalar(self["t"]) @@ -232,7 +245,7 @@ def get_final_state(self): # only get results for the last timepoint # print('state: ', self.state) # if self.integrator == 'odeint': - s.set_state(self.state[:, -1]) + s.set_state(self.state[:, index]) # else: # s.set_state(self.state[:, -1]) @@ -242,7 +255,7 @@ def get_final_state(self): state[k] = s[k] for k, v in self.extra.items(): if clip: - state[k] = v[-1] + state[k] = v[index] else: state[k] = v diff --git a/neurodemo/runner.py b/neurodemo/runner.py index 13d97fc..22d1a4d 100755 --- a/neurodemo/runner.py +++ b/neurodemo/runner.py @@ -8,13 +8,9 @@ class SimRunner(QtCore.QObject): - """Run a simulation continuously and emit signals whenever results are ready. - - Results are emitted with a dictionary containing all state variables for the - final timepoint in the simulation, as well as the complete time-record for - any variables that have been requested using add_request(). + """Run a simulation continuously and emit signals whenever results are ready. """ - new_result = QtCore.Signal(object, object) # final_state, requested_records + new_result = QtCore.Signal(object) def __init__(self, sim): QtCore.QObject.__init__(self) @@ -29,17 +25,10 @@ def __init__(self, sim): self.sim = sim self.speed = 1.0 - self.requests = [] self.timer = QtCore.QTimer() self.timer.timeout.connect(self.run_once) self.counter = 0 - def add_request(self, key): - self.requests.append(key) - - def remove_request(self, key): - self.requests.remove(key) - def start(self, blocksize=500, **kwds): self.starttime = def_timer() self.blocksize = blocksize @@ -49,19 +38,14 @@ def start(self, blocksize=500, **kwds): def stop(self): self.timer.stop() + def running(self): + return self.timer.isActive() + def run_once(self): self.counter += 1 blocksize = int(max(2, self.blocksize * self.speed)) result = self.sim.run(blocksize, **self.run_args) - rec = {} - for key in self.requests: - try: - data = result[key] - except KeyError: - continue - rec[key] = data - state = result.get_final_state() - self.new_result.emit(state, rec) + self.new_result.emit(result) def set_speed(self, speed): self.speed = speed