Oscillators

June 24, 2026 · View on GitHub

Phase extraction from raw signals via canonical Physical (P), Informational (I), and Symbolic (S) channels, plus named extension channels. The P/I/S decomposition is the default abstraction that makes SPO domain-agnostic, but deployments are not limited to three channels: any signal that exhibits periodic or quasi-periodic behaviour can map onto one or more named channels.

Pipeline position

Raw signals ──→ PhysicalExtractor  ──→ PhaseState(θ, ω, quality)
Event streams ──→ InformationalExtractor ──→ PhaseState(θ, ω, quality)
Sequences ──→ SymbolicExtractor ──→ PhaseState(θ, ω, quality)


                                    PhaseQualityScorer

                                    ┌─────────┼──────────┐
                                    ↓         ↓          ↓
                              θ array    ω array    quality mask
                                    │         │          │
                                    ↓         ↓          ↓
                           UPDEEngine.step(phases, omegas, knm * mask, ...)

Oscillators are the input adapters of the SPO pipeline. They convert raw domain signals into the (θ, ω) vectors that the engine requires. Quality scores gate which oscillators participate in coupling.


The PIS Model

Every domain signal decomposes into one or more oscillator channels. The canonical channels are:

ChannelSignal typeExtraction methodExample domains
P (Physical)Continuous waveformsHilbert transformEEG, ECG, vibration, voltage, plasma
I (Informational)Event streams, ratesInter-event intervalNetwork traffic, API calls, manufacturing
S (Symbolic)Categorical sequencesRing mappingProtocols, language, music, genetics

Not every domain uses all three canonical channels. A pure physics domain (tokamak plasma) might use only P. A pure IT domain (microservices) might use only I. Larger deployments can add named extension channels such as thermal, market_sentiment, or operator_intent while preserving the same PhaseState contract. The binding specification declares which channels are active.


Phase State

PhaseState (dataclass)

FieldTypeRangeDescription
thetafloat[0, 2π)Phase angle
omegafloatRInstantaneous frequency (rad/s)
amplitudefloat≥ 0Signal strength (SNR proxy)
qualityfloat[0, 1]Extraction confidence
channelstrIdentifierBinding channel (P, I, S, or named extension)
node_idstrUnique oscillator identifier

Quality scores gate downstream processing: low-quality oscillators are downweighted in coupling and excluded from regime classification.


Extractor Interface

All channel extractors implement the PhaseExtractor abstract base class:

from numpy.typing import NDArray
import numpy as np

FloatArray = NDArray[np.float64]

class PhaseExtractor(ABC):
    @abstractmethod
    def extract(self, signal: FloatArray, sample_rate: float) -> list[PhaseState]: ...

    @abstractmethod
    def quality_score(self, phase_states: list[PhaseState]) -> float: ...

The extract method receives a raw signal window and sample rate, and returns one or more PhaseState objects. The quality_score method computes an aggregate quality for the extraction.


Physical Extraction (P)

PhysicalExtractor

PhysicalExtractor(node_id: str = "phys_0")

Uses the analytic signal (Hilbert transform) to decompose a real-valued waveform into instantaneous phase and amplitude:

$ \text{z}(\text{t}) = \text{x}(\text{t}) + \text{i} \text{H}[\text{x}(\text{t})] θ(\text{t}) = \text{arg}(\text{z}(\text{t})), \text{A}(\text{t}) = |\text{z}(\text{t})| ω = 2π \times \text{median}(\text{instantaneous} \text{frequency}) $

Quality metric

_envelope_quality(signal, analytic) returns quality based on the coefficient of variation (CV) of the analytic signal envelope:

quality = clip(1.0 - CV(|z(t)|), 0, 1)

Clean sinusoids have near-constant envelope (CV ≈ 0, quality ≈ 1.0). Noisy signals have variable envelope (high CV, low quality).

