Aluode commited on
Commit
f4fda52
·
verified ·
1 Parent(s): c0881ca

Upload EEGCrystalMaker.py

Browse files
Files changed (1) hide show
  1. EEGCrystalMaker.py +802 -0
EEGCrystalMaker.py ADDED
@@ -0,0 +1,802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ EEG Crystal Maker
4
+ =================
5
+
6
+ Standalone GUI for growing neural crystal lattices from EEG data.
7
+
8
+ Features:
9
+ - Load any EDF file
10
+ - Set resolution (32x32 to 2048x2048)
11
+ - Watch crystallization in real-time
12
+ - Save crystal state + pin map
13
+ - Load and continue growing
14
+
15
+ The crystal lattice is a 2D Izhikevich neuron sheet with STDP plasticity.
16
+ EEG electrodes inject current at mapped positions (the "pins").
17
+ Over time, the coupling weights crystallize into a structure that
18
+ reflects the EEG's spatiotemporal patterns.
19
+
20
+ Output:
21
+ - .npz file containing:
22
+ - weights (4 directional coupling matrices)
23
+ - pin_coords (electrode positions on grid)
24
+ - pin_names (electrode labels)
25
+ - metadata (resolution, training steps, etc.)
26
+
27
+ Author: Built for Antti's consciousness crystallography research
28
+ """
29
+
30
+ import sys
31
+ import os
32
+ import re
33
+ import json
34
+ import numpy as np
35
+ from datetime import datetime
36
+
37
+ from PyQt6.QtWidgets import (
38
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
39
+ QPushButton, QLabel, QSpinBox, QDoubleSpinBox, QFileDialog,
40
+ QProgressBar, QGroupBox, QGridLayout, QComboBox, QCheckBox,
41
+ QSlider, QFrame, QMessageBox, QStatusBar
42
+ )
43
+ from PyQt6.QtCore import Qt, QTimer
44
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QFont
45
+
46
+ import cv2
47
+
48
+ try:
49
+ import mne
50
+ MNE_AVAILABLE = True
51
+ except ImportError:
52
+ MNE_AVAILABLE = False
53
+ print("Warning: MNE not installed. EEG loading will not work.")
54
+
55
+
56
+ class CrystalLattice:
57
+ """The neural crystal - Izhikevich sheet with STDP."""
58
+
59
+ def __init__(self, grid_size=64):
60
+ self.grid_size = grid_size
61
+ self.init_arrays()
62
+
63
+ # Izhikevich parameters
64
+ self.a = 0.02
65
+ self.b = 0.2
66
+ self.c = -65.0
67
+ self.d = 8.0
68
+ self.dt = 0.5
69
+
70
+ # STDP parameters
71
+ self.learning_rate = 0.005
72
+ self.trace_decay = 0.95
73
+ self.weight_max = 2.0
74
+ self.weight_min = 0.01
75
+
76
+ # Coupling strength - how much neighbors influence each other
77
+ self.coupling_strength = 5.0 # Higher = more spread
78
+
79
+ # Statistics
80
+ self.total_spikes = 0
81
+ self.learning_steps = 0
82
+
83
+ def init_arrays(self):
84
+ """Initialize all arrays to current grid_size."""
85
+ n = self.grid_size
86
+
87
+ # Neural state
88
+ self.v = np.ones((n, n), dtype=np.float32) * -65.0
89
+ self.u = self.v * 0.2
90
+
91
+ # Crystal weights (4 directions)
92
+ self.weights_up = np.ones((n, n), dtype=np.float32) * 0.5
93
+ self.weights_down = np.ones((n, n), dtype=np.float32) * 0.5
94
+ self.weights_left = np.ones((n, n), dtype=np.float32) * 0.5
95
+ self.weights_right = np.ones((n, n), dtype=np.float32) * 0.5
96
+
97
+ # Spike trace for STDP
98
+ self.spike_trace = np.zeros((n, n), dtype=np.float32)
99
+
100
+ def resize(self, new_size):
101
+ """Resize the lattice (resets state)."""
102
+ self.grid_size = new_size
103
+ self.init_arrays()
104
+ self.total_spikes = 0
105
+ self.learning_steps = 0
106
+
107
+ def step(self, input_current, learning=True):
108
+ """One simulation step with optional STDP learning."""
109
+ v = self.v
110
+ u = self.u
111
+ I = input_current
112
+
113
+ # Clamp input to prevent explosion
114
+ I = np.clip(I, -100, 100)
115
+
116
+ # Neighbor coupling
117
+ v_up = np.roll(v, -1, axis=0)
118
+ v_down = np.roll(v, 1, axis=0)
119
+ v_left = np.roll(v, -1, axis=1)
120
+ v_right = np.roll(v, 1, axis=1)
121
+
122
+ neighbor_influence = (
123
+ self.weights_up * v_up +
124
+ self.weights_down * v_down +
125
+ self.weights_left * v_left +
126
+ self.weights_right * v_right
127
+ )
128
+ total_weight = (self.weights_up + self.weights_down +
129
+ self.weights_left + self.weights_right)
130
+ neighbor_avg = neighbor_influence / (total_weight + 1e-6)
131
+
132
+ I_coupling = self.coupling_strength * (neighbor_avg - v)
133
+ I_coupling = np.clip(I_coupling, -50, 50) # Prevent coupling explosion
134
+
135
+ # Izhikevich dynamics
136
+ dv = (0.04 * v * v + 5.0 * v + 140.0 - u + I + I_coupling) * self.dt
137
+ du = self.a * (self.b * v - u) * self.dt
138
+
139
+ v = v + dv
140
+ u = u + du
141
+
142
+ # Clamp voltage to sane range (prevents NaN cascade)
143
+ v = np.clip(v, -100, 50)
144
+ u = np.clip(u, -50, 50)
145
+
146
+ # Handle any NaN that slipped through
147
+ v = np.nan_to_num(v, nan=self.c, posinf=30.0, neginf=-100.0)
148
+ u = np.nan_to_num(u, nan=self.c * self.b, posinf=20.0, neginf=-20.0)
149
+
150
+ # Spikes
151
+ spikes = v >= 30.0
152
+ v[spikes] = self.c
153
+ u[spikes] += self.d
154
+
155
+ self.v = v
156
+ self.u = u
157
+ self.total_spikes += np.sum(spikes)
158
+
159
+ # STDP
160
+ if learning and self.learning_rate > 0:
161
+ self.learning_steps += 1
162
+
163
+ self.spike_trace *= self.trace_decay
164
+ self.spike_trace[spikes] = 1.0
165
+
166
+ trace_up = np.roll(self.spike_trace, -1, axis=0)
167
+ trace_down = np.roll(self.spike_trace, 1, axis=0)
168
+ trace_left = np.roll(self.spike_trace, -1, axis=1)
169
+ trace_right = np.roll(self.spike_trace, 1, axis=1)
170
+
171
+ spike_float = spikes.astype(np.float32)
172
+ lr = self.learning_rate
173
+
174
+ # Potentiation
175
+ dw_up = lr * spike_float * trace_up
176
+ dw_down = lr * spike_float * trace_down
177
+ dw_left = lr * spike_float * trace_left
178
+ dw_right = lr * spike_float * trace_right
179
+
180
+ # Depression
181
+ spike_up = np.roll(spike_float, -1, axis=0)
182
+ spike_down = np.roll(spike_float, 1, axis=0)
183
+ spike_left = np.roll(spike_float, -1, axis=1)
184
+ spike_right = np.roll(spike_float, 1, axis=1)
185
+
186
+ dw_up -= 0.5 * lr * self.spike_trace * spike_up
187
+ dw_down -= 0.5 * lr * self.spike_trace * spike_down
188
+ dw_left -= 0.5 * lr * self.spike_trace * spike_left
189
+ dw_right -= 0.5 * lr * self.spike_trace * spike_right
190
+
191
+ self.weights_up = np.clip(self.weights_up + dw_up, self.weight_min, self.weight_max)
192
+ self.weights_down = np.clip(self.weights_down + dw_down, self.weight_min, self.weight_max)
193
+ self.weights_left = np.clip(self.weights_left + dw_left, self.weight_min, self.weight_max)
194
+ self.weights_right = np.clip(self.weights_right + dw_right, self.weight_min, self.weight_max)
195
+
196
+ return spikes
197
+
198
+ def get_energy(self):
199
+ """Total weight energy."""
200
+ return float(np.sum(self.weights_up) + np.sum(self.weights_down) +
201
+ np.sum(self.weights_left) + np.sum(self.weights_right))
202
+
203
+ def get_entropy(self):
204
+ """Weight distribution entropy."""
205
+ all_weights = np.concatenate([
206
+ self.weights_up.flatten(),
207
+ self.weights_down.flatten(),
208
+ self.weights_left.flatten(),
209
+ self.weights_right.flatten()
210
+ ])
211
+ w_norm = all_weights / (np.sum(all_weights) + 1e-9)
212
+ return float(-np.sum(w_norm * np.log(w_norm + 1e-9)))
213
+
214
+ def render_activity(self, size=256):
215
+ """Render activity as image."""
216
+ disp = np.clip(self.v, -90.0, 40.0)
217
+ disp = np.nan_to_num(disp, nan=-65.0, posinf=40.0, neginf=-90.0)
218
+ norm = ((disp + 90.0) / 130.0 * 255.0).astype(np.uint8)
219
+ heat = cv2.applyColorMap(norm, cv2.COLORMAP_INFERNO)
220
+ heat = cv2.resize(heat, (size, size), interpolation=cv2.INTER_NEAREST)
221
+ return cv2.cvtColor(heat, cv2.COLOR_BGR2RGB)
222
+
223
+ def render_crystal(self, size=256):
224
+ """Render crystal structure as image."""
225
+ horizontal = (self.weights_left + self.weights_right) / 2
226
+ vertical = (self.weights_up + self.weights_down) / 2
227
+
228
+ h_norm = (horizontal - self.weight_min) / (self.weight_max - self.weight_min)
229
+ v_norm = (vertical - self.weight_min) / (self.weight_max - self.weight_min)
230
+ anisotropy = np.abs(h_norm - v_norm)
231
+
232
+ img = np.zeros((self.grid_size, self.grid_size, 3), dtype=np.uint8)
233
+ img[:, :, 0] = (h_norm * 255).astype(np.uint8)
234
+ img[:, :, 1] = ((1 - anisotropy) * 255).astype(np.uint8)
235
+ img[:, :, 2] = (v_norm * 255).astype(np.uint8)
236
+
237
+ return cv2.resize(img, (size, size), interpolation=cv2.INTER_NEAREST)
238
+
239
+
240
+ class EEGSource:
241
+ """Handles EEG loading and electrode mapping."""
242
+
243
+ STANDARD_MAP = {
244
+ "FP1": (0.30, 0.10), "FP2": (0.70, 0.10),
245
+ "F7": (0.10, 0.30), "F3": (0.30, 0.30), "FZ": (0.50, 0.25),
246
+ "F4": (0.70, 0.30), "F8": (0.90, 0.30),
247
+ "T7": (0.10, 0.50), "T3": (0.10, 0.50), # T3 alias
248
+ "C3": (0.30, 0.50), "CZ": (0.50, 0.50),
249
+ "C4": (0.70, 0.50), "T8": (0.90, 0.50), "T4": (0.90, 0.50), # T4 alias
250
+ "P7": (0.10, 0.70), "T5": (0.10, 0.70), # T5 alias
251
+ "P3": (0.30, 0.70), "PZ": (0.50, 0.75),
252
+ "P4": (0.70, 0.70), "P8": (0.90, 0.70), "T6": (0.90, 0.70), # T6 alias
253
+ "O1": (0.35, 0.90), "OZ": (0.50, 0.90), "O2": (0.65, 0.90),
254
+ "A1": (0.05, 0.50), "A2": (0.95, 0.50), # Ear references
255
+ }
256
+
257
+ def __init__(self):
258
+ self.raw = None
259
+ self.data = None
260
+ self.sfreq = 256.0
261
+ self.ch_names = []
262
+ self.current_idx = 0
263
+ self.amplification = 1e9 # Default amplification (Medium)
264
+
265
+ # Pin mapping
266
+ self.pin_coords = [] # (row, col) for each channel
267
+ self.pin_names = [] # Channel names
268
+ self.pin_indices = [] # Channel indices in data
269
+
270
+ def load(self, filepath):
271
+ """Load EDF file."""
272
+ if not MNE_AVAILABLE:
273
+ raise RuntimeError("MNE not installed")
274
+
275
+ raw = mne.io.read_raw_edf(filepath, preload=True, verbose=False)
276
+
277
+ try:
278
+ raw.pick_types(eeg=True, meg=False, eog=False, ecg=False,
279
+ emg=False, misc=False, stim=False)
280
+ except:
281
+ pass
282
+
283
+ if raw.info["sfreq"] > 256:
284
+ raw.resample(256, npad="auto", verbose=False)
285
+
286
+ self.raw = raw
287
+ self.data = raw.get_data()
288
+ self.sfreq = float(raw.info["sfreq"])
289
+ self.ch_names = list(raw.ch_names)
290
+ self.current_idx = 0
291
+
292
+ return len(self.ch_names), self.data.shape[1]
293
+
294
+ def map_electrodes(self, grid_size):
295
+ """Map electrodes to grid positions."""
296
+ self.pin_coords = []
297
+ self.pin_names = []
298
+ self.pin_indices = []
299
+
300
+ for idx, name in enumerate(self.ch_names):
301
+ clean = re.sub(r'[^A-Z0-9]', '', name.upper())
302
+
303
+ pos = None
304
+ # Try exact match first
305
+ for std_name, std_pos in self.STANDARD_MAP.items():
306
+ if std_name in clean or clean in std_name:
307
+ pos = std_pos
308
+ break
309
+
310
+ # Try prefix match
311
+ if pos is None:
312
+ for std_name, std_pos in self.STANDARD_MAP.items():
313
+ if len(clean) >= 2 and clean[:2] == std_name[:2]:
314
+ pos = std_pos
315
+ break
316
+
317
+ if pos:
318
+ grid_r = int(pos[1] * (grid_size - 1))
319
+ grid_c = int(pos[0] * (grid_size - 1))
320
+ self.pin_coords.append((grid_r, grid_c))
321
+ self.pin_names.append(name)
322
+ self.pin_indices.append(idx)
323
+
324
+ return len(self.pin_coords)
325
+
326
+ def get_input_current(self, grid_size):
327
+ """Get input current for one timestep."""
328
+ if self.data is None:
329
+ return np.zeros((grid_size, grid_size), dtype=np.float32)
330
+
331
+ n_samples = self.data.shape[1]
332
+ sample_idx = self.current_idx % n_samples
333
+ self.current_idx += 1
334
+
335
+ I = np.zeros((grid_size, grid_size), dtype=np.float32)
336
+
337
+ # Small spread - electrodes are injection points
338
+ # The coupling between neurons spreads activity, not the electrode radius
339
+ spread_radius = max(2, grid_size // 128) # ~8 at 1024, ~2 at 256
340
+ spread_sigma = max(1.0, spread_radius / 2.0)
341
+
342
+ # Pre-compute Gaussian kernel once
343
+ kernel_size = spread_radius * 2 + 1
344
+ y, x = np.ogrid[-spread_radius:spread_radius+1, -spread_radius:spread_radius+1]
345
+ kernel = np.exp(-(x*x + y*y) / (2 * spread_sigma * spread_sigma)).astype(np.float32)
346
+
347
+ for i, ch_idx in enumerate(self.pin_indices):
348
+ if i < len(self.pin_coords):
349
+ r, c = self.pin_coords[i]
350
+ val = self.data[ch_idx, sample_idx]
351
+
352
+ # Scale EEG
353
+ scaled = float(val) * self.amplification
354
+ scaled = np.clip(scaled, -500, 500)
355
+
356
+ # Calculate bounds for kernel placement
357
+ r_start = max(0, r - spread_radius)
358
+ r_end = min(grid_size, r + spread_radius + 1)
359
+ c_start = max(0, c - spread_radius)
360
+ c_end = min(grid_size, c + spread_radius + 1)
361
+
362
+ # Corresponding kernel bounds
363
+ kr_start = r_start - (r - spread_radius)
364
+ kr_end = kernel_size - ((r + spread_radius + 1) - r_end)
365
+ kc_start = c_start - (c - spread_radius)
366
+ kc_end = kernel_size - ((c + spread_radius + 1) - c_end)
367
+
368
+ # Add weighted kernel to input
369
+ I[r_start:r_end, c_start:c_end] += scaled * kernel[kr_start:kr_end, kc_start:kc_end]
370
+
371
+ return I
372
+
373
+
374
+ class CrystalMakerWindow(QMainWindow):
375
+ """Main GUI window."""
376
+
377
+ def __init__(self):
378
+ super().__init__()
379
+ self.setWindowTitle("EEG Crystal Maker")
380
+ self.setMinimumSize(1000, 700)
381
+
382
+ # Core objects
383
+ self.crystal = CrystalLattice(64)
384
+ self.eeg = EEGSource()
385
+
386
+ # State
387
+ self.is_running = False
388
+ self.eeg_loaded = False
389
+ self.edf_path = ""
390
+
391
+ # Timer for simulation
392
+ self.timer = QTimer()
393
+ self.timer.timeout.connect(self.simulation_step)
394
+
395
+ self.setup_ui()
396
+ self.update_display()
397
+
398
+ def setup_ui(self):
399
+ """Build the UI."""
400
+ central = QWidget()
401
+ self.setCentralWidget(central)
402
+ layout = QHBoxLayout(central)
403
+
404
+ # Left panel - controls
405
+ left_panel = QVBoxLayout()
406
+ layout.addLayout(left_panel, stretch=1)
407
+
408
+ # EEG Loading
409
+ eeg_group = QGroupBox("EEG Source")
410
+ eeg_layout = QVBoxLayout(eeg_group)
411
+
412
+ self.edf_label = QLabel("No file loaded")
413
+ self.edf_label.setWordWrap(True)
414
+ eeg_layout.addWidget(self.edf_label)
415
+
416
+ load_btn = QPushButton("Load EDF File...")
417
+ load_btn.clicked.connect(self.load_edf)
418
+ eeg_layout.addWidget(load_btn)
419
+
420
+ self.eeg_info = QLabel("Channels: -\nSamples: -\nPins mapped: -")
421
+ eeg_layout.addWidget(self.eeg_info)
422
+
423
+ left_panel.addWidget(eeg_group)
424
+
425
+ # Crystal Settings
426
+ crystal_group = QGroupBox("Crystal Settings")
427
+ crystal_layout = QGridLayout(crystal_group)
428
+
429
+ crystal_layout.addWidget(QLabel("Resolution:"), 0, 0)
430
+ self.resolution_combo = QComboBox()
431
+ self.resolution_combo.addItems(["32", "64", "128", "256", "512", "1024"])
432
+ self.resolution_combo.setCurrentText("64")
433
+ self.resolution_combo.currentTextChanged.connect(self.on_resolution_changed)
434
+ crystal_layout.addWidget(self.resolution_combo, 0, 1)
435
+
436
+ crystal_layout.addWidget(QLabel("Learning Rate:"), 1, 0)
437
+ self.lr_spin = QDoubleSpinBox()
438
+ self.lr_spin.setRange(0.0001, 0.1)
439
+ self.lr_spin.setSingleStep(0.001)
440
+ self.lr_spin.setValue(0.005)
441
+ self.lr_spin.valueChanged.connect(self.on_lr_changed)
442
+ crystal_layout.addWidget(self.lr_spin, 1, 1)
443
+
444
+ crystal_layout.addWidget(QLabel("EEG Amplification:"), 2, 0)
445
+ self.amp_combo = QComboBox()
446
+ self.amp_combo.addItems(["1e8 (Low)", "1e9 (Medium)", "1e10 (High)", "1e11 (Very High)"])
447
+ self.amp_combo.setCurrentIndex(1) # Default to Medium
448
+ self.amp_combo.currentIndexChanged.connect(self.on_amp_changed)
449
+ crystal_layout.addWidget(self.amp_combo, 2, 1)
450
+
451
+ crystal_layout.addWidget(QLabel("Coupling Strength:"), 3, 0)
452
+ self.coupling_spin = QDoubleSpinBox()
453
+ self.coupling_spin.setRange(0.1, 20.0)
454
+ self.coupling_spin.setSingleStep(0.5)
455
+ self.coupling_spin.setValue(5.0)
456
+ self.coupling_spin.valueChanged.connect(self.on_coupling_changed)
457
+ crystal_layout.addWidget(self.coupling_spin, 3, 1)
458
+
459
+ crystal_layout.addWidget(QLabel("Target Steps:"), 4, 0)
460
+ self.target_steps_spin = QSpinBox()
461
+ self.target_steps_spin.setRange(100, 100000)
462
+ self.target_steps_spin.setSingleStep(100)
463
+ self.target_steps_spin.setValue(800)
464
+ crystal_layout.addWidget(self.target_steps_spin, 4, 1)
465
+
466
+ left_panel.addWidget(crystal_group)
467
+
468
+ # Simulation Control
469
+ control_group = QGroupBox("Simulation")
470
+ control_layout = QVBoxLayout(control_group)
471
+
472
+ btn_layout = QHBoxLayout()
473
+ self.start_btn = QPushButton("▶ Start")
474
+ self.start_btn.clicked.connect(self.toggle_simulation)
475
+ btn_layout.addWidget(self.start_btn)
476
+
477
+ self.reset_btn = QPushButton("↺ Reset")
478
+ self.reset_btn.clicked.connect(self.reset_crystal)
479
+ btn_layout.addWidget(self.reset_btn)
480
+ control_layout.addLayout(btn_layout)
481
+
482
+ # Speed slider
483
+ speed_layout = QHBoxLayout()
484
+ speed_layout.addWidget(QLabel("Speed:"))
485
+ self.speed_slider = QSlider(Qt.Orientation.Horizontal)
486
+ self.speed_slider.setRange(1, 100)
487
+ self.speed_slider.setValue(50)
488
+ self.speed_slider.valueChanged.connect(self.on_speed_changed)
489
+ speed_layout.addWidget(self.speed_slider)
490
+ control_layout.addLayout(speed_layout)
491
+
492
+ # Progress
493
+ self.progress_bar = QProgressBar()
494
+ self.progress_bar.setRange(0, 800)
495
+ control_layout.addWidget(self.progress_bar)
496
+
497
+ left_panel.addWidget(control_group)
498
+
499
+ # Statistics
500
+ stats_group = QGroupBox("Statistics")
501
+ stats_layout = QVBoxLayout(stats_group)
502
+
503
+ self.stats_label = QLabel("Steps: 0\nSpikes: 0\nEnergy: 0\nEntropy: 0")
504
+ self.stats_label.setFont(QFont("Monospace", 10))
505
+ stats_layout.addWidget(self.stats_label)
506
+
507
+ left_panel.addWidget(stats_group)
508
+
509
+ # Save/Load
510
+ file_group = QGroupBox("File Operations")
511
+ file_layout = QVBoxLayout(file_group)
512
+
513
+ save_btn = QPushButton("💾 Save Crystal...")
514
+ save_btn.clicked.connect(self.save_crystal)
515
+ file_layout.addWidget(save_btn)
516
+
517
+ load_crystal_btn = QPushButton("📂 Load Crystal...")
518
+ load_crystal_btn.clicked.connect(self.load_crystal)
519
+ file_layout.addWidget(load_crystal_btn)
520
+
521
+ left_panel.addWidget(file_group)
522
+
523
+ left_panel.addStretch()
524
+
525
+ # Right panel - visualization
526
+ right_panel = QVBoxLayout()
527
+ layout.addLayout(right_panel, stretch=2)
528
+
529
+ # Activity view
530
+ activity_group = QGroupBox("Neural Activity")
531
+ activity_layout = QVBoxLayout(activity_group)
532
+ self.activity_label = QLabel()
533
+ self.activity_label.setMinimumSize(400, 400)
534
+ self.activity_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
535
+ self.activity_label.setStyleSheet("background-color: #1a1a1a;")
536
+ activity_layout.addWidget(self.activity_label)
537
+ right_panel.addWidget(activity_group)
538
+
539
+ # Crystal view
540
+ crystal_view_group = QGroupBox("Crystal Structure")
541
+ crystal_view_layout = QVBoxLayout(crystal_view_group)
542
+ self.crystal_label = QLabel()
543
+ self.crystal_label.setMinimumSize(400, 400)
544
+ self.crystal_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
545
+ self.crystal_label.setStyleSheet("background-color: #1a1a1a;")
546
+ crystal_view_layout.addWidget(self.crystal_label)
547
+ right_panel.addWidget(crystal_view_group)
548
+
549
+ # Status bar
550
+ self.status_bar = QStatusBar()
551
+ self.setStatusBar(self.status_bar)
552
+ self.status_bar.showMessage("Ready - Load an EDF file to begin")
553
+
554
+ def load_edf(self):
555
+ """Load EDF file dialog."""
556
+ filepath, _ = QFileDialog.getOpenFileName(
557
+ self, "Open EDF File", "", "EDF Files (*.edf);;All Files (*)"
558
+ )
559
+ if filepath:
560
+ try:
561
+ n_channels, n_samples = self.eeg.load(filepath)
562
+ n_pins = self.eeg.map_electrodes(self.crystal.grid_size)
563
+
564
+ self.edf_path = filepath
565
+ self.eeg_loaded = True
566
+
567
+ fname = os.path.basename(filepath)
568
+ self.edf_label.setText(f"Loaded: {fname}")
569
+ self.eeg_info.setText(
570
+ f"Channels: {n_channels}\n"
571
+ f"Samples: {n_samples}\n"
572
+ f"Pins mapped: {n_pins}"
573
+ )
574
+ self.status_bar.showMessage(f"Loaded {fname} - {n_pins} electrodes mapped")
575
+
576
+ except Exception as e:
577
+ QMessageBox.critical(self, "Error", f"Failed to load EDF:\n{str(e)}")
578
+
579
+ def on_resolution_changed(self, text):
580
+ """Handle resolution change."""
581
+ new_size = int(text)
582
+ if new_size != self.crystal.grid_size:
583
+ self.crystal.resize(new_size)
584
+ if self.eeg_loaded:
585
+ n_pins = self.eeg.map_electrodes(new_size)
586
+ self.eeg_info.setText(
587
+ f"Channels: {len(self.eeg.ch_names)}\n"
588
+ f"Samples: {self.eeg.data.shape[1]}\n"
589
+ f"Pins mapped: {n_pins}"
590
+ )
591
+ self.update_display()
592
+ self.status_bar.showMessage(f"Resolution changed to {new_size}x{new_size}")
593
+
594
+ def on_lr_changed(self, value):
595
+ """Handle learning rate change."""
596
+ self.crystal.learning_rate = value
597
+
598
+ def on_amp_changed(self, index):
599
+ """Handle amplification change."""
600
+ amp_values = [1e8, 1e9, 1e10, 1e11]
601
+ self.eeg.amplification = amp_values[index]
602
+ self.status_bar.showMessage(f"Amplification set to {amp_values[index]:.0e}")
603
+
604
+ def on_coupling_changed(self, value):
605
+ """Handle coupling strength change."""
606
+ self.crystal.coupling_strength = value
607
+
608
+ def on_speed_changed(self, value):
609
+ """Handle speed slider change."""
610
+ if self.is_running:
611
+ # Map 1-100 to 100ms-1ms interval
612
+ interval = max(1, 101 - value)
613
+ self.timer.setInterval(interval)
614
+
615
+ def toggle_simulation(self):
616
+ """Start/stop simulation."""
617
+ if not self.eeg_loaded:
618
+ QMessageBox.warning(self, "Warning", "Please load an EDF file first.")
619
+ return
620
+
621
+ if self.is_running:
622
+ self.timer.stop()
623
+ self.is_running = False
624
+ self.start_btn.setText("▶ Start")
625
+ self.status_bar.showMessage("Simulation paused")
626
+ else:
627
+ interval = max(1, 101 - self.speed_slider.value())
628
+ self.timer.start(interval)
629
+ self.is_running = True
630
+ self.start_btn.setText("⏸ Pause")
631
+ self.status_bar.showMessage("Simulation running...")
632
+
633
+ def simulation_step(self):
634
+ """One step of simulation."""
635
+ I = self.eeg.get_input_current(self.crystal.grid_size)
636
+ self.crystal.step(I, learning=True)
637
+
638
+ # Update progress
639
+ target = self.target_steps_spin.value()
640
+ self.progress_bar.setMaximum(target)
641
+ self.progress_bar.setValue(min(self.crystal.learning_steps, target))
642
+
643
+ # Update display every few steps for performance
644
+ if self.crystal.learning_steps % 5 == 0:
645
+ self.update_display()
646
+
647
+ # Auto-stop at target
648
+ if self.crystal.learning_steps >= target:
649
+ self.toggle_simulation()
650
+ self.status_bar.showMessage(f"Completed {target} steps - Crystal ready to save!")
651
+
652
+ def reset_crystal(self):
653
+ """Reset crystal to initial state."""
654
+ self.crystal.init_arrays()
655
+ self.crystal.total_spikes = 0
656
+ self.crystal.learning_steps = 0
657
+ if self.eeg_loaded:
658
+ self.eeg.current_idx = 0
659
+ self.update_display()
660
+ self.status_bar.showMessage("Crystal reset")
661
+
662
+ def update_display(self):
663
+ """Update visualization."""
664
+ # Activity
665
+ activity_img = self.crystal.render_activity(400)
666
+
667
+ # Draw electrode pins on activity
668
+ if self.eeg_loaded:
669
+ scale = 400 / self.crystal.grid_size
670
+ for r, c in self.eeg.pin_coords:
671
+ x, y = int(c * scale), int(r * scale)
672
+ cv2.circle(activity_img, (x, y), 3, (0, 255, 0), -1)
673
+
674
+ h, w, ch = activity_img.shape
675
+ qimg = QImage(activity_img.data, w, h, w * ch, QImage.Format.Format_RGB888)
676
+ self.activity_label.setPixmap(QPixmap.fromImage(qimg))
677
+
678
+ # Crystal
679
+ crystal_img = self.crystal.render_crystal(400)
680
+ h, w, ch = crystal_img.shape
681
+ qimg = QImage(crystal_img.data, w, h, w * ch, QImage.Format.Format_RGB888)
682
+ self.crystal_label.setPixmap(QPixmap.fromImage(qimg))
683
+
684
+ # Stats
685
+ self.stats_label.setText(
686
+ f"Steps: {self.crystal.learning_steps}\n"
687
+ f"Spikes: {self.crystal.total_spikes:,}\n"
688
+ f"Energy: {self.crystal.get_energy():.1f}\n"
689
+ f"Entropy: {self.crystal.get_entropy():.2f}"
690
+ )
691
+
692
+ self.progress_bar.setValue(self.crystal.learning_steps)
693
+
694
+ def save_crystal(self):
695
+ """Save crystal to file."""
696
+ if self.crystal.learning_steps == 0:
697
+ QMessageBox.warning(self, "Warning", "No crystal to save - run some training first.")
698
+ return
699
+
700
+ default_name = f"crystal_{self.crystal.grid_size}x{self.crystal.grid_size}_{self.crystal.learning_steps}steps.npz"
701
+ filepath, _ = QFileDialog.getSaveFileName(
702
+ self, "Save Crystal", default_name, "NumPy Archive (*.npz);;All Files (*)"
703
+ )
704
+
705
+ if filepath:
706
+ try:
707
+ # Prepare pin data
708
+ pin_coords = np.array(self.eeg.pin_coords) if self.eeg.pin_coords else np.array([])
709
+ pin_names = np.array(self.eeg.pin_names) if self.eeg.pin_names else np.array([])
710
+
711
+ np.savez(filepath,
712
+ # Weights
713
+ weights_up=self.crystal.weights_up,
714
+ weights_down=self.crystal.weights_down,
715
+ weights_left=self.crystal.weights_left,
716
+ weights_right=self.crystal.weights_right,
717
+ # Pin map
718
+ pin_coords=pin_coords,
719
+ pin_names=pin_names,
720
+ # Metadata
721
+ grid_size=self.crystal.grid_size,
722
+ learning_steps=self.crystal.learning_steps,
723
+ total_spikes=self.crystal.total_spikes,
724
+ learning_rate=self.crystal.learning_rate,
725
+ edf_source=os.path.basename(self.edf_path) if self.edf_path else "",
726
+ created=datetime.now().isoformat()
727
+ )
728
+
729
+ self.status_bar.showMessage(f"Saved crystal to {os.path.basename(filepath)}")
730
+
731
+ except Exception as e:
732
+ QMessageBox.critical(self, "Error", f"Failed to save:\n{str(e)}")
733
+
734
+ def load_crystal(self):
735
+ """Load crystal from file."""
736
+ filepath, _ = QFileDialog.getOpenFileName(
737
+ self, "Load Crystal", "", "NumPy Archive (*.npz);;All Files (*)"
738
+ )
739
+
740
+ if filepath:
741
+ try:
742
+ data = np.load(filepath, allow_pickle=True)
743
+
744
+ # Get grid size and resize
745
+ grid_size = int(data['grid_size'])
746
+ self.crystal.resize(grid_size)
747
+ self.resolution_combo.setCurrentText(str(grid_size))
748
+
749
+ # Load weights
750
+ self.crystal.weights_up = data['weights_up']
751
+ self.crystal.weights_down = data['weights_down']
752
+ self.crystal.weights_left = data['weights_left']
753
+ self.crystal.weights_right = data['weights_right']
754
+
755
+ # Load stats
756
+ self.crystal.learning_steps = int(data['learning_steps'])
757
+ self.crystal.total_spikes = int(data['total_spikes'])
758
+ if 'learning_rate' in data:
759
+ self.crystal.learning_rate = float(data['learning_rate'])
760
+ self.lr_spin.setValue(self.crystal.learning_rate)
761
+
762
+ # Load pin map
763
+ if 'pin_coords' in data and len(data['pin_coords']) > 0:
764
+ self.eeg.pin_coords = [tuple(c) for c in data['pin_coords']]
765
+ self.eeg.pin_names = list(data['pin_names'])
766
+
767
+ self.update_display()
768
+ self.status_bar.showMessage(f"Loaded crystal from {os.path.basename(filepath)}")
769
+
770
+ except Exception as e:
771
+ QMessageBox.critical(self, "Error", f"Failed to load:\n{str(e)}")
772
+
773
+
774
+ def main():
775
+ app = QApplication(sys.argv)
776
+ app.setStyle("Fusion")
777
+
778
+ # Dark theme
779
+ palette = app.palette()
780
+ palette.setColor(palette.ColorRole.Window, QColor(53, 53, 53))
781
+ palette.setColor(palette.ColorRole.WindowText, QColor(255, 255, 255))
782
+ palette.setColor(palette.ColorRole.Base, QColor(25, 25, 25))
783
+ palette.setColor(palette.ColorRole.AlternateBase, QColor(53, 53, 53))
784
+ palette.setColor(palette.ColorRole.ToolTipBase, QColor(255, 255, 255))
785
+ palette.setColor(palette.ColorRole.ToolTipText, QColor(255, 255, 255))
786
+ palette.setColor(palette.ColorRole.Text, QColor(255, 255, 255))
787
+ palette.setColor(palette.ColorRole.Button, QColor(53, 53, 53))
788
+ palette.setColor(palette.ColorRole.ButtonText, QColor(255, 255, 255))
789
+ palette.setColor(palette.ColorRole.BrightText, QColor(255, 0, 0))
790
+ palette.setColor(palette.ColorRole.Link, QColor(42, 130, 218))
791
+ palette.setColor(palette.ColorRole.Highlight, QColor(42, 130, 218))
792
+ palette.setColor(palette.ColorRole.HighlightedText, QColor(0, 0, 0))
793
+ app.setPalette(palette)
794
+
795
+ window = CrystalMakerWindow()
796
+ window.show()
797
+
798
+ sys.exit(app.exec())
799
+
800
+
801
+ if __name__ == "__main__":
802
+ main()