Aluode/PerceptionLabPortable
0
1#!/usr/bin/env python3
2"""
3EEG Crystal Maker
4=================
5
6Standalone GUI for growing neural crystal lattices from EEG data.
7
8Features:
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
15The crystal lattice is a 2D Izhikevich neuron sheet with STDP plasticity.
16EEG electrodes inject current at mapped positions (the "pins").
17Over time, the coupling weights crystallize into a structure that
18reflects the EEG's spatiotemporal patterns.
19
20Output:
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
27Author: Built for Antti's consciousness crystallography research
28"""
29
30import sys
31import os
32import re
33import json
34import numpy as np
35from datetime import datetime
36
37from 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)
43from PyQt6.QtCore import Qt, QTimer
44from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QFont
45
46import cv2
47
48try:
49 import mne
50 MNE_AVAILABLE = True
51except ImportError:
52 MNE_AVAILABLE = False
53 print("Warning: MNE not installed. EEG loading will not work.")
54
55
56class 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
240class 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
374class 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
774def 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
801if __name__ == "__main__":
802 main()