Validation

  • Rejects empty signals, single-sample signals, and 2-D arrays with ValueError("1-D with >= 2 samples")
  • Returns channel = "P", node_id from constructor

Rust acceleration

When spo_kernel is importable, uses spo_kernel.physical_extract() for the core computation. Python fallback uses scipy Hilbert transform. Parity verified in tests/test_oscillator_physical.py::test_rust_python_parity.

Performance: extract(1s @ 1kHz) < 5 ms.

::: scpn_phase_orchestrator.oscillators.physical


Informational Extraction (I)

InformationalExtractor

InformationalExtractor(node_id: str = "info_0")

Converts event timestamps into phase oscillators:

  1. Compute inter-event intervals: τ_k = t_k - t_{k-1}
  2. Median frequency: f = 1 / median(τ)
  3. Angular frequency: ω = 2πf
  4. Phase: θ = (2πf × total_duration) mod 2π
  5. Amplitude: mean instantaneous frequency
  6. Quality: 1/(1 + CV(τ)) where CV = std(τ)/mean(τ)

Edge cases

InputResult
Single timestampθ=0, ω=0, quality=0
Identical timestampsθ=0, ω=0, quality=0
Two timestampsValid extraction from one interval
Regular eventsquality ≈ 1.0
Irregular eventsquality < 0.9

Performance: extract(100 timestamps) < 500 μs.

::: scpn_phase_orchestrator.oscillators.informational


Symbolic Extraction (S)

SymbolicExtractor

SymbolicExtractor(n_states: int, node_id: str = "sym", mode: str = "ring")
ParameterTypeDescription
n_statesintVocabulary size (≥ 2)
node_idstrOscillator identifier
modestr"ring" or "graph"

Ring mode

Maps state index s to phase: θ_s = 2πs / N (mod 2π). Equispaced phases with gap = 2π/N.

Graph mode

Cumulative transition distances normalised to [0, 2π).

Quality scoring

