Source code for neuralfieldmanifold.simulation.schedules

import numpy as np

# Shared lag structure: gives each lag a distinct identity
_BASE_FREQS = np.array(
    [
        1 / 150,
        1 / 200,
        1 / 180,
        1 / 220,
        1 / 170,
        1 / 190,
        1 / 160,
        1 / 210,
        1 / 175,
        1 / 195,
    ]
)
_BASE_AMPS = np.array([0.35, 0.25, 0.20, 0.15, 0.12, 0.10, 0.09, 0.08, 0.07, 0.06])
_BASE_OFFSETS = np.array([0.6, -0.5, 0.3, -0.2, 0.15, -0.1, 0.08, -0.06, 0.05, -0.04])
_BASE_PHASES = np.array([k * np.pi / 4 for k in range(10)])


def _lag_params(order, l1_target=0.85):
    """Return per-lag frequency, amplitude, offset, and phase vectors.

    Scales the base templates so that the worst-case L1 norm
    ``sum(|offset_k| + |amp_k|)`` stays at *l1_target* (with 20 %
    jitter headroom), keeping the AR process stable by construction.

    Parameters
    ----------
    order : int
        Number of lags.
    l1_target : float, optional
        Target worst-case L1 coefficient norm. Default is 0.85.

    Returns
    -------
    freqs : np.ndarray
        Base frequencies of shape ``(order,)``.
    amps : np.ndarray
        Scaled amplitudes of shape ``(order,)``.
    offsets : np.ndarray
        Scaled offsets of shape ``(order,)``.
    phases : np.ndarray
        Phase offsets of shape ``(order,)``.
    """
    freqs = _BASE_FREQS[:order].copy()
    amps = _BASE_AMPS[:order].copy()
    offsets = _BASE_OFFSETS[:order].copy()
    phases = _BASE_PHASES[:order].copy()

    # Worst-case L1: every lag at its peak, with ~20% jitter headroom
    jitter_headroom = 1.20
    raw_l1 = np.sum(np.abs(offsets) + np.abs(amps)) * jitter_headroom

    scale = l1_target / raw_l1 if raw_l1 > 0 else 1.0

    return freqs, amps * scale, offsets * scale, phases


