PerceptionLabPortable / EEGCrystalMaker.py
Aluode's picture
Upload EEGCrystalMaker.py
f4fda52 verified
#!/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()