Leading-following tipping cascade
A cascade of two tipping elements, a leading fold driven by a ramp and a follower forced by the leader.
- Name:
leading_following_cascade - Field:
physics/climate - Formalism: stochastic differential equations
- References: Dekker, von der Heydt, and Dijkstra 2018; Fang, Wang, Kurths, and Fan 2025
Select this system
from skcomplex.systems import create_simulation, create_system, describe
system = create_system('leading_following_cascade')
entry = describe('leading_following_cascade')
trajectory = create_simulation('leading_following_cascade').prepare().run().trajectory_
Factory
@register_system(
field='physics/climate',
tags=('tipping points', 'cascades', 'fold', 'hopf bifurcation', 'ramp', 'sde'),
references=('Dekker, von der Heydt, and Dijkstra 2018', 'Fang, Wang, Kurths, and Fan 2025'),
defaults={'until': 500.0, 'dt': 0.5, 'solver': EulerMaruyamaSolver(dt=0.01)},
)
def leading_following_cascade(
follower: str = 'fold',
*,
coupling: float = 0.4,
rate: float = 0.004,
sigma: float = 0.02,
phi0: float = -1.0,
phi1: float = 1.0,
c: float = 0.0,
mu: float = 0.0,
omega: float = 1.0,
) -> System:
"""Return a cascade of two tipping elements, a leading fold driven by a ramp and a follower forced by the leader.
The leading element follows `dx = (-x³ + x + phi(t)) dt + sigma dW`, whose control rises as `phi = phi0 + rate t`
until `phi1`, and it tips from its lower to its upper branch when `phi` passes `2 / (3 √3)`. The following element
is forced linearly by the state of the leader, Eq. 10 of Fang et al. after Dekker et al. A fold follower obeys
`dy = (-y³ + y + c + coupling x) dt + sigma dW`, and it tips too, a domino cascade, when the tipped leader moves
its control `c + coupling x` past `2 / (3 √3)`. A Hopf follower obeys
`dw = ((mu + coupling x + i omega) w - |w|² w) dt + sigma dW`, and the tipped leader can carry it through its Hopf
bifurcation onto an oscillation of radius `sqrt(mu + coupling x)`. The follower can show rising variance and
autocorrelation before the leader tips even when it stays far from its own tipping point.
Args:
follower:
The normal form of the following element, `'fold'` or `'hopf'`.
coupling:
The strength of the linear forcing of the follower by the leader.
rate:
The rate at which the control of the leader rises.
sigma:
The amplitude of the noise of both elements.
phi0:
The initial control of the leader.
phi1:
The final control of the leader.
c:
The control of a fold follower, without the coupling.
mu:
The distance of a Hopf follower from its bifurcation, without the coupling.
omega:
The frequency of a Hopf follower.
Returns:
system:
The system, with the leader `leader.x` and the follower `follower.y`, and also `follower.z` for the Hopf
follower.
Raises:
ValidationError:
The follower is neither `'fold'` nor `'hopf'`.
Examples:
>>> from skcomplex.systems.physics.climate import leading_following_cascade
>>> leading_following_cascade('hopf').elements[1].omega
1.0
"""
if follower not in FOLLOWERS:
error_msg = f'Unknown follower {follower!r}. Use one of {", ".join(FOLLOWERS)}.'
raise ValidationError(error_msg)
start = _lower_branch(-1.0, 1.0, phi0)
leader = RampedFoldElement('leader', x=start, sigma=sigma, phi0=phi0, phi1=phi1, rate=rate)
if follower == 'fold':
follows: Element = FoldFollowerElement(
'follower',
y=_lower_branch(-1.0, 1.0, c + coupling * start),
c=c,
sigma=sigma,
)
forcing = [
RateInteraction(
'forcing',
target='follower',
state='y',
sources=['leader'],
rate=lambda s: coupling * s.sources[0].x,
),
]
else:
follows = HopfFollowerElement('follower', mu=mu, omega=omega, sigma=sigma)
forcing = [
RateInteraction(
'forcing_y',
target='follower',
state='y',
sources=['leader'],
rate=lambda s: coupling * s.sources[0].x * s.target.y,
),
RateInteraction(
'forcing_z',
target='follower',
state='z',
sources=['leader'],
rate=lambda s: coupling * s.sources[0].x * s.target.z,
),
]
return System(elements=[leader, follows], interactions=forcing)
Components
Elements:
RampedFoldElement, fromElementFoldFollowerElement, fromElement
Interactions:
RateInteraction
class RampedFoldElement(Element):
"""Stochastic fold `dx = (a x^3 + b x + phi(t)) dt + sigma dW` whose control rises linearly from `phi0` to `phi1`.
For `a < 0 < b` the fold has two stable branches for `|phi| < phi_c = sqrt(-4 b^3 / (27 a))`, and the lower
branch vanishes at `phi = phi_c`. The control rises at the rate `rate` and stays at `phi1` once it reaches it.
Args:
label:
The label of the system.
visual:
How the element is drawn, or `None` for `default_visual_`.
x:
The initial state.
a:
The cubic coefficient, negative.
b:
The linear coefficient, positive.
sigma:
The amplitude of the noise.
phi0:
The initial control.
phi1:
The final control.
rate:
The rate at which the control rises.
Examples:
>>> from skcomplex.systems.physics.climate import RampedFoldElement
>>> RampedFoldElement('system').rate
0.004
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
x: Annotated[float, StateSpec()] = -1.3247,
a: Annotated[float, ParameterSpec()] = -1.0,
b: Annotated[float, ParameterSpec()] = 1.0,
sigma: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.02,
phi0: float = -1.0,
phi1: float = 1.0,
rate: float = 0.004,
) -> None:
super().__init__(label, visual=visual)
self.x = x
self.a = a
self.b = b
self.sigma = sigma
self.phi0 = phi0
self.phi1 = phi1
self.rate = rate
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return the fold with its ramped control and its noise.
Args:
s:
The namespace of the symbols the laws can use.
Returns:
laws:
The laws of the component.
"""
control = self.phi0 + ramp(s.t, 0.0, (self.phi1 - self.phi0) / self.rate, self.phi1 - self.phi0)
return [RateLaw(s.x, s.a * s.x**3 + s.b * s.x + control), NoiseLaw(s.x, s.sigma)]
class FoldFollowerElement(Element):
"""Stochastic fold `dy = (-y³ + y + c) dt + sigma dW`, whose control a leading element shifts.
The fold has a lower and an upper stable branch while `|c| < 2 / (3 √3)`, and a coupling adds to its control.
Args:
label:
The label of the follower.
visual:
How the follower is drawn, or `None` for `default_visual_`.
y:
The initial state.
c:
The control of the fold, without the coupling.
sigma:
The amplitude of the noise.
Attributes:
default_visual_:
The visual of the follower when it sets none.
Examples:
>>> from skcomplex.systems.physics.climate import FoldFollowerElement
>>> FoldFollowerElement('follower').c
0.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
y: Annotated[float, StateSpec()] = -1.0,
c: Annotated[float, ParameterSpec()] = 0.0,
sigma: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.02,
) -> None:
super().__init__(label, visual=visual)
self.y = y
self.c = c
self.sigma = sigma
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return the fold and its noise.
Args:
s:
The namespace of the symbols the laws can use.
Returns:
laws:
The rate law and the noise law of the state.
"""
return [RateLaw(s.y, -(s.y**3) + s.y + s.c), NoiseLaw(s.y, s.sigma)]
class RateInteraction(Interaction):
"""Rate `rate(ns)` and noise `noise(ns) dW` added to the highest time derivative of a state of a target.
On a state of order 1 the rate is its first derivative, and on a state of order `k` it adds to the `k`-th
derivative, so rates on one state superpose. With a divisor `d` the rate and the noise are divided by `d`, the name
of a state or parameter of the target, such as an inertia, a friction, or a capacitance, which backends can read.
A rate, a noise, or a divisor function takes the namespace of the interaction, with the namespaces `target` and
`sources` of the bound elements, the time `t`, and the symbols of the parameters a subclass declares. A number
stands for a constant, and a number on a vector state applies to every component. The noise is a Wiener process per
component and member by default, a process of a `WienerProcessSpec` to share or correlate it across states and
members, or a fractional Brownian motion of a Hurst index.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the element or group whose state the rate changes.
state:
The name of the state of the target that the rate changes.
rate:
A function of the namespace that returns the rate, or a number.
sources:
The paths of the elements or groups the rate and the noise read but do not change.
noise:
A function of the namespace that returns the amplitude of the noise, a number, or `None` for no noise.
divisor:
The name of the state or parameter of the target that divides the rate and the noise, a function of the
namespace of the target that returns the divisor, such as a moment of inertia `m l^2`, or `None`.
process:
The Wiener processes that drive the noise, or `None` for one independent process per component and member.
index:
The process of `process` that drives the noise, or `None` for every process, one per component of a vector
state.
convention:
The stochastic calculus of the noise, `'ito'`, `'stratonovich'`, or `'anti-ito'`.
hurst:
The Hurst index in `(0, 1)` of a fractional Brownian motion that drives the noise, a number or a function
of the namespace that returns a parameter, or `None` for a Wiener process.
conserved:
Whether the noise is the divergence of a random flux on the links of the lattice of a field, so it keeps the
total of the state.
Raises:
ValidationError:
The state is not a name, a function is neither callable nor a number, the convention is unknown, the Hurst
index is outside `(0, 1)`, or the noise options cannot combine.
Examples:
>>> from skcomplex.core.components.elements import CompartmentElement
>>> from skcomplex.core.components.interactions import RateInteraction
>>> from skcomplex.core.components.system import System
>>> from skcomplex.core.engine.runtime import Simulation
>>> decay = RateInteraction('decay', target='stock', state='n', rate=lambda s: -0.5 * s.target.n)
>>> system = System(elements=[CompartmentElement('stock', n=8.0)], interactions=[decay])
>>> trajectory = Simulation(system, dt=0.01).prepare().run(until=2.0).trajectory_
>>> round(float(trajectory.values('stock.n')[-1]), 3)
2.943
"""
law_classes: ClassVar[tuple[type[BaseLaw], ...]] = (RateLaw, NoiseLaw, FractionalNoiseLaw)
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec(elements=(ElementKind.ANY,), states=('state', 'divisor'))],
state: str = 'x',
rate: Callable[[SimpleNamespace], object] | float = 0.0,
sources: Annotated[Sequence[str], RoleSpec(variadic=True, elements=(ElementKind.ANY,))] = (),
noise: Callable[[SimpleNamespace], object] | float | None = None,
divisor: str | Callable[[SimpleNamespace], object] | None = None,
process: Annotated[WienerProcessSpec | None, RoleSpec(noise=True)] = None,
index: int | None = None,
convention: str = 'ito',
hurst: Callable[[SimpleNamespace], object] | float | None = None,
conserved: bool = False,
) -> None:
super().__init__(label)
self.target = target
self.state = state
self.rate = rate
self.sources = sources
self.noise = noise
self.divisor = divisor
self.process = process
self.index = index
self.convention = convention
self.hurst = hurst
self.conserved = conserved
def _check_functions(self) -> None:
"""Raise an error for a state that is not a name, or a function that is neither callable nor a number."""
if not isinstance(self.state, str) or not self.state.isidentifier():
error_msg = (
f'{type(self).__name__} {self.label!r} changes the state {self.state!r}, which is not a name of a '
'state.'
)
raise ValidationError(error_msg)
for name in ('rate', 'noise'):
value = getattr(self, name)
if not (value is None or callable(value) or isinstance(value, int | float)):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a function of the namespace or a number.'
raise ValidationError(error_msg)
if not (
self.divisor is None
or callable(self.divisor)
or (isinstance(self.divisor, str) and self.divisor.isidentifier())
):
error_msg = (
f'The divisor of {self.label!r} is {self.divisor!r}. Give the name of a state or parameter, or a '
'function of the namespace of the target.'
)
raise ValidationError(error_msg)
def _check_noise(self) -> None:
"""Raise an error for an unknown convention, a Hurst index outside `(0, 1)`, or noise options that conflict."""
if self.convention not in CONVENTIONS:
error_msg = f'Unknown noise convention {self.convention!r}. Use one of {", ".join(CONVENTIONS)}.'
raise ValidationError(error_msg)
if not (
self.hurst is None or callable(self.hurst) or (isinstance(self.hurst, int | float) and 0 < self.hurst < 1)
):
error_msg = f'The Hurst index of {self.label!r} is {self.hurst!r}. Give a number in (0, 1) or a function.'
raise ValidationError(error_msg)
if self.index is not None and self.process is None:
error_msg = f'{self.label!r} picks the process {self.index!r} but binds no processes. Give `process`.'
raise ValidationError(error_msg)
options = [
name
for name, given in (('hurst', self.hurst), ('process', self.process), ('conserved', self.conserved))
if given
]
if len(options) > 1:
error_msg = f'The noise of {self.label!r} takes {" and ".join(options)}. Give at most one of them.'
raise ValidationError(error_msg)
def _check(self) -> None:
"""Raise an error for a state, functions, or noise options the interaction cannot take.
Raises:
ValidationError:
The type declares states, the state is not a name, a function is neither callable nor a number, the
convention is unknown, the Hurst index is outside `(0, 1)`, or the noise options cannot combine.
"""
super()._check()
self._check_functions()
self._check_noise()
def _evaluate(self, function: Callable[[SimpleNamespace], object] | float, namespace: SimpleNamespace) -> object:
"""Return the value of a function of the namespace, or a number as it is."""
return function(namespace) if callable(function) else function
def _broadcast(self, value: object, state: object) -> object:
"""Return a value with one component per component of a vector state, repeating a number."""
if isinstance(state, FreeModuleElement) and not isinstance(value, FreeModuleElement | list | tuple):
return vector(SR, [value] * len(state))
if isinstance(value, list | tuple):
return vector(SR, value)
return value
def _divide(self, value: object, namespace: SimpleNamespace) -> object:
"""Return a value divided by the divisor of the target, or the value without a divisor."""
if self.divisor is None:
return value
divisor = (
self.divisor(self._get_member(namespace, 'target'))
if callable(self.divisor)
else getattr(self._get_member(namespace, 'target'), self.divisor)
)
return operator.truediv(value, divisor)
def _get_rate(self, namespace: SimpleNamespace) -> object:
"""Return the rate before the divisor, the hook a subclass overrides to derive it from its parameters."""
return self._evaluate(self.rate, namespace)
def _get_noise(self, namespace: SimpleNamespace) -> object:
"""Return the noise amplitude before the divisor, the hook a subclass overrides, or `None` for no noise."""
return None if self.noise is None else self._evaluate(self.noise, namespace)
def _get_noise_law(self, state: object, amplitude: object, namespace: SimpleNamespace) -> BasePart:
"""Return the noise law of an amplitude, by the process, the convention, and the Hurst index."""
if self.hurst is not None:
hurst = self._evaluate(self.hurst, namespace)
return FractionalNoiseLaw(state, amplitude, hurst=hurst, convention=self.convention)
process = None
if self.process is not None:
handle = self._get_member(namespace, 'process').dW
process = handle if self.index is None else handle[self.index]
return NoiseLaw(state, amplitude, convention=self.convention, process=process, conserved=self.conserved)
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the rate law of the state and its noise law, both divided by the divisor.
Args:
namespace:
The namespace of the symbols the laws can use, with the namespaces of the target and the sources.
Returns:
laws:
The rate law, and the noise law when the interaction has noise.
"""
state = getattr(self._get_member(namespace, 'target'), self.state)
rate = self._divide(self._broadcast(self._get_rate(namespace), state), namespace)
laws: list[BasePart] = [RateLaw(state, rate)]
noise = self._get_noise(namespace)
if noise is not None:
laws.append(self._get_noise_law(state, self._divide(noise, namespace), namespace))
return laws
Equations
leader.x: diff(x_leader(t), t) == a_leader*x_leader(t)^3 + b_leader*x_leader(t) + where(t < 0.0, 0, where(t < 500.0, 0.004*t, 2.0)) - 1.0
follower.y: diff(y_follower(t), t) == -y_follower(t)^3 + c_follower + 0.4*x_leader(t) + y_follower(t)