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.
- electric_stimulation-0.1.2/.github/workflows/publish-pypi.yml +31 -0
- electric_stimulation-0.1.2/.gitignore +9 -0
- electric_stimulation-0.1.2/Electric_stimulation/WaveGeneGUI.py +10 -0
- electric_stimulation-0.1.2/Electric_stimulation/__init__.py +24 -0
- electric_stimulation-0.1.2/Electric_stimulation/build_exe.py +59 -0
- electric_stimulation-0.1.2/Electric_stimulation/wavegene_backend.py +216 -0
- electric_stimulation-0.1.2/Electric_stimulation/wavegene_gui.py +429 -0
- electric_stimulation-0.1.2/LICENSE +21 -0
- electric_stimulation-0.1.2/PKG-INFO +35 -0
- electric_stimulation-0.1.2/README.md +8 -0
- electric_stimulation-0.1.2/pyproject.toml +44 -0
- electric_stimulation-0.1.2/requirements.txt +3 -0
|
@@ -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,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,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"]
|