Delays, events, drivers, and noise
Make laws read the past, change states at discrete events, follow inputs that switch in time, and take noise in the convention of your field.
Delays
Read a state as it was some time ago, as in a feedback that acts with a lag. delayed(x, tau) from
skcomplex.core.components.laws.utils is the state x at t - tau.
from typing import Annotated
import numpy as np
from skcomplex.core.components.elements import Element
from skcomplex.core.components.laws import RateLaw
from skcomplex.core.components.laws.utils import delayed
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, StateSpec
def wavy_history(t):
return 0.5 + 0.1 * np.sin(t)
class MackeyGlassElement(Element):
def __init__(
self,
label,
*,
x: Annotated[float, StateSpec(history=wavy_history)] = 0.5,
beta: Annotated[float, ParameterSpec()] = 0.2,
gamma: Annotated[float, ParameterSpec()] = 0.1,
n: Annotated[float, ParameterSpec()] = 10.0,
tau: Annotated[float, ParameterSpec()] = 17.0,
):
super().__init__(label)
self.x = x
self.beta = beta
self.gamma = gamma
self.n = n
self.tau = tau
def _get_laws(self, s):
past = delayed(s.x, s.tau)
return [RateLaw(s.x, s.beta * past / (1 + past**s.n) - s.gamma * s.x)]
cells = System(elements=[MackeyGlassElement('cells')])
round(float(Simulation(cells, dt=0.5).prepare().run(until=300).trajectory_.values('cells.x')[-1]), 3)
1.004
- The delay is a constant or depends on the state.
- The solvers keep a history of dense output and step no further than the smallest delay, by the method of steps.
- Before the start, the history is the initial value, or what the state declares with
StateSpec(history=...), a number or a function of time.
Events
Change states at the moment a condition is met, such as a ball that bounces off the floor.
CrossingLaw(guard, reset, direction, terminal, label='') fires when the guard crosses zero.
from typing import Annotated
from skcomplex.core.components.elements import Element
from skcomplex.core.components.laws import CrossingLaw, RateLaw
from skcomplex.core.components.laws.utils import derivative
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, StateSpec
class BallElement(Element):
def __init__(
self,
label,
*,
h: Annotated[float, StateSpec(order=2)] = 1.0,
g: Annotated[float, ParameterSpec()] = 9.81,
e: Annotated[float, ParameterSpec()] = 0.8,
):
super().__init__(label)
self.h = h
self.g = g
self.e = e
def _get_laws(self, s):
bounce = {derivative(s.h, 1): -s.e * derivative(s.h, 1)}
return [RateLaw(s.h, -s.g), CrossingLaw(s.h, bounce, direction=-1, label='impact')]
trajectory = Simulation(System(elements=[BallElement('ball')]), dt=0.01).prepare().run(until=3).trajectory_
[round(event.time, 6) for event in trajectory.events]
[0.451524, 1.173961, 1.751912, 2.214272, 2.58416, 2.880071]
- The guard fires upward for
direction=1, downward for-1, and either way for0. - The solver locates the crossing on its interpolant to the precision of the root finder. It applies the reset to every named state at once, and restarts from there.
- A terminal event ends the run at the crossing.
- Every frame carries the events since the previous one. The trajectory keeps them all, with their times and the states before and after the reset.
- A guard that fires again right after its reset raises an error that names the chattering event.
Drivers
Feed a system an input that switches or bends in time, such as a step, a ramp, or measured data. step, ramp,
pulse, pulse_train, and series from skcomplex.core.components.laws.utils are inputs of the time s.t.
from typing import Annotated
from skcomplex.core.components.elements import Element
from skcomplex.core.components.laws import RateLaw
from skcomplex.core.components.laws.utils import step
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, StateSpec
class FilterElement(Element):
def __init__(
self,
label,
*,
y: Annotated[float, StateSpec()] = 0.0,
k: Annotated[float, ParameterSpec()] = 1.0,
):
super().__init__(label)
self.y = y
self.k = k
def _get_laws(self, s):
return [RateLaw(s.y, -s.k * s.y + step(s.t, 5.0))]
filtered = Simulation(System(elements=[FilterElement('filter')]), dt=0.5).prepare().run(until=10).trajectory_
round(float(filtered.values('filter.y')[-1]), 4)
0.9933
series interpolates data with 'linear' or 'previous' interpolation. The compiler finds the times where the inputs
jump or bend, including the periodic edges of pulse trains. The solvers stop at every one and evaluate the inputs on the
correct side of it, so a step response is exact to the solver tolerance.
Noise conventions
Read noise in the convention of your field, Itô, Stratonovich, or anti-Itô.
NoiseLaw(..., convention='stratonovich') or 'anti-ito' declares how a noise law is read.
from typing import Annotated
from skcomplex.core.components.elements import Element
from skcomplex.core.components.laws import NoiseLaw, RateLaw
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.engine.solvers import MilsteinSolver
from skcomplex.core.specs import ParameterSpec, StateSpec
class GrowthElement(Element):
def __init__(
self,
label,
*,
x: Annotated[float, StateSpec()] = 1.0,
mu: Annotated[float, ParameterSpec()] = 0.5,
sigma: Annotated[float, ParameterSpec()] = 0.8,
):
super().__init__(label)
self.x = x
self.mu = mu
self.sigma = sigma
def _get_laws(self, s):
return [RateLaw(s.x, s.mu * s.x), NoiseLaw(s.x, s.sigma * s.x, convention='stratonovich')]
growth = System(elements=[GrowthElement('asset')])
round(float(Simulation(growth, solver=MilsteinSolver(dt=0.01), random_state=0).prepare().run(until=1).state_[0]), 3)
4.235
The compiler adds the drift correction α g ∂g/∂x, with α = 1/2 for Stratonovich and 1 for anti-Itô. So every SDE
solver integrates the equivalent Itô equation.
EulerMaruyamaSolverhas strong order 1/2.MilsteinSolver, in Platen's derivative-free form, has strong order 1 for diagonal noise.HeunSolveraverages the drift over every step.
Shared and correlated noise
Drive several states with one source of noise, or with sources that move together, such as the prices of two related
assets. NoiseLaw(..., process=...) names the Wiener process a law draws.
from typing import Annotated
import numpy as np
from skcomplex.core.components.elements import Element
from skcomplex.core.components.interactions import Interaction
from skcomplex.core.components.laws import NoiseLaw
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.engine.solvers import EulerMaruyamaSolver
from skcomplex.core.specs import RoleSpec, StateSpec, WienerProcessSpec
class StockElement(Element):
def __init__(self, label, *, n: Annotated[float, StateSpec()] = 0.0):
super().__init__(label)
self.n = n
class CorrelatedNoiseInteraction(Interaction):
def __init__(
self,
label,
*,
first: Annotated[str, RoleSpec()],
second: Annotated[str, RoleSpec()],
noise: Annotated[WienerProcessSpec, RoleSpec(noise=True)],
):
super().__init__(label)
self.first = first
self.second = second
self.noise = noise
def _get_laws(self, s):
return [NoiseLaw(s.first.n, 1.0, process=s.noise.dW[0]), NoiseLaw(s.second.n, 1.0, process=s.noise.dW[1])]
processes = WienerProcessSpec('market', dimension=2, covariance=[[1.0, 0.8], [0.8, 1.0]])
market = System(
elements=[StockElement('a'), StockElement('b')],
interactions=[CorrelatedNoiseInteraction('drive', first='a', second='b', noise=processes)],
)
solver = EulerMaruyamaSolver(dt=1.0)
trajectory = Simulation(market, solver=solver, dt=1.0, random_state=0).prepare().run(until=2000).trajectory_
round(float(np.corrcoef(np.diff(trajectory.values('a.n')), np.diff(trajectory.values('b.n')))[0, 1]), 1)
0.8
The measured correlation of the increments is the 0.8 of the covariance.
- Without a process, every element and every group member takes its own independent process.
- Laws of one element, or of one member, that name the same label share one increment. So a reaction can drive every species it changes with one process.
- A
WienerProcessSpec(label, dimension, covariance)fromskcomplex.core.specsdeclares processes whose increments over a stephhave the covarianceh C. - A
WienerProcessSpecis no element. An interaction binds it through a role annotated withRoleSpec(noise=True), and its noise laws name processkwiths.<role>.dW[k], which every element, and every member of a group, can share. - Interactions of one system that bind processes of one label share them and their correlations.
- The covariance is fixed when the system compiles. NumPy factors it once, so the steppers scale standard normal increments drawn from the run's random generator.
The Stratonovich and anti-Itô corrections of each law use the full diffusion matrix and the covariance. So a state that several laws drive, and laws that share a process, are corrected exactly. Laws that share a process declare one convention. Validation reports:
- laws of different conventions on one process,
- a Stratonovich or anti-Itô amplitude that reads a state another element drives through its process,
- a covariance that is not symmetric positive semidefinite,
- a handle
dWused anywhere but as the process of a noise law.
Conserved noise
Add thermal noise to a field without changing its total, as in System B and active field theories.
NoiseLaw(target, value, conserved=True) on a state of a field on a lattice adds the divergence of a random flux,
div(value eta).
from typing import Annotated
from sage.all__sagemath_symbolics import sqrt
from skcomplex.core.components.elements import Element, FieldElement
from skcomplex.core.components.laws import NoiseLaw, RateLaw
from skcomplex.core.components.laws.utils import laplacian
from skcomplex.core.components.spaces import LatticeSpace
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.engine.solvers import EulerMaruyamaSolver
from skcomplex.core.specs import ParameterSpec, StateSpec
class DensityElement(Element):
def __init__(
self,
label,
*,
rho: Annotated[float, StateSpec()] = 1.0,
D: Annotated[float, ParameterSpec()] = 1.0,
T: Annotated[float, ParameterSpec()] = 0.1,
):
super().__init__(label)
self.rho = rho
self.D = D
self.T = T
def _get_laws(self, s):
return [RateLaw(s.rho, s.D * laplacian(s.rho)), NoiseLaw(s.rho, sqrt(2 * s.T), conserved=True)]
gas = System(elements=[FieldElement('gas', element=DensityElement, space=LatticeSpace((16, 16)))])
solver = EulerMaruyamaSolver(dt=0.01)
density = Simulation(gas, solver=solver, random_state=0).prepare().run(until=5).trajectory_.values('gas.rho')
round(float(density[-1].sum()), 6), bool(density[-1].std() > 0)
(256.0, True)
The density fluctuates, and its total stays at 256, one per site.
- Every link between neighbouring sites draws one Wiener process. Site
igainsvalue / htimes the increment of the link from its previous neighbour and loses the same amount on the link to its next one, along every dimension of spacingh. - The total of the field then changes only by its rate laws, exactly to rounding.
- The links that leave a lattice through a zero-flux or fixed edge carry no process.
- The value is a number or an expression of parameters, the same on every link, and the field uses finite differences on a square lattice.
- Validation reports a conserved noise law on any other state with the code
noise-unsupported.
Which solvers support what
Solvers and backends declare the noise structures they draw.
'diagonal'is one process per state.'shared'is a process that drives several states.'correlated'is the processes of aWienerProcessSpecwith a covariance.'conserved'is the link processes of conserved noise.
EulerMaruyamaSolver, HeunSolver, and the NumPy and Numba backends support all four. MilsteinSolver, whose
derivative-free correction holds for diagonal noise only, and LammpsSolver support diagonal noise. Validation
reports a system with shared, correlated, or conserved noise for them before compiling it. The compatibility page lists
the declarations.
SciPy, RK4, Euler-Maruyama, Milstein, and Heun handle delays, events, and drivers. Velocity Verlet, Gillespie, and map iteration reject delays and events, with an error that names the solvers to use.
Ready-made systems with delays and events
The catalog has these systems ready.
- Delays, events, and drivers:
mackey_glassin physiology,hutchinsonin ecology,integrate_and_fireandizhikevichin neuroscience,delayed_kuramotoin synchronization,stochastic_resonanceandbrownian_near_wallin soft matter, andbouncing_ball,newtons_cradle, andcircular_billiardin mechanics. - Noise:
BindingInteractionandDissociationInteractiontakelangevin=Truefor chemical Langevin equations, as inchemical_langevin_network.correlated_geometric_brownian_motiondrives two assets with correlated processes, andactive_system_bandcahn_hilliardwith atemperaturetake conserved noise.