electric-stimulation 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ - name: Install build dependencies
23
+ run: pip install hatch
24
+
25
+ - name: Build package
26
+ run: hatch build
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ skip-existing: true
@@ -0,0 +1,9 @@
1
+ *.json
2
+ *.exe
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ *.egg
8
+ __pycache__/
9
+ .spec
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Launcher for WaveGene - NI-DAQmx pulse generator.
4
+ """
5
+
6
+ import sys
7
+ from Electric_stimulation.wavegene_gui import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Electric Stimulation - NI-DAQmx pulse generator for electrical stimulation.
4
+
5
+ Provides:
6
+ - wavegene_backend: DAQWorker, build_channel_path for NI-DAQmx output
7
+ - wavegene_gui: WaveGeneWindow, main() for the PyQt5 GUI
8
+ """
9
+
10
+ from .wavegene_backend import (
11
+ DAQ_AVAILABLE,
12
+ DAQWorker,
13
+ build_channel_path,
14
+ )
15
+ from .wavegene_gui import WaveGeneWindow, main
16
+
17
+ __all__ = [
18
+ "DAQ_AVAILABLE",
19
+ "DAQWorker",
20
+ "WaveGeneWindow",
21
+ "build_channel_path",
22
+ "main",
23
+ ]
24
+ __version__ = "0.1.0"
@@ -0,0 +1,59 @@
1
+ """
2
+ Script to build a standalone executable for WaveGene.
3
+
4
+ Usage:
5
+ python build_exe.py
6
+ # or: python -m build_exe (from project root)
7
+
8
+ Prerequisites:
9
+ pip install -r requirements.txt pyinstaller
10
+
11
+ The executable will be generated in dist/ (in the current directory).
12
+ """
13
+
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ import tempfile
18
+
19
+ SCRIPT_DIR = Path(__file__).resolve().parent
20
+
21
+
22
+ def main():
23
+ output_dir = Path.cwd() / "dist"
24
+ exe_name = "WaveGene.exe" if sys.platform == "win32" else "WaveGene"
25
+ exe_path = output_dir / exe_name
26
+
27
+ if exe_path.exists():
28
+ try:
29
+ exe_path.unlink()
30
+ except PermissionError:
31
+ print("ERROR: The executable is locked (running or in use by another program).")
32
+ print("Close WaveGene and try again.")
33
+ sys.exit(1)
34
+
35
+ launcher = SCRIPT_DIR / "WaveGeneGUI.py"
36
+ with tempfile.TemporaryDirectory() as tmp:
37
+ cmd = [
38
+ sys.executable, "-m", "PyInstaller",
39
+ "--name=WaveGene",
40
+ "--windowed",
41
+ "--onefile",
42
+ "--clean",
43
+ "--distpath", str(output_dir),
44
+ "--specpath", tmp,
45
+ "--workpath", tmp,
46
+ "--hidden-import=PyQt5",
47
+ "--hidden-import=PyQt5.QtCore",
48
+ "--hidden-import=PyQt5.QtGui",
49
+ "--hidden-import=PyQt5.QtWidgets",
50
+ "--hidden-import=numpy",
51
+ "--hidden-import=PyDAQmx",
52
+ str(launcher.resolve()),
53
+ ]
54
+ subprocess.run(cmd, check=True, cwd=Path.cwd())
55
+ print(f"\n✓ Executable created: {exe_path}")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -0,0 +1,216 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Backend for NI-DAQmx pulse generation.
4
+
5
+ Contains:
6
+ - build_channel_path: build device/channel path
7
+ - DAQWorker: worker thread for DAQ output (infinite/finite, buffer, 0V on exit)
8
+ """
9
+
10
+ import time
11
+ from ctypes import byref, c_int32
12
+
13
+ import numpy as np
14
+ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot
15
+
16
+ # Optional: PyDAQmx for NI-DAQmx hardware access
17
+ try:
18
+ import PyDAQmx as nidaq
19
+ DAQ_AVAILABLE = True
20
+ except ImportError:
21
+ DAQ_AVAILABLE = False
22
+ nidaq = None
23
+
24
+
25
+ def build_channel_path(device_str, channel_str):
26
+ """
27
+ Build full channel path for NI-DAQmx.
28
+ Example: Dev2 + ao0 -> Dev2/ao0
29
+ """
30
+ dev = device_str.strip() or "Dev2"
31
+ ch = channel_str.strip() or "ao0"
32
+ return f"{dev}/{ch}" if ch else dev
33
+
34
+
35
+ class DAQWorker(QObject):
36
+ """
37
+ Worker for DAQ generation (runs in QThread).
38
+ Emits: started (when output begins), finished, error(str).
39
+ """
40
+ finished = pyqtSignal()
41
+ error = pyqtSignal(str)
42
+ started = pyqtSignal()
43
+
44
+ def __init__(self, device, sampling_rate, pulse_duration, inter_pulse_interval,
45
+ infinite, nb_pulses, buffer_duration=5.0):
46
+ super().__init__()
47
+ self.device = device
48
+ self.sampling_rate = sampling_rate
49
+ self.pulse_duration = pulse_duration
50
+ self.inter_pulse_interval = inter_pulse_interval
51
+ self.infinite = infinite
52
+ self.nb_pulses = nb_pulses
53
+ self.buffer_duration = buffer_duration
54
+ self._stop_requested = False
55
+
56
+ def stop(self):
57
+ """Request worker to stop (called from main thread)."""
58
+ self._stop_requested = True
59
+
60
+ @pyqtSlot()
61
+ def run(self):
62
+ """Main generation logic (runs in worker thread)."""
63
+ try:
64
+ if not DAQ_AVAILABLE:
65
+ self.error.emit("PyDAQmx is not installed.")
66
+ return
67
+
68
+ # Compute sample counts from durations
69
+ buffer_samples = int(self.buffer_duration * self.sampling_rate)
70
+ pulse_samples = int(self.pulse_duration * self.sampling_rate)
71
+ interval_samples = int(self.inter_pulse_interval * self.sampling_rate)
72
+ samples_per_cycle = pulse_samples + interval_samples
73
+
74
+ # Build one cycle: pulse at 2V, then interval at 0V
75
+ Sig_cycle = np.zeros(samples_per_cycle)
76
+ Sig_cycle[:pulse_samples] = 2.0
77
+
78
+ read = c_int32()
79
+ t = None
80
+ if self.infinite:
81
+ # Buffer once at start, then repeat (pulse+interval) cycle
82
+ if buffer_samples > 0:
83
+ # Phase 1: Output buffer (0V) once at start
84
+ self.started.emit()
85
+ t_buf = nidaq.Task()
86
+ t_buf.CreateAOVoltageChan(self.device, None, -10.0, 10.0,
87
+ nidaq.DAQmx_Val_Volts, None)
88
+ t_buf.CfgSampClkTiming("", self.sampling_rate, nidaq.DAQmx_Val_Rising,
89
+ nidaq.DAQmx_Val_FiniteSamps, buffer_samples)
90
+ t_buf.WriteAnalogF64(buffer_samples, False, 10,
91
+ nidaq.DAQmx_Val_GroupByScanNumber,
92
+ np.zeros(buffer_samples), byref(read), None)
93
+ t_buf.StartTask()
94
+ # Poll for completion or stop; WaitUntilTaskDone raises on timeout
95
+ buf_timeout = self.buffer_duration + 5
96
+ elapsed = 0
97
+ while not self._stop_requested and elapsed < buf_timeout:
98
+ try:
99
+ t_buf.WaitUntilTaskDone(1.0)
100
+ break # Task completed
101
+ except Exception:
102
+ elapsed += 1.0 # Timeout, keep polling
103
+ t_buf.StopTask()
104
+ t_buf.ClearTask()
105
+ if self._stop_requested:
106
+ t = None # User stopped during buffer, skip main task
107
+ else:
108
+ # Phase 2: Continuous pulse+interval cycle
109
+ t = nidaq.Task()
110
+ t.CreateAOVoltageChan(self.device, None, -10.0, 10.0,
111
+ nidaq.DAQmx_Val_Volts, None)
112
+ t.CfgSampClkTiming("", self.sampling_rate, nidaq.DAQmx_Val_Rising,
113
+ nidaq.DAQmx_Val_ContSamps, samples_per_cycle)
114
+ t.WriteAnalogF64(samples_per_cycle, False, 10,
115
+ nidaq.DAQmx_Val_GroupByScanNumber,
116
+ Sig_cycle, byref(read), None)
117
+ t.StartTask()
118
+ else:
119
+ # No buffer: start continuous cycle directly
120
+ t = nidaq.Task()
121
+ t.CreateAOVoltageChan(self.device, None, -10.0, 10.0,
122
+ nidaq.DAQmx_Val_Volts, None)
123
+ t.CfgSampClkTiming("", self.sampling_rate, nidaq.DAQmx_Val_Rising,
124
+ nidaq.DAQmx_Val_ContSamps, samples_per_cycle)
125
+ t.WriteAnalogF64(samples_per_cycle, False, 10,
126
+ nidaq.DAQmx_Val_GroupByScanNumber,
127
+ Sig_cycle, byref(read), None)
128
+ t.StartTask()
129
+ else:
130
+ # Finite mode: buffer + nb_pulses × (pulse + interval)
131
+ data = np.concatenate([
132
+ np.zeros(buffer_samples),
133
+ np.tile(Sig_cycle, self.nb_pulses)
134
+ ])
135
+ nb_samples = len(data)
136
+ t = nidaq.Task()
137
+ t.CreateAOVoltageChan(self.device, None, -10.0, 10.0,
138
+ nidaq.DAQmx_Val_Volts, None)
139
+ t.CfgSampClkTiming("", self.sampling_rate, nidaq.DAQmx_Val_Rising,
140
+ nidaq.DAQmx_Val_FiniteSamps, nb_samples)
141
+ t.WriteAnalogF64(nb_samples, False, 10,
142
+ nidaq.DAQmx_Val_GroupByScanNumber,
143
+ data, byref(read), None)
144
+ t.StartTask()
145
+
146
+ # Emit started for GUI timer (buffer case already emitted)
147
+ if self.infinite and buffer_samples == 0:
148
+ self.started.emit()
149
+ elif not self.infinite:
150
+ self.started.emit()
151
+
152
+ # Run main task until stop or completion
153
+ if t is not None:
154
+ if self.infinite:
155
+ while not self._stop_requested:
156
+ time.sleep(0.1) # Poll for stop request
157
+ else:
158
+ timeout = self.buffer_duration + self.nb_pulses * (self.inter_pulse_interval + self.pulse_duration) + 10
159
+ elapsed = 0
160
+ while not self._stop_requested and elapsed < timeout:
161
+ try:
162
+ t.WaitUntilTaskDone(1.0)
163
+ break # Task completed
164
+ except Exception:
165
+ elapsed += 1.0 # Timeout, keep polling
166
+
167
+ t.StopTask()
168
+ t.ClearTask()
169
+
170
+ except Exception as e:
171
+ self.error.emit(str(e))
172
+ finally:
173
+ # Safety: always set output to 0V on exit (stop, completion, or error)
174
+ if DAQ_AVAILABLE:
175
+ # First, stop and release any running task that might hold the channel
176
+ try:
177
+ if t is not None:
178
+ try:
179
+ t.StopTask()
180
+ except Exception:
181
+ pass
182
+ try:
183
+ t.ClearTask()
184
+ except Exception:
185
+ pass
186
+ except NameError:
187
+ pass
188
+ time.sleep(0.05) # Allow hardware to release the channel
189
+ t_zero = None
190
+ try:
191
+ t_zero = nidaq.Task()
192
+ t_zero.CreateAOVoltageChan(self.device, None, -10.0, 10.0,
193
+ nidaq.DAQmx_Val_Volts, None)
194
+ if hasattr(t_zero, 'WriteAnalogScalarF64'):
195
+ t_zero.StartTask()
196
+ t_zero.WriteAnalogScalarF64(1, 10.0, 0.0, None)
197
+ else:
198
+ read_zero = c_int32()
199
+ t_zero.CfgSampClkTiming("", 1000, nidaq.DAQmx_Val_Rising,
200
+ nidaq.DAQmx_Val_FiniteSamps, 1)
201
+ t_zero.WriteAnalogF64(1, True, 10, nidaq.DAQmx_Val_GroupByScanNumber,
202
+ np.array([0.0], dtype=np.float64), byref(read_zero), None)
203
+ try:
204
+ t_zero.WaitUntilTaskDone(10.0)
205
+ except Exception:
206
+ pass
207
+ except Exception:
208
+ pass
209
+ finally:
210
+ if t_zero is not None:
211
+ try:
212
+ t_zero.StopTask()
213
+ t_zero.ClearTask()
214
+ except Exception:
215
+ pass
216
+ self.finished.emit()
@@ -0,0 +1,429 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ GUI for WaveGene pulse generator.
4
+
5
+ Displays parameters, state indicator, and controls.
6
+ Uses wavegene_backend for DAQ logic.
7
+ """
8
+
9
+ import json
10
+ import sys
11
+ import time
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from PyQt5.QtCore import Qt, QThread, QTimer
16
+ from PyQt5.QtGui import QFont
17
+ from PyQt5.QtWidgets import (
18
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
19
+ QGroupBox, QFormLayout, QSpinBox, QDoubleSpinBox, QCheckBox,
20
+ QPushButton, QLabel, QMessageBox, QLineEdit, QFrame, QFileDialog
21
+ )
22
+ try:
23
+ from .wavegene_backend import build_channel_path, DAQWorker, DAQ_AVAILABLE
24
+ except ImportError:
25
+ from wavegene_backend import build_channel_path, DAQWorker, DAQ_AVAILABLE
26
+
27
+
28
+ def _app_dir():
29
+ """Return app directory (next to exe when frozen, else script dir)."""
30
+ if getattr(sys, "frozen", False):
31
+ return Path(sys.executable).parent
32
+ return Path(__file__).parent
33
+
34
+
35
+ class WaveGeneWindow(QMainWindow):
36
+ """Main application window for pulse generation control."""
37
+
38
+ def __init__(self):
39
+ super().__init__()
40
+ self.worker = None
41
+ self.worker_thread = None
42
+ self.state_timer = None
43
+ self.state_start_time = None
44
+ self.worker_params = None
45
+ self.init_ui()
46
+ self.load_params_from_json(silent=True)
47
+
48
+ def init_ui(self):
49
+ """Build the main window layout and widgets."""
50
+ self.setWindowTitle("WaveGene - NI-DAQmx Pulse Generator")
51
+ self.setMinimumWidth(400)
52
+
53
+ central = QWidget()
54
+ self.setCentralWidget(central)
55
+ layout = QVBoxLayout(central)
56
+
57
+ # --- Load Parameters (top left) ---
58
+ top_bar = QHBoxLayout()
59
+ self.load_btn = QPushButton("Load Parameters")
60
+ self.load_btn.setMinimumHeight(32)
61
+ self.load_btn.clicked.connect(self.load_params_from_json)
62
+ top_bar.addWidget(self.load_btn)
63
+ top_bar.addStretch()
64
+ layout.addLayout(top_bar)
65
+
66
+ # --- Parameters group ---
67
+ params_group = QGroupBox("Parameters")
68
+ params_layout = QFormLayout(params_group)
69
+
70
+ # Device and channel (e.g. Dev2, ao0)
71
+ self.device_edit = QLineEdit()
72
+ self.device_edit.setPlaceholderText("Dev1, Dev2, ...")
73
+ self.device_edit.setText("Dev2")
74
+ params_layout.addRow("Device NI-DAQmx:", self.device_edit)
75
+
76
+ self.channel_edit = QLineEdit()
77
+ self.channel_edit.setPlaceholderText("ao0, ao1, ...")
78
+ self.channel_edit.setText("ao0")
79
+ params_layout.addRow("Channel:", self.channel_edit)
80
+
81
+ # Sampling rate in Hz (output update frequency)
82
+ self.sampling_rate_spin = QDoubleSpinBox()
83
+ self.sampling_rate_spin.setRange(100, 100000)
84
+ self.sampling_rate_spin.setValue(1000)
85
+ self.sampling_rate_spin.setSuffix(" Hz")
86
+ self.sampling_rate_spin.setDecimals(0)
87
+ params_layout.addRow("Sampling rate:", self.sampling_rate_spin)
88
+
89
+ # Pulse duration at 2V (seconds)
90
+ self.pulse_duration_spin = QDoubleSpinBox()
91
+ self.pulse_duration_spin.setRange(0.001, 60)
92
+ self.pulse_duration_spin.setValue(0.2)
93
+ self.pulse_duration_spin.setSuffix(" s")
94
+ self.pulse_duration_spin.setDecimals(3)
95
+ params_layout.addRow("Pulse duration (2V):", self.pulse_duration_spin)
96
+
97
+ # Time at 0V between pulses (seconds)
98
+ self.inter_pulse_spin = QDoubleSpinBox()
99
+ self.inter_pulse_spin.setRange(0, 3600)
100
+ self.inter_pulse_spin.setValue(20)
101
+ self.inter_pulse_spin.setSuffix(" s")
102
+ self.inter_pulse_spin.setDecimals(1)
103
+ params_layout.addRow("Inter-pulse interval:", self.inter_pulse_spin)
104
+
105
+ # Initial 0V period before first pulse (seconds)
106
+ self.buffer_spin = QDoubleSpinBox()
107
+ self.buffer_spin.setRange(0, 300)
108
+ self.buffer_spin.setValue(5)
109
+ self.buffer_spin.setSuffix(" s")
110
+ self.buffer_spin.setDecimals(1)
111
+ self.buffer_spin.setToolTip("Time at 0V before the first pulse")
112
+ params_layout.addRow("Duration before first impulse:", self.buffer_spin)
113
+
114
+ # Infinite: repeat forever; else use nb_pulses
115
+ self.infinite_check = QCheckBox("Repeat indefinitely")
116
+ self.infinite_check.setChecked(True)
117
+ self.infinite_check.toggled.connect(self.on_infinite_toggled)
118
+ params_layout.addRow("", self.infinite_check)
119
+
120
+ self.nb_pulses_spin = QSpinBox()
121
+ self.nb_pulses_spin.setRange(1, 10000)
122
+ self.nb_pulses_spin.setValue(5)
123
+ self.nb_pulses_spin.setEnabled(False)
124
+ params_layout.addRow("Number of pulses:", self.nb_pulses_spin)
125
+
126
+ layout.addWidget(params_group)
127
+
128
+ # --- Action buttons ---
129
+ btn_layout = QHBoxLayout()
130
+ self.start_btn = QPushButton("Start")
131
+ self.start_btn.setMinimumHeight(40)
132
+ self.start_btn.setFont(QFont("", 11, QFont.Bold))
133
+ self.start_btn.clicked.connect(self.start_generation)
134
+ self.start_btn.setStyleSheet("background-color: #4CAF50; color: white;")
135
+
136
+ self.stop_btn = QPushButton("Stop")
137
+ self.stop_btn.setMinimumHeight(40)
138
+ self.stop_btn.setFont(QFont("", 11, QFont.Bold))
139
+ self.stop_btn.clicked.connect(self.stop_generation)
140
+ self.stop_btn.setEnabled(False)
141
+ self.stop_btn.setStyleSheet("background-color: #f44336; color: white;")
142
+
143
+ btn_layout.addWidget(self.start_btn)
144
+ btn_layout.addWidget(self.stop_btn)
145
+ layout.addLayout(btn_layout)
146
+
147
+ # --- State indicator: shows current phase (Duration before first impulse, Pulse, 0V) + countdown ---
148
+ self.state_frame = QFrame()
149
+ self.state_frame.setFrameStyle(QFrame.StyledPanel)
150
+ self.state_frame.setMinimumHeight(60)
151
+ self.state_frame.setStyleSheet("""
152
+ QFrame { background-color: #e0e0e0; border-radius: 8px; }
153
+ """)
154
+ state_layout = QVBoxLayout(self.state_frame)
155
+ self.state_label = QLabel("—")
156
+ self.state_label.setAlignment(Qt.AlignCenter)
157
+ self.state_label.setFont(QFont("", 18, QFont.Bold))
158
+ self.state_label.setStyleSheet("color: #666;")
159
+ state_layout.addWidget(self.state_label)
160
+ self.time_label = QLabel("—")
161
+ self.time_label.setAlignment(Qt.AlignCenter)
162
+ self.time_label.setFont(QFont("", 14))
163
+ self.time_label.setStyleSheet("color: #666;")
164
+ state_layout.addWidget(self.time_label)
165
+ layout.addWidget(self.state_frame)
166
+
167
+ # --- Total elapsed time (subtle display) ---
168
+ self.time_total_frame = QFrame()
169
+ self.time_total_frame.setFrameStyle(QFrame.NoFrame)
170
+ self.time_total_frame.setMaximumHeight(32)
171
+ self.time_total_frame.setStyleSheet("QFrame { background-color: transparent; }")
172
+ time_total_layout = QHBoxLayout(self.time_total_frame)
173
+ time_total_layout.setContentsMargins(4, 2, 4, 2)
174
+ time_total_label_txt = QLabel("Total time:")
175
+ time_total_label_txt.setStyleSheet("color: #999; font-size: 11px;")
176
+ time_total_layout.addWidget(time_total_label_txt)
177
+ self.time_total_label = QLabel("0:00")
178
+ self.time_total_label.setFont(QFont("", 11))
179
+ self.time_total_label.setStyleSheet("color: #999;")
180
+ time_total_layout.addWidget(self.time_total_label)
181
+ time_total_layout.addStretch()
182
+ layout.addWidget(self.time_total_frame)
183
+
184
+ # Disable start if PyDAQmx not installed
185
+ if not DAQ_AVAILABLE:
186
+ self.start_btn.setEnabled(False)
187
+ self.start_btn.setToolTip("PyDAQmx is not installed")
188
+
189
+ def on_infinite_toggled(self, checked):
190
+ """Enable nb_pulses only when not in infinite mode."""
191
+ self.nb_pulses_spin.setEnabled(not checked)
192
+
193
+ def set_params_enabled(self, enabled):
194
+ """Enable or disable all parameter fields."""
195
+ self.device_edit.setEnabled(enabled)
196
+ self.channel_edit.setEnabled(enabled)
197
+ self.sampling_rate_spin.setEnabled(enabled)
198
+ self.pulse_duration_spin.setEnabled(enabled)
199
+ self.inter_pulse_spin.setEnabled(enabled)
200
+ self.buffer_spin.setEnabled(enabled)
201
+ self.infinite_check.setEnabled(enabled)
202
+ self.nb_pulses_spin.setEnabled(enabled and not self.infinite_check.isChecked())
203
+ self.load_btn.setEnabled(enabled)
204
+
205
+ def start_generation(self):
206
+ """Start DAQ generation in a worker thread."""
207
+ if self.worker_thread and self.worker_thread.isRunning():
208
+ return
209
+
210
+ # Gather parameters from UI
211
+ device = build_channel_path(self.device_edit.text(), self.channel_edit.text())
212
+ sampling_rate = self.sampling_rate_spin.value()
213
+ pulse_duration = self.pulse_duration_spin.value()
214
+ inter_pulse = self.inter_pulse_spin.value()
215
+ buffer_duration = self.buffer_spin.value()
216
+ infinite = self.infinite_check.isChecked()
217
+ nb_pulses = self.nb_pulses_spin.value()
218
+
219
+ self.start_btn.setEnabled(False)
220
+ self.stop_btn.setEnabled(True)
221
+ self.set_params_enabled(False)
222
+
223
+ # Create worker and thread
224
+ self.worker = DAQWorker(device, sampling_rate, pulse_duration, inter_pulse,
225
+ infinite, nb_pulses, buffer_duration)
226
+ self.worker_thread = QThread()
227
+ self.worker.moveToThread(self.worker_thread)
228
+
229
+ self.worker_thread.started.connect(self.worker.run)
230
+ self.worker.started.connect(self.on_generation_started)
231
+ self.worker.finished.connect(self.on_generation_finished)
232
+ self.worker.error.connect(self.on_generation_error)
233
+
234
+ # Store params for state indicator and JSON save
235
+ self.worker_params = {
236
+ "device": self.device_edit.text().strip(),
237
+ "channel": self.channel_edit.text().strip(),
238
+ "sampling_rate": sampling_rate,
239
+ "buffer": buffer_duration,
240
+ "pulse": pulse_duration,
241
+ "interval": inter_pulse,
242
+ "infinite": infinite,
243
+ "nb_pulses": nb_pulses,
244
+ }
245
+ self.experiment_start_time = datetime.now()
246
+ self.worker_thread.start()
247
+
248
+ def stop_generation(self):
249
+ """Request worker to stop generation."""
250
+ if self.worker:
251
+ self.worker.stop()
252
+
253
+ def on_generation_started(self):
254
+ """Start timer to display real-time state (called when DAQ output begins)."""
255
+ self.state_start_time = time.time()
256
+ self.state_timer = QTimer(self)
257
+ self.state_timer.timeout.connect(self.update_state_indicator)
258
+ self.state_timer.start(100)
259
+
260
+ def format_elapsed(self, seconds):
261
+ """Format elapsed time as m:ss.cc."""
262
+ m = int(seconds // 60)
263
+ s = seconds % 60
264
+ return f"{m}:{s:05.2f}"
265
+
266
+ def format_countdown(self, seconds):
267
+ """Format countdown in seconds."""
268
+ if seconds <= 0:
269
+ return "0.00 s"
270
+ return f"{seconds:.2f} s remaining"
271
+
272
+ def update_state_indicator(self):
273
+ """Update indicator based on current signal phase (called every 100ms)."""
274
+ if self.state_start_time is None or self.worker_params is None:
275
+ return
276
+ elapsed = time.time() - self.state_start_time
277
+ self.time_total_label.setText(self.format_elapsed(elapsed))
278
+ p = self.worker_params
279
+ buf, pulse, interval = p["buffer"], p["pulse"], p["interval"]
280
+ cycle_duration = pulse + interval
281
+
282
+ # Phase 1: Duration before first impulse (0V)
283
+ if elapsed < buf:
284
+ text, color, bg = "(0V)", "#666", "#e0e0e0"
285
+ remaining = buf - elapsed
286
+ countdown = self.format_countdown(remaining)
287
+ else:
288
+ # Phase 2: Pulse or interval
289
+ cycle_time = elapsed - buf
290
+ if p["infinite"]:
291
+ pos_in_cycle = cycle_time % cycle_duration
292
+ else:
293
+ total_cycles = p["nb_pulses"] * cycle_duration
294
+ if cycle_time >= total_cycles:
295
+ # All pulses done
296
+ text, color, bg = "Done", "#666", "#e0e0e0"
297
+ self.state_label.setText(text)
298
+ self.state_label.setStyleSheet(f"color: {color};")
299
+ self.state_frame.setStyleSheet(f"QFrame {{ background-color: {bg}; border-radius: 8px; }}")
300
+ self.time_label.setText("—")
301
+ return
302
+ pos_in_cycle = cycle_time % cycle_duration
303
+
304
+ if pos_in_cycle < pulse:
305
+ # In pulse phase
306
+ text, color, bg = "Pulse (2V)", "#1b5e20", "#c8e6c9"
307
+ remaining = pulse - pos_in_cycle
308
+ else:
309
+ # In interval phase
310
+ text, color, bg = "0V (interval)", "#37474f", "#eceff1"
311
+ remaining = cycle_duration - pos_in_cycle
312
+ countdown = self.format_countdown(remaining)
313
+
314
+ self.state_label.setText(text)
315
+ self.state_label.setStyleSheet(f"color: {color};")
316
+ self.state_frame.setStyleSheet(f"QFrame {{ background-color: {bg}; border-radius: 8px; }}")
317
+ self.time_label.setText(countdown)
318
+
319
+ def save_params_to_json(self, duration_seconds):
320
+ """Save parameters and experiment info to a JSON file (on generation stop)."""
321
+ if self.worker_params is None:
322
+ return
323
+ p = self.worker_params
324
+ exp_time = getattr(self, "experiment_start_time", datetime.now())
325
+ data = {
326
+ "device": p.get("device", "Dev2"),
327
+ "channel": p.get("channel", "ao0"),
328
+ "sampling_rate": p.get("sampling_rate", 1000),
329
+ "pulse_duration": p.get("pulse", 0.2),
330
+ "inter_pulse_interval": p.get("interval", 20),
331
+ "buffer_duration": p.get("buffer", 5),
332
+ "infinite": p.get("infinite", True),
333
+ "nb_pulses": p.get("nb_pulses", 5),
334
+ "duree_secondes": round(duration_seconds, 2),
335
+ "heure_debut": exp_time.strftime("%Y-%m-%d %H:%M:%S"),
336
+ "heure_fin": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
337
+ }
338
+ save_dir = _app_dir() / "experiences"
339
+ save_dir.mkdir(exist_ok=True)
340
+ filename = exp_time.strftime("wavegene_%Y-%m-%d_%H-%M-%S.json")
341
+ filepath = save_dir / filename
342
+ with open(filepath, "w", encoding="utf-8") as f:
343
+ json.dump(data, f, indent=2, ensure_ascii=False)
344
+
345
+ def load_params_from_json(self, silent=False):
346
+ """Load parameters from a JSON file.
347
+ If silent=True (e.g. at startup), loads the most recent file without dialog.
348
+ If silent=False (button click), opens a file dialog to choose the file."""
349
+ save_dir = _app_dir() / "experiences"
350
+ if not save_dir.exists():
351
+ if not silent:
352
+ QMessageBox.warning(self, "Load", "No experiments folder found.")
353
+ return
354
+ json_files = list(save_dir.glob("wavegene_*.json"))
355
+ if not json_files:
356
+ if not silent:
357
+ QMessageBox.warning(self, "Load", "No experiment file found.")
358
+ return
359
+
360
+ if silent:
361
+ path = max(json_files, key=lambda p: p.stat().st_mtime)
362
+ else:
363
+ path_str, _ = QFileDialog.getOpenFileName(
364
+ self, "Load Parameters",
365
+ str(save_dir),
366
+ "JSON files (*.json);;All files (*)"
367
+ )
368
+ if not path_str:
369
+ return
370
+ path = Path(path_str)
371
+
372
+ try:
373
+ with open(path, "r", encoding="utf-8") as f:
374
+ data = json.load(f)
375
+ self.device_edit.setText(data.get("device", "Dev2"))
376
+ self.channel_edit.setText(data.get("channel", "ao0"))
377
+ self.sampling_rate_spin.setValue(data.get("sampling_rate", 1000))
378
+ self.pulse_duration_spin.setValue(data.get("pulse_duration", 0.2))
379
+ self.inter_pulse_spin.setValue(data.get("inter_pulse_interval", 20))
380
+ self.buffer_spin.setValue(data.get("buffer_duration", 5))
381
+ self.infinite_check.setChecked(data.get("infinite", True))
382
+ self.nb_pulses_spin.setValue(data.get("nb_pulses", 5))
383
+ if not silent:
384
+ QMessageBox.information(self, "Load", f"Parameters loaded from {path.name}")
385
+ except Exception as e:
386
+ if not silent:
387
+ QMessageBox.critical(self, "Error", f"Failed to load file:\n{e}")
388
+
389
+ def on_generation_finished(self):
390
+ """Clean up when generation stops (user stop or completion)."""
391
+ if self.state_timer:
392
+ self.state_timer.stop()
393
+ self.state_timer = None
394
+ duration = 0
395
+ if self.state_start_time is not None:
396
+ duration = time.time() - self.state_start_time
397
+ self.save_params_to_json(duration)
398
+ self.state_start_time = None
399
+ self.state_label.setText("—")
400
+ self.time_label.setText("—")
401
+ self.time_total_label.setText("0:00")
402
+ self.state_label.setStyleSheet("color: #666;")
403
+ self.time_label.setStyleSheet("color: #666;")
404
+ self.state_frame.setStyleSheet("QFrame { background-color: #e0e0e0; border-radius: 8px; }")
405
+ if self.worker_thread:
406
+ self.worker_thread.quit()
407
+ self.worker_thread.wait(2000)
408
+ self.start_btn.setEnabled(True)
409
+ self.stop_btn.setEnabled(False)
410
+ self.set_params_enabled(True)
411
+
412
+ def on_generation_error(self, msg):
413
+ """Handle DAQ or worker error: cleanup and show message."""
414
+ self.on_generation_finished()
415
+ QMessageBox.critical(self, "Error", msg)
416
+
417
+
418
+ def main():
419
+ """Application entry point."""
420
+ app = QApplication([])
421
+ app.setStyle("Fusion")
422
+ window = WaveGeneWindow()
423
+ window.show()
424
+ return app.exec_()
425
+
426
+
427
+ if __name__ == "__main__":
428
+ import sys
429
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wireless Neural Interface Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: electric-stimulation
3
+ Version: 0.1.2
4
+ Summary: NI-DAQmx pulse generator for electrical stimulation with PyQt5 GUI
5
+ Project-URL: Homepage, https://github.com/WNIlabs/Electric-stimulation
6
+ Project-URL: Repository, https://github.com/WNIlabs/Electric-stimulation
7
+ Author: Wireless Neural Interface Team
8
+ Author-email: Pierre He <pierre.he@inserm.fr>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: DAQ,NI-DAQmx,PyQt5,electrical-stimulation,pulse-generator
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Requires-Python: >=3.8
23
+ Requires-Dist: numpy>=1.20
24
+ Requires-Dist: pydaqmx>=1.4
25
+ Requires-Dist: pyqt5>=5.15
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Electric stimulation
29
+
30
+ Manual GUI launcher <br>
31
+ Backend <br>
32
+ Frontend <br>
33
+ Build for standalone EXE File <br>
34
+
35
+ To build the standelone software, download all files, install libraries and run build_exe.
@@ -0,0 +1,8 @@
1
+ # Electric stimulation
2
+
3
+ Manual GUI launcher <br>
4
+ Backend <br>
5
+ Frontend <br>
6
+ Build for standalone EXE File <br>
7
+
8
+ To build the standelone software, download all files, install libraries and run build_exe.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "electric-stimulation"
7
+ version = "0.1.2"
8
+ description = "NI-DAQmx pulse generator for electrical stimulation with PyQt5 GUI"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.8"
12
+ authors = [
13
+ {name = "Wireless Neural Interface Team"},
14
+ {name = "Pierre He", email = "pierre.he@inserm.fr"},
15
+ ]
16
+ keywords = ["DAQ", "NI-DAQmx", "electrical-stimulation", "pulse-generator", "PyQt5"]
17
+ classifiers = [
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Scientific/Engineering",
28
+ ]
29
+ dependencies = [
30
+ "numpy>=1.20",
31
+ "PyDAQmx>=1.4",
32
+ "PyQt5>=5.15",
33
+ ]
34
+
35
+ [project.scripts]
36
+ wavegene = "Electric_stimulation.wavegene_gui:main"
37
+ wavegene-build = "Electric_stimulation.build_exe:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/WNIlabs/Electric-stimulation"
41
+ Repository = "https://github.com/WNIlabs/Electric-stimulation"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["Electric_stimulation"]
@@ -0,0 +1,3 @@
1
+ numpy>=1.20
2
+ PyDAQmx>=1.4
3
+ PyQt5>=5.15