CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
EEGCrystalMaker.py802 linesDownload Raw Back to root
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()
Aluode/PerceptionLabPortable · CoolFace