[docs] def sinusoid(n_samples, order, rng, freq_jitter=0.15, amp_jitter=0.15): """Sinusoidal coefficient schedule with per-lag variation. Each lag gets a distinct base frequency, amplitude, and offset with small random jitter for stochastic diversity across samples. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. freq_jitter : float, optional Relative frequency jitter range. Default is 0.15. amp_jitter : float, optional Relative amplitude jitter range. Default is 0.15. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ t = np.arange(n_samples) freqs, amps, offsets, phases = _lag_params(order) # Add small random jitter to keep stochasticity across samples freqs *= 1 + rng.uniform(-freq_jitter, freq_jitter, size=order) amps *= 1 + rng.uniform(-amp_jitter, amp_jitter, size=order) offsets *= 1 + rng.uniform(-0.1, 0.1, size=order) phases += rng.uniform(-np.pi / 8, np.pi / 8, size=order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): if k % 2 == 0: a[:, k] = offsets[k] + amps[k] * np.sin( 2 * np.pi * freqs[k] * t + phases[k] ) else: a[:, k] = offsets[k] + amps[k] * np.cos( 2 * np.pi * freqs[k] * t + phases[k] ) return a
[docs] def fourier(n_samples, order, rng, M=3, amp_jitter=0.2): """Fourier-series coefficient schedule with decaying harmonics. Each lag is represented by a truncated Fourier series with *M* harmonics whose amplitudes decay as ``1 / m^1.2``. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. M : int, optional Number of Fourier harmonics per lag. Default is 3. amp_jitter : float, optional Relative amplitude jitter per harmonic. Default is 0.2. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ t = np.arange(n_samples) freqs, amps, offsets, phases = _lag_params(order) freqs *= 1 + rng.uniform(-0.15, 0.15, size=order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): ak = offsets[k] * np.ones(n_samples) for m in range(1, M + 1): harm_amp = amps[k] / (m**1.2) * (1 + rng.uniform(-amp_jitter, amp_jitter)) phi = phases[k] * m + rng.uniform(-np.pi / 6, np.pi / 6) if (k + m) % 2 == 0: ak += harm_amp * np.sin(2 * np.pi * (m * freqs[k]) * t + phi) else: ak += harm_amp * np.cos(2 * np.pi * (m * freqs[k]) * t + phi) a[:, k] = ak return a
[docs] def quasiperiodic(n_samples, order, rng, freq_jitter=0.15): """Quasiperiodic coefficient schedule with two incommensurate frequencies. Each lag combines two sinusoidal components whose frequency ratio is irrational, producing non-repeating oscillatory patterns. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. freq_jitter : float, optional Relative frequency jitter range. Default is 0.15. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ t = np.arange(n_samples) freqs, amps, offsets, phases = _lag_params(order) # Second set of frequencies (shifted) freqs2 = ( np.roll(freqs, 1) * 1.3 * (1 + rng.uniform(-freq_jitter, freq_jitter, size=order)) ) amps2 = amps * rng.uniform(0.4, 0.8, size=order) phases2 = phases + rng.uniform(np.pi / 4, np.pi / 2, size=order) freqs *= 1 + rng.uniform(-freq_jitter, freq_jitter, size=order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): a[:, k] = ( offsets[k] + amps[k] * np.sin(2 * np.pi * freqs[k] * t + phases[k]) + amps2[k] * np.cos(2 * np.pi * freqs2[k] * t + phases2[k]) ) return a
[docs] def polynomial_drift(n_samples, order, rng, degree=3, coef_jitter=0.3): """Polynomial-drift coefficient schedule. Each lag's trajectory is a random polynomial of the given degree centred on the lag-specific offset. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. degree : int, optional Polynomial degree. Default is 3. coef_jitter : float, optional Relative jitter on polynomial coefficients. Default is 0.3. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ tn = np.linspace(-1, 1, n_samples) _, amps, offsets, _ = _lag_params(order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): # Start from the structured offset ak = offsets[k] * np.ones(n_samples) # Add polynomial variation scaled by lag amplitude betas = rng.normal( 0, amps[k] * (1 + rng.uniform(-coef_jitter, coef_jitter)), size=degree + 1 ) betas[0] = 0 # offset already handled for d in range(1, degree + 1): ak += betas[d] * (tn**d) a[:, k] = ak return a
[docs] def logistic_transition(n_samples, order, rng): """Single logistic-transition coefficient schedule. Each lag transitions between two distinct levels via a sigmoid whose centre and steepness are randomised. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ tau_range = (n_samples / 10, n_samples / 4) t = np.arange(n_samples) _, amps, offsets, _ = _lag_params(order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): # Transition between offset +/- amp (distinct per lag) alo = offsets[k] - amps[k] * (1 + rng.uniform(-0.2, 0.2)) ahi = offsets[k] + amps[k] * (1 + rng.uniform(-0.2, 0.2)) t0 = rng.integers(int(0.2 * n_samples), int(0.8 * n_samples)) tau = rng.uniform(tau_range[0], tau_range[1]) s = 1.0 / (1.0 + np.exp(-(t - t0) / tau)) a[:, k] = alo + (ahi - alo) * s return a
[docs] def multi_sigmoid(n_samples, order, rng, J=3): """Multi-sigmoid step coefficient schedule. Superimposes *J* sigmoid transitions of random magnitude and steepness around each lag's baseline offset. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. J : int, optional Number of sigmoid steps per lag. Default is 3. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ tau_range = (n_samples / 20, n_samples / 8) t = np.arange(n_samples) _, amps, offsets, _ = _lag_params(order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): ak = offsets[k] * np.ones(n_samples) t0s = np.sort(rng.integers(int(0.1 * n_samples), int(0.9 * n_samples), size=J)) for j in range(J): delta = rng.normal(0, amps[k] * 0.5) tau = rng.uniform(tau_range[0], tau_range[1]) s = 1.0 / (1.0 + np.exp(-(t - t0s[j]) / tau)) ak += delta * s a[:, k] = ak return a
[docs] def gaussian_bumps(n_samples, order, rng, J=4): """Gaussian-bump coefficient schedule. Overlays *J* Gaussian-shaped bumps of random centre, width, and sign on each lag's baseline offset. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. J : int, optional Number of Gaussian bumps per lag. Default is 4. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ width_range = (n_samples / 10, n_samples / 5) t = np.arange(n_samples) _, amps, offsets, _ = _lag_params(order) a = np.zeros((n_samples, order), dtype=np.float32) for k in range(order): ak = offsets[k] * np.ones(n_samples) for _ in range(J): mu = rng.uniform(0.1 * n_samples, 0.9 * n_samples) sig = rng.uniform(width_range[0], width_range[1]) c = rng.uniform(-amps[k], amps[k]) ak += c * np.exp(-0.5 * ((t - mu) / sig) ** 2) a[:, k] = ak return a
[docs] def smooth_random(n_samples, order, rng): """Smooth random (GP-like) coefficient schedule. Generates spectrally shaped Gaussian noise with a long correlation length, producing smooth, aperiodic coefficient trajectories centred on each lag's baseline. Parameters ---------- n_samples : int Length of the time series. order : int AR order (number of lags). rng : np.random.Generator NumPy random generator instance. Returns ------- a : np.ndarray Time-varying AR coefficients of shape ``(n_samples, order)``. """ smooth_sigma = n_samples / 10 _, amps, offsets, _ = _lag_params(order) a = np.zeros((n_samples, order), dtype=np.float32) freqs = np.fft.rfftfreq(n_samples) H = np.exp(-2 * (np.pi**2) * (smooth_sigma**2) * (freqs**2)) for k in range(order): w = rng.normal(0, 1, size=n_samples) W = np.fft.rfft(w) sm = np.fft.irfft(W * H, n=n_samples) sm = sm / (np.std(sm) + 1e-8) a[:, k] = (offsets[k] + amps[k] * sm).astype(np.float32) return a