Transition typeQuality
Single step (Δs
Stalled (Δs = 0)0.2
Large jump (Δs
First state (no prior)0.5

Omega derivation

ω is derived from consecutive phase differences divided by dt (1/sample_rate). For ring mode with single steps: ω = 2π/(N·dt).

Performance: extract(1000 states) < 1 ms.

::: scpn_phase_orchestrator.oscillators.symbolic


Wavelet-ridge extractor (physical channel)

WaveletExtractor is a band-adaptive alternative to the Hilbert extractor: it computes a complex Morlet continuous wavelet transform across a log-spaced frequency bank, selects the dominant energy ridge over a cone-of-influence-safe interior region, and reads the analytic phase along that ridge. The terminal phase is extrapolated from a COI-safe interior sample at the ridge frequency, so the corrupted signal edge is avoided. The Morlet wavelet is ψ(t) = π^(−1/4)·exp(i·ω₀·t/s)·exp(−(t/s)²/2)/√s with ω₀ = 6; a pure-NumPy path is used because SciPy 1.15 removed cwt/morlet2. Prefer it over Hilbert when a dominant oscillation sits in broadband noise or slow drift.

::: scpn_phase_orchestrator.oscillators.wavelet


Zero-crossing extractor (physical channel)

ZeroCrossingExtractor recovers phase from interpolated zero crossings: each crossing is a half-cycle (π of phase advance), absolute phase is anchored to the crossing direction (a rising crossing ≡ 0, a falling crossing ≡ π, the sine convention), and a Schmitt-trigger deadband (a fraction of the RMS) suppresses spurious noise-induced crossings. Angular frequency comes from the mean half-period and quality from the regularity of the half-period intervals. Prefer it for sharply non-sinusoidal periodic signals where a single analytic phase is ill-defined.

::: scpn_phase_orchestrator.oscillators.zero_crossing


Quality Scoring

PhaseQualityScorer

MethodSignatureDescription
score(states) → floatAmplitude-weighted mean quality
detect_collapse(states, threshold=0.1) → boolTrue if >50% below threshold
downweight_mask(states, min_quality=0.3) → NDArray[np.float64]Weight array, zeros below min

Downweight mask in pipeline

The mask is applied to the coupling matrix before engine evaluation:

mask = scorer.downweight_mask(states, min_quality=0.3)
knm_gated = knm * mask[:, None] * mask[None, :]
# Low-quality oscillators decoupled from high-quality ones

This prevents noisy phase estimates from corrupting the synchronisation dynamics. Only oscillators with quality ≥ min_quality participate.

Performance: downweight_mask(100 states) < 50 μs.

::: scpn_phase_orchestrator.oscillators.quality


Base Types

::: scpn_phase_orchestrator.oscillators.base

Phase Initialisation

Utilities for deterministic and random initial phase generation used in simulation setup and reproducible experiment seeds.

::: scpn_phase_orchestrator.oscillators.init_phases


Phase reduction

Model-free phase reduction: a dependency-light evaluator of a trained phase autoencoder (see nn.phase_autoencoder) that recovers the asymptotic phase Θ(x) and the phase-sensitivity function Z(θ) — the phase response curve — from frozen NumPy weights, with no JAX on the control path.

::: scpn_phase_orchestrator.oscillators.phase_reduction


Extractor factory

build_extractor maps a binding extractor_type — a channel alias (physical/informational/symbolic) or a canonical algorithm name (hilbert/wavelet/zero_crossing/event/ring/graph) — to the concrete PhaseExtractor that implements it. Aliases resolve through resolve_extractor_type; an unknown type raises ValueError (fail-closed) rather than silently degrading to a default algorithm.

::: scpn_phase_orchestrator.oscillators.factory


Cross-channel composition

A domain can use multiple channels simultaneously. The binding spec declares which channels are active and how they map to oscillator indices:

layers:
  - name: voltage
    channel: P
    indices: [0, 1, 2, 3]
  - name: event_rate
    channel: I
    indices: [4, 5]
  - name: protocol_state
    channel: S
    indices: [6, 7]

All channels produce PhaseState with the same fields, so the engine treats them uniformly. The channel field enables channel-aware analysis (e.g., computing R separately for P and I oscillators).

Rust FFI acceleration

PhysicalExtractor uses spo_kernel.physical_extract() when the Rust extension is installed. The Rust path computes the Hilbert transform and phase extraction in a single pass, avoiding Python/NumPy overhead for large signals.

Parity is verified in tests/test_oscillator_physical.py::test_rust_python_parity with tolerance atol=1e-10 for phase, rtol=0.01 for frequency.


Performance summary

OperationBudgetRustNotes
PhysicalExtractor.extract(1s @ 1kHz)< 5 ms< 1 msHilbert transform
InformationalExtractor.extract(100 ts)< 500 μsnumpy operations
SymbolicExtractor.extract(1000 states)< 1 msring mapping
PhaseQualityScorer.downweight_mask(100)< 50 μsarray comparison

Domain examples

Neuroscience (EEG)

# 64-channel EEG → 64 P-channel oscillators
extractor = PhysicalExtractor(node_id="eeg")
for ch in range(64):
    states = extractor.extract(eeg_data[ch], fs=256.0)
    phases[ch] = states[0].theta
    omegas[ch] = states[0].omega

Microservices (queue depths)

# 12 services → 12 I-channel oscillators
extractor = InformationalExtractor(node_id="svc")
for svc in services:
    timestamps = svc.request_timestamps()
    states = extractor.extract(timestamps, sample_rate=0.0)
    phases[svc.id] = states[0].theta

Genomic sequences

# DNA codons → S-channel oscillators
extractor = SymbolicExtractor(n_states=64, mode="ring")
codon_indices = encode_codons(sequence)
states = extractor.extract(codon_indices, sample_rate=1.0)