PerceptionLabPortable / app /nodes /anttis_superfluid.py
Aluode's picture
Upload folder using huggingface_hub
3bb804c verified
"""
Antti's Superfluid Node - Simulates a 1D complex field with knots
Physics based on the 1D NLSE from knotiverse_interactive_viewer.py
Requires: pip install scipy
Place this file in the 'nodes' folder
"""
import numpy as np
from PyQt6 import QtGui
import cv2
import sys
import os
# --- This is the new, correct block ---
import __main__
BaseNode = __main__.BaseNode
PA_INSTANCE = getattr(__main__, "PA_INSTANCE", None)
# ------------------------------------
try:
from scipy.signal import hilbert
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
print("Warning: AnttiSuperfluidNode requires 'scipy'.")
print("Please run: pip install scipy")
class AnttiSuperfluidNode(BaseNode):
NODE_CATEGORY = "Transform"
NODE_COLOR = QtGui.QColor(180, 80, 180) # Superfluid purple
def __init__(self, grid_size=512, coupling=0.5, nonlinear=0.8, damping=0.005):
super().__init__()
self.node_title = "Antti's Superfluid"
self.inputs = {
'signal_in': 'signal',
'coupling': 'signal',
'nonlinearity': 'signal',
'damping': 'signal'
}
self.outputs = {
'field_image': 'image',
'angular_momentum': 'signal',
'knot_count': 'signal'
}
# --- Parameters from knotiverse_interactive_viewer.py ---
self.L = int(grid_size)
self.dt = 0.05
self.detect_threshold = 0.5
self.saturation_threshold = 2.0
self.max_amplitude_clip = 1e3
# Default physics values (will be overridden by signals)
self.coupling = coupling
self.nonlinear = nonlinear
self.damping = damping
# --- Internal State ---
rng = np.random.default_rng()
self.psi = (rng.standard_normal(self.L) + 1j * rng.standard_normal(self.L)) * 0.01
# Seed with a pulse
x = np.arange(self.L)
p = self.L // 2
gauss = 1.0 * np.exp(-((x - p)**2) / (2 * 4**2))
self.psi += gauss * np.exp(1j * 2.0 * np.pi * rng.random())
self.knots = np.array([], dtype=int)
self.angular_momentum_out = 0.0
if not SCIPY_AVAILABLE:
self.node_title = "Superfluid (No SciPy!)"
def laplacian_1d(self, arr):
"""Discrete laplacian with periodic boundary."""
return np.roll(arr, -1) - 2*arr + np.roll(arr, 1)
def step(self):
if not SCIPY_AVAILABLE:
return
# --- Get inputs ---
signal_in = self.get_blended_input('signal_in', 'sum') or 0.0
coupling = self.get_blended_input('coupling', 'sum')
nonlinear = self.get_blended_input('nonlinearity', 'sum')
damping = self.get_blended_input('damping', 'sum')
# Use signal if connected, else use internal value
c = coupling if coupling is not None else self.coupling
n = nonlinear if nonlinear is not None else self.nonlinear
d = damping if damping is not None else self.damping
# --- Physics Step (from knotiverse_interactive_viewer.py) ---
lap = self.laplacian_1d(self.psi)
coupling_term = 1j * c * lap
amp = np.abs(self.psi)
sat = np.tanh(amp / self.saturation_threshold)
nonlin_term = -1j * n * (sat**2) * self.psi
damping_term = -d * self.psi
self.psi = self.psi + self.dt * (coupling_term + nonlin_term + damping_term)
# --- Resonance from input signal ---
# "Pluck" the center of the string
self.psi[self.L // 2] += signal_in * 0.5 # Scale input
# Stability checks
self.psi = np.nan_to_num(self.psi, nan=0.0, posinf=0.0, neginf=0.0)
amp_new = np.abs(self.psi)
over = amp_new > self.max_amplitude_clip
if np.any(over):
self.psi[over] = self.psi[over] * (self.max_amplitude_clip / amp_new[over])
amp_now = np.abs(self.psi)
# --- Knot Detection ---
left = np.roll(amp_now, 1)
right = np.roll(amp_now, -1)
mask_thresh = amp_now > self.detect_threshold
mask_local_max = (amp_now >= left) & (amp_now >= right)
self.knots = np.where(mask_thresh & mask_local_max)[0]
self.knot_count_out = len(self.knots)
# --- Angular Momentum ---
grad_psi = np.roll(self.psi, -1) - np.roll(self.psi, 1)
moment_density = np.imag(np.conj(self.psi) * grad_psi)
self.angular_momentum_out = float(np.sum(moment_density))
def get_output(self, port_name):
if port_name == 'field_image':
return self._draw_field_image(as_float=True)
elif port_name == 'angular_momentum':
return self.angular_momentum_out
elif port_name == 'knot_count':
return self.knot_count_out
return None
def _draw_field_image(self, as_float=False):
h, w = 64, self.L
img_color = np.zeros((h, w, 3), dtype=np.uint8)
# Get field data
amp_now = np.abs(self.psi)
phase_now = np.angle(hilbert(self.psi.real))
# Normalize
amp_norm = np.clip(amp_now / self.saturation_threshold, 0, 1)
phase_norm = (phase_now + np.pi) / (2 * np.pi)
# Draw amplitude (top half) and phase (bottom half)
h_half = h // 2
for x in range(w):
# Amplitude (Cyan)
y_amp = int((h_half - 1) - amp_norm[x] * (h_half - 1))
img_color[y_amp, x] = (255, 255, 0) # BGR for Cyan
# Phase (Magenta)
y_phase = int(h_half + (h_half - 1) - phase_norm[x] * (h_half - 1))
img_color[y_phase, x] = (255, 0, 255) # BGR for Magenta
# Draw center line
cv2.line(img_color, (0, h // 2), (w, h // 2), (50, 50, 50), 1)
# Draw knots (Red)
for kx in self.knots:
ky = int((h_half - 1) - amp_norm[kx] * (h_half - 1))
cv2.circle(img_color, (kx, ky), 3, (0, 0, 255), -1) # BGR for Red
if as_float:
return img_color.astype(np.float32) / 255.0
img_color = np.ascontiguousarray(img_color)
return QtGui.QImage(img_color.data, w, h, 3*w, QtGui.QImage.Format.Format_BGR888)
def get_display_image(self):
return self._draw_field_image(as_float=False)
def get_config_options(self):
return [
("Grid Size", "L", self.L, None),
("Knot Threshold", "detect_threshold", self.detect_threshold, None),
("Coupling", "coupling", self.coupling, None),
("Nonlinearity", "nonlinear", self.nonlinear, None),
("Damping", "damping", self.damping, None),
]