Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| EEG Crystal Maker | |
| ================= | |
| Standalone GUI for growing neural crystal lattices from EEG data. | |
| Features: | |
| - Load any EDF file | |
| - Set resolution (32x32 to 2048x2048) | |
| - Watch crystallization in real-time | |
| - Save crystal state + pin map | |
| - Load and continue growing | |
| The crystal lattice is a 2D Izhikevich neuron sheet with STDP plasticity. | |
| EEG electrodes inject current at mapped positions (the "pins"). | |
| Over time, the coupling weights crystallize into a structure that | |
| reflects the EEG's spatiotemporal patterns. | |
| Output: | |
| - .npz file containing: | |
| - weights (4 directional coupling matrices) | |
| - pin_coords (electrode positions on grid) | |
| - pin_names (electrode labels) | |
| - metadata (resolution, training steps, etc.) | |
| Author: Built for Antti's consciousness crystallography research | |
| """ | |
| import sys | |
| import os | |
| import re | |
| import json | |
| import numpy as np | |
| from datetime import datetime | |
| from PyQt6.QtWidgets import ( | |
| QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, | |
| QPushButton, QLabel, QSpinBox, QDoubleSpinBox, QFileDialog, | |
| QProgressBar, QGroupBox, QGridLayout, QComboBox, QCheckBox, | |
| QSlider, QFrame, QMessageBox, QStatusBar | |
| ) | |
| from PyQt6.QtCore import Qt, QTimer | |
| from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QFont | |
| import cv2 | |
| try: | |
| import mne | |
| MNE_AVAILABLE = True | |
| except ImportError: | |
| MNE_AVAILABLE = False | |
| print("Warning: MNE not installed. EEG loading will not work.") | |
| class CrystalLattice: | |
| """The neural crystal - Izhikevich sheet with STDP.""" | |
| def __init__(self, grid_size=64): | |
| self.grid_size = grid_size | |
| self.init_arrays() | |
| # Izhikevich parameters | |
| self.a = 0.02 | |
| self.b = 0.2 | |
| self.c = -65.0 | |
| self.d = 8.0 | |
| self.dt = 0.5 | |
| # STDP parameters | |
| self.learning_rate = 0.005 | |
| self.trace_decay = 0.95 | |
| self.weight_max = 2.0 | |
| self.weight_min = 0.01 | |
| # Coupling strength - how much neighbors influence each other | |
| self.coupling_strength = 5.0 # Higher = more spread | |
| # Statistics | |
| self.total_spikes = 0 | |
| self.learning_steps = 0 | |
| def init_arrays(self): | |
| """Initialize all arrays to current grid_size.""" | |
| n = self.grid_size | |
| # Neural state | |
| self.v = np.ones((n, n), dtype=np.float32) * -65.0 | |
| self.u = self.v * 0.2 | |
| # Crystal weights (4 directions) | |
| self.weights_up = np.ones((n, n), dtype=np.float32) * 0.5 | |
| self.weights_down = np.ones((n, n), dtype=np.float32) * 0.5 | |
| self.weights_left = np.ones((n, n), dtype=np.float32) * 0.5 | |
| self.weights_right = np.ones((n, n), dtype=np.float32) * 0.5 | |
| # Spike trace for STDP | |
| self.spike_trace = np.zeros((n, n), dtype=np.float32) | |
| def resize(self, new_size): | |
| """Resize the lattice (resets state).""" | |
| self.grid_size = new_size | |
| self.init_arrays() | |
| self.total_spikes = 0 | |
| self.learning_steps = 0 | |
| def step(self, input_current, learning=True): | |
| """One simulation step with optional STDP learning.""" | |
| v = self.v | |
| u = self.u | |
| I = input_current | |
| # Clamp input to prevent explosion | |
| I = np.clip(I, -100, 100) | |
| # Neighbor coupling | |
| v_up = np.roll(v, -1, axis=0) | |
| v_down = np.roll(v, 1, axis=0) | |
| v_left = np.roll(v, -1, axis=1) | |
| v_right = np.roll(v, 1, axis=1) | |
| neighbor_influence = ( | |
| self.weights_up * v_up + | |
| self.weights_down * v_down + | |
| self.weights_left * v_left + | |
| self.weights_right * v_right | |
| ) | |
| total_weight = (self.weights_up + self.weights_down + | |
| self.weights_left + self.weights_right) | |
| neighbor_avg = neighbor_influence / (total_weight + 1e-6) | |
| I_coupling = self.coupling_strength * (neighbor_avg - v) | |
| I_coupling = np.clip(I_coupling, -50, 50) # Prevent coupling explosion | |
| # Izhikevich dynamics | |
| dv = (0.04 * v * v + 5.0 * v + 140.0 - u + I + I_coupling) * self.dt | |
| du = self.a * (self.b * v - u) * self.dt | |
| v = v + dv | |
| u = u + du | |
| # Clamp voltage to sane range (prevents NaN cascade) | |
| v = np.clip(v, -100, 50) | |
| u = np.clip(u, -50, 50) | |
| # Handle any NaN that slipped through | |
| v = np.nan_to_num(v, nan=self.c, posinf=30.0, neginf=-100.0) | |
| u = np.nan_to_num(u, nan=self.c * self.b, posinf=20.0, neginf=-20.0) | |
| # Spikes | |
| spikes = v >= 30.0 | |
| v[spikes] = self.c | |
| u[spikes] += self.d | |
| self.v = v | |
| self.u = u | |
| self.total_spikes += np.sum(spikes) | |
| # STDP | |
| if learning and self.learning_rate > 0: | |
| self.learning_steps += 1 | |
| self.spike_trace *= self.trace_decay | |
| self.spike_trace[spikes] = 1.0 | |
| trace_up = np.roll(self.spike_trace, -1, axis=0) | |
| trace_down = np.roll(self.spike_trace, 1, axis=0) | |
| trace_left = np.roll(self.spike_trace, -1, axis=1) | |
| trace_right = np.roll(self.spike_trace, 1, axis=1) | |
| spike_float = spikes.astype(np.float32) | |
| lr = self.learning_rate | |
| # Potentiation | |
| dw_up = lr * spike_float * trace_up | |
| dw_down = lr * spike_float * trace_down | |
| dw_left = lr * spike_float * trace_left | |
| dw_right = lr * spike_float * trace_right | |
| # Depression | |
| spike_up = np.roll(spike_float, -1, axis=0) | |
| spike_down = np.roll(spike_float, 1, axis=0) | |
| spike_left = np.roll(spike_float, -1, axis=1) | |
| spike_right = np.roll(spike_float, 1, axis=1) | |
| dw_up -= 0.5 * lr * self.spike_trace * spike_up | |
| dw_down -= 0.5 * lr * self.spike_trace * spike_down | |
| dw_left -= 0.5 * lr * self.spike_trace * spike_left | |
| dw_right -= 0.5 * lr * self.spike_trace * spike_right | |
| self.weights_up = np.clip(self.weights_up + dw_up, self.weight_min, self.weight_max) | |
| self.weights_down = np.clip(self.weights_down + dw_down, self.weight_min, self.weight_max) | |
| self.weights_left = np.clip(self.weights_left + dw_left, self.weight_min, self.weight_max) | |
| self.weights_right = np.clip(self.weights_right + dw_right, self.weight_min, self.weight_max) | |
| return spikes | |
| def get_energy(self): | |
| """Total weight energy.""" | |
| return float(np.sum(self.weights_up) + np.sum(self.weights_down) + | |
| np.sum(self.weights_left) + np.sum(self.weights_right)) | |
| def get_entropy(self): | |
| """Weight distribution entropy.""" | |
| all_weights = np.concatenate([ | |
| self.weights_up.flatten(), | |
| self.weights_down.flatten(), | |
| self.weights_left.flatten(), | |
| self.weights_right.flatten() | |
| ]) | |
| w_norm = all_weights / (np.sum(all_weights) + 1e-9) | |
| return float(-np.sum(w_norm * np.log(w_norm + 1e-9))) | |
| def render_activity(self, size=256): | |
| """Render activity as image.""" | |
| disp = np.clip(self.v, -90.0, 40.0) | |
| disp = np.nan_to_num(disp, nan=-65.0, posinf=40.0, neginf=-90.0) | |
| norm = ((disp + 90.0) / 130.0 * 255.0).astype(np.uint8) | |
| heat = cv2.applyColorMap(norm, cv2.COLORMAP_INFERNO) | |
| heat = cv2.resize(heat, (size, size), interpolation=cv2.INTER_NEAREST) | |
| return cv2.cvtColor(heat, cv2.COLOR_BGR2RGB) | |
| def render_crystal(self, size=256): | |
| """Render crystal structure as image.""" | |
| horizontal = (self.weights_left + self.weights_right) / 2 | |
| vertical = (self.weights_up + self.weights_down) / 2 | |
| h_norm = (horizontal - self.weight_min) / (self.weight_max - self.weight_min) | |
| v_norm = (vertical - self.weight_min) / (self.weight_max - self.weight_min) | |
| anisotropy = np.abs(h_norm - v_norm) | |
| img = np.zeros((self.grid_size, self.grid_size, 3), dtype=np.uint8) | |
| img[:, :, 0] = (h_norm * 255).astype(np.uint8) | |
| img[:, :, 1] = ((1 - anisotropy) * 255).astype(np.uint8) | |
| img[:, :, 2] = (v_norm * 255).astype(np.uint8) | |
| return cv2.resize(img, (size, size), interpolation=cv2.INTER_NEAREST) | |
| class EEGSource: | |
| """Handles EEG loading and electrode mapping.""" | |
| STANDARD_MAP = { | |
| "FP1": (0.30, 0.10), "FP2": (0.70, 0.10), | |
| "F7": (0.10, 0.30), "F3": (0.30, 0.30), "FZ": (0.50, 0.25), | |
| "F4": (0.70, 0.30), "F8": (0.90, 0.30), | |
| "T7": (0.10, 0.50), "T3": (0.10, 0.50), # T3 alias | |
| "C3": (0.30, 0.50), "CZ": (0.50, 0.50), | |
| "C4": (0.70, 0.50), "T8": (0.90, 0.50), "T4": (0.90, 0.50), # T4 alias | |
| "P7": (0.10, 0.70), "T5": (0.10, 0.70), # T5 alias | |
| "P3": (0.30, 0.70), "PZ": (0.50, 0.75), | |
| "P4": (0.70, 0.70), "P8": (0.90, 0.70), "T6": (0.90, 0.70), # T6 alias | |
| "O1": (0.35, 0.90), "OZ": (0.50, 0.90), "O2": (0.65, 0.90), | |
| "A1": (0.05, 0.50), "A2": (0.95, 0.50), # Ear references | |
| } | |
| def __init__(self): | |
| self.raw = None | |
| self.data = None | |
| self.sfreq = 256.0 | |
| self.ch_names = [] | |
| self.current_idx = 0 | |
| self.amplification = 1e9 # Default amplification (Medium) | |
| # Pin mapping | |
| self.pin_coords = [] # (row, col) for each channel | |
| self.pin_names = [] # Channel names | |
| self.pin_indices = [] # Channel indices in data | |
| def load(self, filepath): | |
| """Load EDF file.""" | |
| if not MNE_AVAILABLE: | |
| raise RuntimeError("MNE not installed") | |
| raw = mne.io.read_raw_edf(filepath, preload=True, verbose=False) | |
| try: | |
| raw.pick_types(eeg=True, meg=False, eog=False, ecg=False, | |
| emg=False, misc=False, stim=False) | |
| except: | |
| pass | |
| if raw.info["sfreq"] > 256: | |
| raw.resample(256, npad="auto", verbose=False) | |
| self.raw = raw | |
| self.data = raw.get_data() | |
| self.sfreq = float(raw.info["sfreq"]) | |
| self.ch_names = list(raw.ch_names) | |
| self.current_idx = 0 | |
| return len(self.ch_names), self.data.shape[1] | |
| def map_electrodes(self, grid_size): | |
| """Map electrodes to grid positions.""" | |
| self.pin_coords = [] | |
| self.pin_names = [] | |
| self.pin_indices = [] | |
| for idx, name in enumerate(self.ch_names): | |
| clean = re.sub(r'[^A-Z0-9]', '', name.upper()) | |
| pos = None | |
| # Try exact match first | |
| for std_name, std_pos in self.STANDARD_MAP.items(): | |
| if std_name in clean or clean in std_name: | |
| pos = std_pos | |
| break | |
| # Try prefix match | |
| if pos is None: | |
| for std_name, std_pos in self.STANDARD_MAP.items(): | |
| if len(clean) >= 2 and clean[:2] == std_name[:2]: | |
| pos = std_pos | |
| break | |
| if pos: | |
| grid_r = int(pos[1] * (grid_size - 1)) | |
| grid_c = int(pos[0] * (grid_size - 1)) | |
| self.pin_coords.append((grid_r, grid_c)) | |
| self.pin_names.append(name) | |
| self.pin_indices.append(idx) | |
| return len(self.pin_coords) | |
| def get_input_current(self, grid_size): | |
| """Get input current for one timestep.""" | |
| if self.data is None: | |
| return np.zeros((grid_size, grid_size), dtype=np.float32) | |
| n_samples = self.data.shape[1] | |
| sample_idx = self.current_idx % n_samples | |
| self.current_idx += 1 | |
| I = np.zeros((grid_size, grid_size), dtype=np.float32) | |
| # Small spread - electrodes are injection points | |
| # The coupling between neurons spreads activity, not the electrode radius | |
| spread_radius = max(2, grid_size // 128) # ~8 at 1024, ~2 at 256 | |
| spread_sigma = max(1.0, spread_radius / 2.0) | |
| # Pre-compute Gaussian kernel once | |
| kernel_size = spread_radius * 2 + 1 | |
| y, x = np.ogrid[-spread_radius:spread_radius+1, -spread_radius:spread_radius+1] | |
| kernel = np.exp(-(x*x + y*y) / (2 * spread_sigma * spread_sigma)).astype(np.float32) | |
| for i, ch_idx in enumerate(self.pin_indices): | |
| if i < len(self.pin_coords): | |
| r, c = self.pin_coords[i] | |
| val = self.data[ch_idx, sample_idx] | |
| # Scale EEG | |
| scaled = float(val) * self.amplification | |
| scaled = np.clip(scaled, -500, 500) | |
| # Calculate bounds for kernel placement | |
| r_start = max(0, r - spread_radius) | |
| r_end = min(grid_size, r + spread_radius + 1) | |
| c_start = max(0, c - spread_radius) | |
| c_end = min(grid_size, c + spread_radius + 1) | |
| # Corresponding kernel bounds | |
| kr_start = r_start - (r - spread_radius) | |
| kr_end = kernel_size - ((r + spread_radius + 1) - r_end) | |
| kc_start = c_start - (c - spread_radius) | |
| kc_end = kernel_size - ((c + spread_radius + 1) - c_end) | |
| # Add weighted kernel to input | |
| I[r_start:r_end, c_start:c_end] += scaled * kernel[kr_start:kr_end, kc_start:kc_end] | |
| return I | |
| class CrystalMakerWindow(QMainWindow): | |
| """Main GUI window.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.setWindowTitle("EEG Crystal Maker") | |
| self.setMinimumSize(1000, 700) | |
| # Core objects | |
| self.crystal = CrystalLattice(64) | |
| self.eeg = EEGSource() | |
| # State | |
| self.is_running = False | |
| self.eeg_loaded = False | |
| self.edf_path = "" | |
| # Timer for simulation | |
| self.timer = QTimer() | |
| self.timer.timeout.connect(self.simulation_step) | |
| self.setup_ui() | |
| self.update_display() | |
| def setup_ui(self): | |
| """Build the UI.""" | |
| central = QWidget() | |
| self.setCentralWidget(central) | |
| layout = QHBoxLayout(central) | |
| # Left panel - controls | |
| left_panel = QVBoxLayout() | |
| layout.addLayout(left_panel, stretch=1) | |
| # EEG Loading | |
| eeg_group = QGroupBox("EEG Source") | |
| eeg_layout = QVBoxLayout(eeg_group) | |
| self.edf_label = QLabel("No file loaded") | |
| self.edf_label.setWordWrap(True) | |
| eeg_layout.addWidget(self.edf_label) | |
| load_btn = QPushButton("Load EDF File...") | |
| load_btn.clicked.connect(self.load_edf) | |
| eeg_layout.addWidget(load_btn) | |
| self.eeg_info = QLabel("Channels: -\nSamples: -\nPins mapped: -") | |
| eeg_layout.addWidget(self.eeg_info) | |
| left_panel.addWidget(eeg_group) | |
| # Crystal Settings | |
| crystal_group = QGroupBox("Crystal Settings") | |
| crystal_layout = QGridLayout(crystal_group) | |
| crystal_layout.addWidget(QLabel("Resolution:"), 0, 0) | |
| self.resolution_combo = QComboBox() | |
| self.resolution_combo.addItems(["32", "64", "128", "256", "512", "1024"]) | |
| self.resolution_combo.setCurrentText("64") | |
| self.resolution_combo.currentTextChanged.connect(self.on_resolution_changed) | |
| crystal_layout.addWidget(self.resolution_combo, 0, 1) | |
| crystal_layout.addWidget(QLabel("Learning Rate:"), 1, 0) | |
| self.lr_spin = QDoubleSpinBox() | |
| self.lr_spin.setRange(0.0001, 0.1) | |
| self.lr_spin.setSingleStep(0.001) | |
| self.lr_spin.setValue(0.005) | |
| self.lr_spin.valueChanged.connect(self.on_lr_changed) | |
| crystal_layout.addWidget(self.lr_spin, 1, 1) | |
| crystal_layout.addWidget(QLabel("EEG Amplification:"), 2, 0) | |
| self.amp_combo = QComboBox() | |
| self.amp_combo.addItems(["1e8 (Low)", "1e9 (Medium)", "1e10 (High)", "1e11 (Very High)"]) | |
| self.amp_combo.setCurrentIndex(1) # Default to Medium | |
| self.amp_combo.currentIndexChanged.connect(self.on_amp_changed) | |
| crystal_layout.addWidget(self.amp_combo, 2, 1) | |
| crystal_layout.addWidget(QLabel("Coupling Strength:"), 3, 0) | |
| self.coupling_spin = QDoubleSpinBox() | |
| self.coupling_spin.setRange(0.1, 20.0) | |
| self.coupling_spin.setSingleStep(0.5) | |
| self.coupling_spin.setValue(5.0) | |
| self.coupling_spin.valueChanged.connect(self.on_coupling_changed) | |
| crystal_layout.addWidget(self.coupling_spin, 3, 1) | |
| crystal_layout.addWidget(QLabel("Target Steps:"), 4, 0) | |
| self.target_steps_spin = QSpinBox() | |
| self.target_steps_spin.setRange(100, 100000) | |
| self.target_steps_spin.setSingleStep(100) | |
| self.target_steps_spin.setValue(800) | |
| crystal_layout.addWidget(self.target_steps_spin, 4, 1) | |
| left_panel.addWidget(crystal_group) | |
| # Simulation Control | |
| control_group = QGroupBox("Simulation") | |
| control_layout = QVBoxLayout(control_group) | |
| btn_layout = QHBoxLayout() | |
| self.start_btn = QPushButton("▶ Start") | |
| self.start_btn.clicked.connect(self.toggle_simulation) | |
| btn_layout.addWidget(self.start_btn) | |
| self.reset_btn = QPushButton("↺ Reset") | |
| self.reset_btn.clicked.connect(self.reset_crystal) | |
| btn_layout.addWidget(self.reset_btn) | |
| control_layout.addLayout(btn_layout) | |
| # Speed slider | |
| speed_layout = QHBoxLayout() | |
| speed_layout.addWidget(QLabel("Speed:")) | |
| self.speed_slider = QSlider(Qt.Orientation.Horizontal) | |
| self.speed_slider.setRange(1, 100) | |
| self.speed_slider.setValue(50) | |
| self.speed_slider.valueChanged.connect(self.on_speed_changed) | |
| speed_layout.addWidget(self.speed_slider) | |
| control_layout.addLayout(speed_layout) | |
| # Progress | |
| self.progress_bar = QProgressBar() | |
| self.progress_bar.setRange(0, 800) | |
| control_layout.addWidget(self.progress_bar) | |
| left_panel.addWidget(control_group) | |
| # Statistics | |
| stats_group = QGroupBox("Statistics") | |
| stats_layout = QVBoxLayout(stats_group) | |
| self.stats_label = QLabel("Steps: 0\nSpikes: 0\nEnergy: 0\nEntropy: 0") | |
| self.stats_label.setFont(QFont("Monospace", 10)) | |
| stats_layout.addWidget(self.stats_label) | |
| left_panel.addWidget(stats_group) | |
| # Save/Load | |
| file_group = QGroupBox("File Operations") | |
| file_layout = QVBoxLayout(file_group) | |
| save_btn = QPushButton("💾 Save Crystal...") | |
| save_btn.clicked.connect(self.save_crystal) | |
| file_layout.addWidget(save_btn) | |
| load_crystal_btn = QPushButton("📂 Load Crystal...") | |
| load_crystal_btn.clicked.connect(self.load_crystal) | |
| file_layout.addWidget(load_crystal_btn) | |
| left_panel.addWidget(file_group) | |
| left_panel.addStretch() | |
| # Right panel - visualization | |
| right_panel = QVBoxLayout() | |
| layout.addLayout(right_panel, stretch=2) | |
| # Activity view | |
| activity_group = QGroupBox("Neural Activity") | |
| activity_layout = QVBoxLayout(activity_group) | |
| self.activity_label = QLabel() | |
| self.activity_label.setMinimumSize(400, 400) | |
| self.activity_label.setAlignment(Qt.AlignmentFlag.AlignCenter) | |
| self.activity_label.setStyleSheet("background-color: #1a1a1a;") | |
| activity_layout.addWidget(self.activity_label) | |
| right_panel.addWidget(activity_group) | |
| # Crystal view | |
| crystal_view_group = QGroupBox("Crystal Structure") | |
| crystal_view_layout = QVBoxLayout(crystal_view_group) | |
| self.crystal_label = QLabel() | |
| self.crystal_label.setMinimumSize(400, 400) | |
| self.crystal_label.setAlignment(Qt.AlignmentFlag.AlignCenter) | |
| self.crystal_label.setStyleSheet("background-color: #1a1a1a;") | |
| crystal_view_layout.addWidget(self.crystal_label) | |
| right_panel.addWidget(crystal_view_group) | |
| # Status bar | |
| self.status_bar = QStatusBar() | |
| self.setStatusBar(self.status_bar) | |
| self.status_bar.showMessage("Ready - Load an EDF file to begin") | |
| def load_edf(self): | |
| """Load EDF file dialog.""" | |
| filepath, _ = QFileDialog.getOpenFileName( | |
| self, "Open EDF File", "", "EDF Files (*.edf);;All Files (*)" | |
| ) | |
| if filepath: | |
| try: | |
| n_channels, n_samples = self.eeg.load(filepath) | |
| n_pins = self.eeg.map_electrodes(self.crystal.grid_size) | |
| self.edf_path = filepath | |
| self.eeg_loaded = True | |
| fname = os.path.basename(filepath) | |
| self.edf_label.setText(f"Loaded: {fname}") | |
| self.eeg_info.setText( | |
| f"Channels: {n_channels}\n" | |
| f"Samples: {n_samples}\n" | |
| f"Pins mapped: {n_pins}" | |
| ) | |
| self.status_bar.showMessage(f"Loaded {fname} - {n_pins} electrodes mapped") | |
| except Exception as e: | |
| QMessageBox.critical(self, "Error", f"Failed to load EDF:\n{str(e)}") | |
| def on_resolution_changed(self, text): | |
| """Handle resolution change.""" | |
| new_size = int(text) | |
| if new_size != self.crystal.grid_size: | |
| self.crystal.resize(new_size) | |
| if self.eeg_loaded: | |
| n_pins = self.eeg.map_electrodes(new_size) | |
| self.eeg_info.setText( | |
| f"Channels: {len(self.eeg.ch_names)}\n" | |
| f"Samples: {self.eeg.data.shape[1]}\n" | |
| f"Pins mapped: {n_pins}" | |
| ) | |
| self.update_display() | |
| self.status_bar.showMessage(f"Resolution changed to {new_size}x{new_size}") | |
| def on_lr_changed(self, value): | |
| """Handle learning rate change.""" | |
| self.crystal.learning_rate = value | |
| def on_amp_changed(self, index): | |
| """Handle amplification change.""" | |
| amp_values = [1e8, 1e9, 1e10, 1e11] | |
| self.eeg.amplification = amp_values[index] | |
| self.status_bar.showMessage(f"Amplification set to {amp_values[index]:.0e}") | |
| def on_coupling_changed(self, value): | |
| """Handle coupling strength change.""" | |
| self.crystal.coupling_strength = value | |
| def on_speed_changed(self, value): | |
| """Handle speed slider change.""" | |
| if self.is_running: | |
| # Map 1-100 to 100ms-1ms interval | |
| interval = max(1, 101 - value) | |
| self.timer.setInterval(interval) | |
| def toggle_simulation(self): | |
| """Start/stop simulation.""" | |
| if not self.eeg_loaded: | |
| QMessageBox.warning(self, "Warning", "Please load an EDF file first.") | |
| return | |
| if self.is_running: | |
| self.timer.stop() | |
| self.is_running = False | |
| self.start_btn.setText("▶ Start") | |
| self.status_bar.showMessage("Simulation paused") | |
| else: | |
| interval = max(1, 101 - self.speed_slider.value()) | |
| self.timer.start(interval) | |
| self.is_running = True | |
| self.start_btn.setText("⏸ Pause") | |
| self.status_bar.showMessage("Simulation running...") | |
| def simulation_step(self): | |
| """One step of simulation.""" | |
| I = self.eeg.get_input_current(self.crystal.grid_size) | |
| self.crystal.step(I, learning=True) | |
| # Update progress | |
| target = self.target_steps_spin.value() | |
| self.progress_bar.setMaximum(target) | |
| self.progress_bar.setValue(min(self.crystal.learning_steps, target)) | |
| # Update display every few steps for performance | |
| if self.crystal.learning_steps % 5 == 0: | |
| self.update_display() | |
| # Auto-stop at target | |
| if self.crystal.learning_steps >= target: | |
| self.toggle_simulation() | |
| self.status_bar.showMessage(f"Completed {target} steps - Crystal ready to save!") | |
| def reset_crystal(self): | |
| """Reset crystal to initial state.""" | |
| self.crystal.init_arrays() | |
| self.crystal.total_spikes = 0 | |
| self.crystal.learning_steps = 0 | |
| if self.eeg_loaded: | |
| self.eeg.current_idx = 0 | |
| self.update_display() | |
| self.status_bar.showMessage("Crystal reset") | |
| def update_display(self): | |
| """Update visualization.""" | |
| # Activity | |
| activity_img = self.crystal.render_activity(400) | |
| # Draw electrode pins on activity | |
| if self.eeg_loaded: | |
| scale = 400 / self.crystal.grid_size | |
| for r, c in self.eeg.pin_coords: | |
| x, y = int(c * scale), int(r * scale) | |
| cv2.circle(activity_img, (x, y), 3, (0, 255, 0), -1) | |
| h, w, ch = activity_img.shape | |
| qimg = QImage(activity_img.data, w, h, w * ch, QImage.Format.Format_RGB888) | |
| self.activity_label.setPixmap(QPixmap.fromImage(qimg)) | |
| # Crystal | |
| crystal_img = self.crystal.render_crystal(400) | |
| h, w, ch = crystal_img.shape | |
| qimg = QImage(crystal_img.data, w, h, w * ch, QImage.Format.Format_RGB888) | |
| self.crystal_label.setPixmap(QPixmap.fromImage(qimg)) | |
| # Stats | |
| self.stats_label.setText( | |
| f"Steps: {self.crystal.learning_steps}\n" | |
| f"Spikes: {self.crystal.total_spikes:,}\n" | |
| f"Energy: {self.crystal.get_energy():.1f}\n" | |
| f"Entropy: {self.crystal.get_entropy():.2f}" | |
| ) | |
| self.progress_bar.setValue(self.crystal.learning_steps) | |
| def save_crystal(self): | |
| """Save crystal to file.""" | |
| if self.crystal.learning_steps == 0: | |
| QMessageBox.warning(self, "Warning", "No crystal to save - run some training first.") | |
| return | |
| default_name = f"crystal_{self.crystal.grid_size}x{self.crystal.grid_size}_{self.crystal.learning_steps}steps.npz" | |
| filepath, _ = QFileDialog.getSaveFileName( | |
| self, "Save Crystal", default_name, "NumPy Archive (*.npz);;All Files (*)" | |
| ) | |
| if filepath: | |
| try: | |
| # Prepare pin data | |
| pin_coords = np.array(self.eeg.pin_coords) if self.eeg.pin_coords else np.array([]) | |
| pin_names = np.array(self.eeg.pin_names) if self.eeg.pin_names else np.array([]) | |
| np.savez(filepath, | |
| # Weights | |
| weights_up=self.crystal.weights_up, | |
| weights_down=self.crystal.weights_down, | |
| weights_left=self.crystal.weights_left, | |
| weights_right=self.crystal.weights_right, | |
| # Pin map | |
| pin_coords=pin_coords, | |
| pin_names=pin_names, | |
| # Metadata | |
| grid_size=self.crystal.grid_size, | |
| learning_steps=self.crystal.learning_steps, | |
| total_spikes=self.crystal.total_spikes, | |
| learning_rate=self.crystal.learning_rate, | |
| edf_source=os.path.basename(self.edf_path) if self.edf_path else "", | |
| created=datetime.now().isoformat() | |
| ) | |
| self.status_bar.showMessage(f"Saved crystal to {os.path.basename(filepath)}") | |
| except Exception as e: | |
| QMessageBox.critical(self, "Error", f"Failed to save:\n{str(e)}") | |
| def load_crystal(self): | |
| """Load crystal from file.""" | |
| filepath, _ = QFileDialog.getOpenFileName( | |
| self, "Load Crystal", "", "NumPy Archive (*.npz);;All Files (*)" | |
| ) | |
| if filepath: | |
| try: | |
| data = np.load(filepath, allow_pickle=True) | |
| # Get grid size and resize | |
| grid_size = int(data['grid_size']) | |
| self.crystal.resize(grid_size) | |
| self.resolution_combo.setCurrentText(str(grid_size)) | |
| # Load weights | |
| self.crystal.weights_up = data['weights_up'] | |
| self.crystal.weights_down = data['weights_down'] | |
| self.crystal.weights_left = data['weights_left'] | |
| self.crystal.weights_right = data['weights_right'] | |
| # Load stats | |
| self.crystal.learning_steps = int(data['learning_steps']) | |
| self.crystal.total_spikes = int(data['total_spikes']) | |
| if 'learning_rate' in data: | |
| self.crystal.learning_rate = float(data['learning_rate']) | |
| self.lr_spin.setValue(self.crystal.learning_rate) | |
| # Load pin map | |
| if 'pin_coords' in data and len(data['pin_coords']) > 0: | |
| self.eeg.pin_coords = [tuple(c) for c in data['pin_coords']] | |
| self.eeg.pin_names = list(data['pin_names']) | |
| self.update_display() | |
| self.status_bar.showMessage(f"Loaded crystal from {os.path.basename(filepath)}") | |
| except Exception as e: | |
| QMessageBox.critical(self, "Error", f"Failed to load:\n{str(e)}") | |
| def main(): | |
| app = QApplication(sys.argv) | |
| app.setStyle("Fusion") | |
| # Dark theme | |
| palette = app.palette() | |
| palette.setColor(palette.ColorRole.Window, QColor(53, 53, 53)) | |
| palette.setColor(palette.ColorRole.WindowText, QColor(255, 255, 255)) | |
| palette.setColor(palette.ColorRole.Base, QColor(25, 25, 25)) | |
| palette.setColor(palette.ColorRole.AlternateBase, QColor(53, 53, 53)) | |
| palette.setColor(palette.ColorRole.ToolTipBase, QColor(255, 255, 255)) | |
| palette.setColor(palette.ColorRole.ToolTipText, QColor(255, 255, 255)) | |
| palette.setColor(palette.ColorRole.Text, QColor(255, 255, 255)) | |
| palette.setColor(palette.ColorRole.Button, QColor(53, 53, 53)) | |
| palette.setColor(palette.ColorRole.ButtonText, QColor(255, 255, 255)) | |
| palette.setColor(palette.ColorRole.BrightText, QColor(255, 0, 0)) | |
| palette.setColor(palette.ColorRole.Link, QColor(42, 130, 218)) | |
| palette.setColor(palette.ColorRole.Highlight, QColor(42, 130, 218)) | |
| palette.setColor(palette.ColorRole.HighlightedText, QColor(0, 0, 0)) | |
| app.setPalette(palette) | |
| window = CrystalMakerWindow() | |
| window.show() | |
| sys.exit(app.exec()) | |
| if __name__ == "__main__": | |
| main() |