py-neuromodulation 0.0.7__py3-none-any.whl → 0.1.1__py3-none-any.whl

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.
Files changed (55) hide show
  1. py_neuromodulation/ConnectivityDecoding/_get_grid_whole_brain.py +0 -1
  2. py_neuromodulation/ConnectivityDecoding/_helper_write_connectome.py +0 -2
  3. py_neuromodulation/__init__.py +12 -4
  4. py_neuromodulation/analysis/RMAP.py +3 -3
  5. py_neuromodulation/analysis/decode.py +55 -2
  6. py_neuromodulation/analysis/feature_reader.py +1 -0
  7. py_neuromodulation/analysis/stats.py +3 -3
  8. py_neuromodulation/default_settings.yaml +25 -20
  9. py_neuromodulation/features/bandpower.py +65 -23
  10. py_neuromodulation/features/bursts.py +9 -8
  11. py_neuromodulation/features/coherence.py +7 -4
  12. py_neuromodulation/features/feature_processor.py +4 -4
  13. py_neuromodulation/features/fooof.py +7 -6
  14. py_neuromodulation/features/mne_connectivity.py +60 -87
  15. py_neuromodulation/features/oscillatory.py +5 -4
  16. py_neuromodulation/features/sharpwaves.py +21 -0
  17. py_neuromodulation/filter/kalman_filter.py +17 -6
  18. py_neuromodulation/gui/__init__.py +3 -0
  19. py_neuromodulation/gui/backend/app_backend.py +419 -0
  20. py_neuromodulation/gui/backend/app_manager.py +345 -0
  21. py_neuromodulation/gui/backend/app_pynm.py +253 -0
  22. py_neuromodulation/gui/backend/app_socket.py +97 -0
  23. py_neuromodulation/gui/backend/app_utils.py +306 -0
  24. py_neuromodulation/gui/backend/app_window.py +202 -0
  25. py_neuromodulation/gui/frontend/assets/Figtree-VariableFont_wght-CkXbWBDP.ttf +0 -0
  26. py_neuromodulation/gui/frontend/assets/index-_6V8ZfAS.js +300137 -0
  27. py_neuromodulation/gui/frontend/assets/plotly-DTCwMlpS.js +23594 -0
  28. py_neuromodulation/gui/frontend/charite.svg +16 -0
  29. py_neuromodulation/gui/frontend/index.html +14 -0
  30. py_neuromodulation/gui/window_api.py +115 -0
  31. py_neuromodulation/lsl_api.cfg +3 -0
  32. py_neuromodulation/processing/data_preprocessor.py +9 -2
  33. py_neuromodulation/processing/filter_preprocessing.py +43 -27
  34. py_neuromodulation/processing/normalization.py +32 -17
  35. py_neuromodulation/processing/projection.py +2 -2
  36. py_neuromodulation/processing/resample.py +6 -2
  37. py_neuromodulation/run_gui.py +36 -0
  38. py_neuromodulation/stream/__init__.py +7 -1
  39. py_neuromodulation/stream/backend_interface.py +47 -0
  40. py_neuromodulation/stream/data_processor.py +24 -3
  41. py_neuromodulation/stream/mnelsl_player.py +121 -21
  42. py_neuromodulation/stream/mnelsl_stream.py +9 -17
  43. py_neuromodulation/stream/settings.py +80 -34
  44. py_neuromodulation/stream/stream.py +83 -62
  45. py_neuromodulation/utils/channels.py +1 -1
  46. py_neuromodulation/utils/file_writer.py +110 -0
  47. py_neuromodulation/utils/io.py +46 -5
  48. py_neuromodulation/utils/perf.py +156 -0
  49. py_neuromodulation/utils/pydantic_extensions.py +322 -0
  50. py_neuromodulation/utils/types.py +33 -107
  51. {py_neuromodulation-0.0.7.dist-info → py_neuromodulation-0.1.1.dist-info}/METADATA +23 -4
  52. {py_neuromodulation-0.0.7.dist-info → py_neuromodulation-0.1.1.dist-info}/RECORD +55 -35
  53. {py_neuromodulation-0.0.7.dist-info → py_neuromodulation-0.1.1.dist-info}/WHEEL +1 -1
  54. py_neuromodulation-0.1.1.dist-info/entry_points.txt +2 -0
  55. {py_neuromodulation-0.0.7.dist-info → py_neuromodulation-0.1.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,306 @@
1
+ import multiprocessing as mp
2
+ import logging
3
+ from typing import Sequence
4
+ import sys
5
+ from pathlib import Path
6
+ from py_neuromodulation.utils.types import _PathLike
7
+ from functools import lru_cache
8
+ import platform
9
+ from py_neuromodulation import logger
10
+
11
+
12
+ def force_terminate_process(
13
+ process: mp.Process, name: str, logger: logging.Logger | None = None
14
+ ) -> None:
15
+ log = logger.debug if logger else print
16
+
17
+ import psutil
18
+
19
+ p = psutil.Process(process.pid)
20
+ try:
21
+ log(f"Terminating process {name}")
22
+ for child in p.children(recursive=True):
23
+ log(f"Terminating child process {child.pid}")
24
+ child.terminate()
25
+ p.terminate()
26
+ p.wait(timeout=3)
27
+ except psutil.NoSuchProcess:
28
+ log(f"Process {name} has already exited.")
29
+ except psutil.TimeoutExpired:
30
+ log(f"Forcefully killing {name}...")
31
+ p.kill()
32
+
33
+
34
+ def create_logger(name, color: str, level=logging.INFO):
35
+ """Function to set up a logger with color coded output"""
36
+ color = ansi_color(color=color, bright=True, styles=["BOLD"])
37
+ logger = logging.getLogger(name)
38
+ log_format = f"{color}[%(name)s %(levelname)s (%(asctime)s)]:{ansi_color(styles=['RESET'])} %(message)s"
39
+ stream_handler = logging.StreamHandler()
40
+ stream_handler.setFormatter(logging.Formatter(log_format, "%H:%M:%S"))
41
+ stream_handler.setStream(sys.stderr)
42
+ logger.setLevel(level)
43
+ logger.addHandler(stream_handler)
44
+
45
+ return logger
46
+
47
+
48
+ def ansi_color(
49
+ color: str = "DEFAULT",
50
+ bright: bool = True,
51
+ styles: Sequence[str] = [],
52
+ bg_color: str = "DEFAULT",
53
+ bg_bright: bool = True,
54
+ ) -> str:
55
+ """
56
+ Function to generate ANSI color codes for colored text in the terminal.
57
+ See https://en.wikipedia.org/wiki/ANSI_escape_code
58
+
59
+ Returns:
60
+ str: ANSI color code
61
+ """
62
+ ANSI_COLORS = {
63
+ # https://en.wikipedia.org/wiki/ANSI_escape_code
64
+ "BLACK": 30,
65
+ "RED": 31,
66
+ "GREEN": 32,
67
+ "YELLOW": 33,
68
+ "BLUE": 34,
69
+ "MAGENTA": 35,
70
+ "CYAN": 36,
71
+ "WHITE": 37,
72
+ "DEFAULT": 39,
73
+ }
74
+
75
+ ANSI_STYLES = {
76
+ "RESET": 0,
77
+ "BOLD": 1,
78
+ "FAINT": 2,
79
+ "ITALIC": 3,
80
+ "UNDERLINE": 4,
81
+ "BLINK": 5,
82
+ "NEGATIVE": 7,
83
+ "CROSSED": 9,
84
+ }
85
+
86
+ color = color.upper()
87
+ bg_color = bg_color.upper()
88
+ styles = [style.upper() for style in styles]
89
+
90
+ if color not in ANSI_COLORS.keys() or bg_color not in ANSI_COLORS.keys():
91
+ raise ValueError(f"Invalid color: {color}")
92
+
93
+ for style in styles:
94
+ if style not in ANSI_STYLES.keys():
95
+ raise ValueError(f"Invalid style: {style}")
96
+
97
+ color_code = str(ANSI_COLORS[color] + (60 if bright else 0))
98
+ bg_color_code = str(ANSI_COLORS[bg_color] + 10 + (60 if bg_bright else 0))
99
+ style_codes = ";".join((str(ANSI_STYLES[style]) for style in styles))
100
+
101
+ return f"\033[{style_codes};{color_code};{bg_color_code}m"
102
+
103
+
104
+ ansi_reset = ansi_color(styles=["RESET"])
105
+
106
+
107
+ def is_hidden(filepath: _PathLike) -> bool:
108
+ """Check if a file or directory is hidden.
109
+
110
+ Args:
111
+ filepath (str): Path to the file or directory.
112
+
113
+ Returns:
114
+ bool: True if the file or directory is hidden, False otherwise.
115
+ """
116
+ from pathlib import Path
117
+
118
+ filepath = Path(filepath)
119
+
120
+ if sys.platform.startswith("win"):
121
+ import ctypes
122
+
123
+ try:
124
+ attrs = ctypes.windll.kernel32.GetFileAttributesW(str(filepath))
125
+ assert attrs != -1
126
+ result = bool(attrs & 2) or filepath.name.startswith(".")
127
+ except (AttributeError, AssertionError):
128
+ result = filepath.name.startswith(".")
129
+ else:
130
+ result = filepath.name.startswith(".")
131
+
132
+ return result
133
+
134
+
135
+ @lru_cache(maxsize=1)
136
+ def get_quick_access():
137
+ system = platform.system()
138
+ if system == "Windows":
139
+ return get_windows_quick_access()
140
+ elif system == "Darwin": # macOS
141
+ return get_macos_quick_access()
142
+ else: # Linux, Unix, etc.
143
+ return {"items": []}
144
+
145
+
146
+ def get_windows_quick_access():
147
+ quick_access_items = []
148
+
149
+ # Add available drives
150
+ available_drives = [
151
+ f"{d}:\\" for d in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if Path(f"{d}:").exists()
152
+ ]
153
+ for drive in available_drives:
154
+ quick_access_items.append(
155
+ {"name": f"Drive ({drive})", "type": "drive", "path": drive}
156
+ )
157
+
158
+ # Get user's pinned folders
159
+ pinned_folders = get_pinned_folders_windows()
160
+ for folder in pinned_folders:
161
+ path = Path(folder["Path"])
162
+ if path.exists():
163
+ quick_access_items.append(
164
+ {"name": folder["Name"], "type": "folder", "path": str(path)}
165
+ )
166
+
167
+ # Get user's home directory
168
+ home_path = Path.home()
169
+
170
+ # Add common folders if they're not already in pinned folders
171
+ common_folders = [
172
+ ("Desktop", "Desktop"),
173
+ ("Documents", "Documents"),
174
+ ("Downloads", "Downloads"),
175
+ ("Pictures", "Pictures"),
176
+ ("Music", "Music"),
177
+ ("Videos", "Videos"),
178
+ ]
179
+
180
+ for folder_name, folder_path in common_folders:
181
+ full_path = home_path / folder_path
182
+ if full_path.exists() and str(full_path) not in [
183
+ item["path"] for item in quick_access_items
184
+ ]:
185
+ quick_access_items.append(
186
+ {"name": folder_name, "type": "folder", "path": str(full_path)}
187
+ )
188
+
189
+ # Add user's home directory if not already included
190
+ if str(home_path) not in [item["path"] for item in quick_access_items]:
191
+ quick_access_items.append(
192
+ {"name": "Home", "type": "folder", "path": str(home_path)}
193
+ )
194
+
195
+ return {"items": quick_access_items}
196
+
197
+
198
+ def get_pinned_folders_windows():
199
+ import subprocess
200
+ import json
201
+
202
+ powershell_command = """
203
+ $shell = New-Object -ComObject Shell.Application
204
+ $quickaccess = $shell.Namespace("shell:::{679f85cb-0220-4080-b29b-5540cc05aab6}").Items()
205
+ $pinned = $quickaccess | Where-Object { $_.IsFolder } | ForEach-Object {
206
+ [PSCustomObject]@{
207
+ Name = $_.Name
208
+ Path = $_.Path
209
+ }
210
+ }
211
+ $pinned | ConvertTo-Json
212
+ """
213
+
214
+ try:
215
+ result = subprocess.run(
216
+ ["powershell", "-Command", powershell_command],
217
+ capture_output=True,
218
+ text=True,
219
+ check=True,
220
+ )
221
+ return json.loads(result.stdout)
222
+ except subprocess.CalledProcessError as e:
223
+ logger.error(f"Error running PowerShell command: {e}")
224
+ return []
225
+ except json.JSONDecodeError as e:
226
+ logger.error(f"Error decoding JSON: {e}")
227
+ return []
228
+
229
+
230
+ def get_macos_quick_access():
231
+ quick_access_folders = get_macos_favorites()
232
+ quick_access_items = []
233
+
234
+ quick_access_items.append({"name": "Computer", "type": "drive", "path": "/"})
235
+
236
+ # Add Volumes for macOS
237
+ volumes_path = Path("/Volumes")
238
+ if volumes_path.exists():
239
+ for volume in volumes_path.iterdir():
240
+ if volume.is_mount():
241
+ quick_access_items.append(
242
+ {"name": volume.name, "type": "drive", "path": str(volume)}
243
+ )
244
+
245
+ # Add quick access folders
246
+ for folder in quick_access_folders:
247
+ path = Path(folder["Path"])
248
+ if path.exists():
249
+ quick_access_items.append(
250
+ {"name": folder["Name"], "type": "folder", "path": str(path)}
251
+ )
252
+
253
+ # Add user's home directory if not already included
254
+ home_path = str(Path.home())
255
+ if home_path not in [item["path"] for item in quick_access_items]:
256
+ quick_access_items.append({"name": "Home", "type": "folder", "path": home_path})
257
+
258
+ return {"items": quick_access_items}
259
+
260
+
261
+ def get_macos_favorites():
262
+ import subprocess
263
+ import json
264
+
265
+ favorites = []
266
+
267
+ try:
268
+ # Common locations in macOS
269
+ common_locations = [
270
+ ("Desktop", Path.home() / "Desktop"),
271
+ ("Documents", Path.home() / "Documents"),
272
+ ("Downloads", Path.home() / "Downloads"),
273
+ ("Pictures", Path.home() / "Pictures"),
274
+ ("Music", Path.home() / "Music"),
275
+ ("Movies", Path.home() / "Movies"),
276
+ ]
277
+
278
+ for name, path in common_locations:
279
+ if path.exists():
280
+ favorites.append({"Name": name, "Path": str(path)})
281
+
282
+ # Get user-defined favorites from sidebar plist
283
+ plist_path = (
284
+ Path.home()
285
+ / "Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.FavoriteItems.sfl2"
286
+ )
287
+ if plist_path.exists():
288
+ try:
289
+ result = subprocess.run(
290
+ ["plutil", "-convert", "json", "-o", "-", str(plist_path)],
291
+ capture_output=True,
292
+ text=True,
293
+ check=True,
294
+ )
295
+ plist_data = json.loads(result.stdout)
296
+ for item in plist_data.get("Bookmark", []):
297
+ if "Name" in item and "URL" in item:
298
+ path = item["URL"].replace("file://", "")
299
+ favorites.append({"Name": item["Name"], "Path": path})
300
+ except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
301
+ logger.error(f"Error processing macOS favorites: {e}")
302
+
303
+ except Exception as e:
304
+ logger.error(f"Error getting macOS favorites: {e}")
305
+
306
+ return favorites
@@ -0,0 +1,202 @@
1
+ import threading
2
+ import time
3
+ import logging
4
+ import requests
5
+
6
+ from .app_utils import ansi_color, ansi_reset
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ import webview
12
+
13
+ DEV = True
14
+
15
+ VITE_URL = "http://localhost:54321"
16
+ FASTAPI_URL = "http://localhost:50001"
17
+ APP_URL = VITE_URL if DEV else FASTAPI_URL
18
+
19
+ USER_AGENT = "PyNmWebView"
20
+
21
+
22
+ class WebViewWindow:
23
+ def __init__(self, debug: bool = False) -> None:
24
+ import webview
25
+
26
+ self.debug = debug
27
+ self.api = WebViewWindowApi()
28
+
29
+ self.window = webview.create_window(
30
+ title="PyNeuromodulation GUI",
31
+ url=APP_URL,
32
+ min_size=(1200, 800),
33
+ frameless=True,
34
+ resizable=True,
35
+ easy_drag=False,
36
+ js_api=self.api,
37
+ )
38
+
39
+ self.api.register_window(self.window)
40
+ # Customize PyWebView logging format
41
+ color = ansi_color(color="CYAN", styles=["BOLD"])
42
+ logger = logging.getLogger("pywebview")
43
+ formatter = logging.Formatter(
44
+ f"{color}[PyWebView %(levelname)s (%(asctime)s)]:{ansi_reset} %(message)s",
45
+ datefmt="%H:%M:%S",
46
+ )
47
+ logger.handlers[0].setFormatter(formatter)
48
+
49
+ def start(self):
50
+ import webview
51
+
52
+ # Set timer to load SPA after a delay
53
+ if DEV:
54
+ self.wait_for_vite_server()
55
+
56
+ webview.start(debug=self.debug, user_agent=USER_AGENT)
57
+
58
+ def wait_for_vite_server(self):
59
+ while True:
60
+ if self.is_vite_server_running():
61
+ break
62
+ time.sleep(0.1) # Wait for 1 second before checking again
63
+
64
+ def is_vite_server_running(self):
65
+ try:
66
+ response = requests.get(VITE_URL, timeout=1)
67
+ return response.status_code == 200
68
+ except requests.RequestException:
69
+ return False
70
+
71
+ # Register event handlers
72
+ def register_event_handler(self, event_type, handler):
73
+ # https://pywebview.flowrl.com/guide/api.html#window-events
74
+ match event_type:
75
+ case "closed":
76
+ self.window.events.closed += handler
77
+ case "closing":
78
+ self.window.events.closing += handler
79
+ case "loaded":
80
+ self.window.events.loaded += handler
81
+ case "minimized":
82
+ self.window.events.minimized += handler
83
+ case "maximized":
84
+ self.window.events.maximized += handler
85
+ case "resized":
86
+ self.window.events.resized += handler
87
+ case "restore":
88
+ self.window.events.restore += handler
89
+ case "shown":
90
+ self.window.events.shown += handler
91
+
92
+
93
+ # API class implementing all the methods available in the PyWebView Window object
94
+ # API Reference: https://pywebview.flowrl.com/guide/api.html#webview-window
95
+ class WebViewWindowApi:
96
+ def __init__(self):
97
+ self._window: "webview.Window"
98
+ self.is_resizing = False
99
+ self.start_x = 0
100
+ self.start_y = 0
101
+ self.start_width = 0
102
+ self.start_height = 0
103
+
104
+ # Function to store the reference to the PyWevView window
105
+ def register_window(self, window: "webview.Window"):
106
+ self._window = window
107
+
108
+ # Functions to handle window resizing
109
+ def start_resize(self, start_x, start_y):
110
+ self.is_resizing = True
111
+ self.start_x = start_x
112
+ self.start_y = start_y
113
+ self.start_width, self.start_height = self.get_size()
114
+ threading.Thread(target=self._resize_loop).start()
115
+
116
+ def stop_resize(self):
117
+ self.is_resizing = False
118
+
119
+ def update_resize(self, current_x, current_y):
120
+ if self.is_resizing:
121
+ dx = current_x - self.start_x
122
+ dy = current_y - self.start_y
123
+ new_width = max(self.start_width + dx, 200) # Minimum width
124
+ new_height = max(self.start_height + dy, 200) # Minimum height
125
+ self.set_size(int(new_width), int(new_height))
126
+
127
+ def _resize_loop(self):
128
+ while self.is_resizing:
129
+ time.sleep(0.01) # Small delay to prevent excessive CPU usage
130
+
131
+ # All API methods from the PyWebView docs
132
+ def close_window(self):
133
+ self._window.destroy()
134
+
135
+ def maximize_window(self):
136
+ self._window.maximize()
137
+
138
+ def minimize_window(self):
139
+ self._window.minimize()
140
+
141
+ def restore_window(self):
142
+ self._window.restore()
143
+
144
+ def toggle_fullscreen(self):
145
+ self._window.toggle_fullscreen()
146
+
147
+ def set_title(self, title: str):
148
+ self._window.title = title
149
+
150
+ def get_position(self):
151
+ return (self._window.x, self._window.y)
152
+
153
+ def set_position(self, x: int, y: int):
154
+ self._window.move(x, y)
155
+
156
+ def get_size(self):
157
+ return (self._window.width, self._window.height)
158
+
159
+ def set_size(self, width: int, height: int):
160
+ self._window.resize(width, height)
161
+
162
+ def set_on_top(self, on_top: bool):
163
+ self._window.on_top = on_top
164
+
165
+ def show(self):
166
+ self._window.show()
167
+
168
+ def hide(self):
169
+ self._window.hide()
170
+
171
+ def create_file_dialog(
172
+ self,
173
+ dialog_type: int = 10, # webview.OPEN_DIALOG,
174
+ directory="",
175
+ allow_multiple=False,
176
+ save_filename="",
177
+ file_types=(),
178
+ ):
179
+ return self._window.create_file_dialog(
180
+ dialog_type, directory, allow_multiple, save_filename, file_types
181
+ )
182
+
183
+ def create_confirmation_dialog(self, title, message):
184
+ return self._window.create_confirmation_dialog(title, message)
185
+
186
+ def load_url(self, url):
187
+ self._window.load_url(url)
188
+
189
+ def load_html(self, content, base_uri: str):
190
+ self._window.load_html(content, base_uri)
191
+
192
+ def load_css(self, css):
193
+ self._window.load_css(css)
194
+
195
+ def evaluate_js(self, script, callback=None):
196
+ return self._window.evaluate_js(script, callback)
197
+
198
+ def get_current_url(self):
199
+ return self._window.get_current_url()
200
+
201
+ def get_elements(self, selector):
202
+ return self._window.get_elements(selector)