Fields
Describe a quantity spread over space, such as heat in a rod or a chemical in a dish, and write its law once, for one site. Heat spreads from a hot spot in the middle of a rod with insulated ends:
from typing import Annotated
import numpy as np
from skcomplex.core.components.elements import Element, FieldElement
from skcomplex.core.components.laws import 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.specs import ParameterSpec, StateSpec
class HeatElement(Element):
def __init__(
self,
label,
*,
u: Annotated[float, StateSpec()] = 0.0,
D: Annotated[float, ParameterSpec()] = 1.0,
):
super().__init__(label)
self.u = u
self.D = D
def _get_laws(self, s):
return [RateLaw(s.u, s.D * laplacian(s.u))]
initial = np.zeros(400)
initial[200] = 20.0
space = LatticeSpace(400, spacing=0.05)
rod = FieldElement('rod', element=HeatElement, space=space, boundary={'u': 'zero-flux'}, values={'u': initial})
trajectory = Simulation(System(elements=[rod]), dt=0.1).prepare().run(until=0.5).trajectory_
heat = trajectory.values('rod.u')
round(float(heat[-1].max()), 3), heat.sum(axis=1)[[0, -1]].round(6).tolist()
(0.399, [20.0, 20.0])
A field holds one value per site of a lattice or per node of a network. FieldElement(label, element=..., space=...,
boundary=..., values=...) places a member of an element type on every site. The laws of the type are written for one
site, and they read the neighbouring sites through differential operators. The method of lines turns a field into
ordinary differential equations, so every ODE solver and backend runs it.
laplacian(u)lowers to the2d + 1-point stencil divided by the squared spacing, and to the graph Laplacian on aNetworkSpace.gradient(u, n_dim)anddivergence(vector)use central differences, andadvection(u, velocity)upwind differences.- Operators apply to any expression of the field's states and parameters, and they nest.
boundary={'u': 'periodic' | 'zero-flux' | ('fixed', value)}sets the edges of each state. Zero flux mirrors the field and conserves its total, and fixed edges read the given value. The lattice's ownboundaryis the default.- The equations keep the operators,
diff(u(t), t) == D*laplacian(u(t)), while the backends evaluate the stencils. On Numba, the stencils of a lattice are fused into one compiled loop. problem.stable_stepis the diffusive limith^2 / (2 d D)of explicit steps. Fixed-step solvers whosedtexceeds it warn before the run, with the limit.
Agents on a grid of the same shape couple to a field in co-simulation. A rule reads the field at a cell with
system.field('ground/pheromone.level', cell) and adds to it with system.deposit(name, cell, amount). The deposits
reach the field at the next synchronisation point. Lattice fields stream to Rerun as images.
The library has heat_diffusion, fisher_kpp, gray_scott, brusselator, fitzhugh_nagumo, schnakenberg, and
pheromone_foraging, which you select by name, such as create_system('gray_scott').
Discretizations and stiff solvers
Run the same laws with another discretization, and take long steps through stiff equations. The Kuramoto-Sivashinsky
equation, spectral in space and stepped by ETDRK4Solver far beyond the explicit limit:
from typing import Annotated
import numpy as np
from skcomplex.core.components.elements import Element, FieldElement
from skcomplex.core.components.laws import RateLaw
from skcomplex.core.components.laws.utils import divergence, 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 ETDRK4Solver
from skcomplex.core.specs import StateSpec
class ChaosElement(Element):
def __init__(self, label, *, u: Annotated[float, StateSpec()] = 0.0):
super().__init__(label)
self.u = u
def _get_laws(self, s):
return [RateLaw(s.u, -divergence([s.u**2 / 2]) - laplacian(s.u) - laplacian(laplacian(s.u)))]
positions = np.arange(1, 129) * 32 * np.pi / 128
line = FieldElement(
'line',
element=ChaosElement,
space=LatticeSpace(128, spacing=32 * np.pi / 128),
values={'u': np.cos(positions / 16) * (1 + np.sin(positions / 16))},
discretization='spectral',
)
state = Simulation(System(elements=[line]), dt=10, solver=ETDRK4Solver(dt=0.25)).prepare().run(until=100).state_
round(float(np.abs(state).max()), 1)
2.4
The argument FieldElement(..., discretization=...) chooses how space is discretized:
'spectral'fields on periodic lattices multiply Fourier modes, and the two-thirds rule removes aliasing from every operator, solaplacian(u)is exact for smooth fields.inverse_laplacian(u)solves Poisson equations.symmetry={'u': (None, 'odd')}keeps a state odd or even along an axis, which represents walls on a doubled lattice.LatticeSpace(spacing=(dx, dy))sets a spacing per axis.'finite-volume'fields takeFluxLaw(state, flux)s,du/dt + divergence(flux) = 0. They are updated with MUSCL reconstruction, minmod or van Leer limiters, and Rusanov or exact Godunov fluxes.SSPRKSolversteps them by the CFL condition and by the stability limit of their Laplacian rates at the current parameters, and conserves their totals.'finite-element'fields live on aMeshSpaceof the points and cells of a scikit-fem mesh, such asMeshSpace(grid.p, grid.t)forgrid = skfem.MeshTri.init_lshaped().refined(3). Their stiffness and mass matrices come from scikit-fem, and their boundary values can be functions of the coordinates. They stream to Rerun as colored meshes.
Stiff equations need implicit or exponential steps. The compiler splits the rate of every field into a linear part with
constant coefficients, such as D * laplacian(u) or -laplacian(laplacian(u)), and a nonlinear remainder.
IMEXEulerSolver,IMEX2Solver, andCrankNicolsonSolvertreat the linear part implicitly. They use sparse solves for finite differences and finite elements, and solves per Fourier mode for spectral fields.ETDRK4Solverintegrates the linear part exactly per mode, coupled states included, so stiff equations take steps far beyond the explicit limit.
The implicit methods of ScipyODESolver are the stiff path for finite-difference and finite-element fields, with the
adaptive error control that the fixed-step split steppers lack:
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.engine.solvers import ScipyODESolver
from skcomplex.systems import create_system
stiff = create_system('fisher_kpp', length=400, D=1.0, spacing=0.05)
simulation = Simulation(stiff, dt=1.0, solver=ScipyODESolver(method='BDF')).prepare().run(until=5)
simulation.stepper_.jacobian_pattern_.nnz, simulation.state_.max().round(3)
(1198, 0.954)
The compiler derives the sparsity pattern of the Jacobian from the laws. The pattern covers member and shared reads, the neighbour tables of stencils, the sparse matrices of finite elements, and the reconstruction stencils of finite volumes. It holds dense blocks where couplings are known only at run time, such as pairs, spectral operators, and callables.
ScipyODESolver(method='BDF')andScipyODESolver(method='Radau')receive the pattern. SciPy then estimates the Jacobian with one drift evaluation per group of independent columns, and factors it with sparse LU.LSODAreceives the half bandwidths of a pattern narrower than a tenth of the size.- The option
jacobianselects the path:'auto'by default,'exact'for the sparse Jacobian of the laws differentiated by Sage, which laws without a symbolic form cannot give, and'dense'for the dense estimate. - Explicit methods ignore it, and
RK45warns when one call spans more than 1000 stable steps of the diffusion of a field.
On Fisher-KPP on 4000 points, whose largest eigenvalue is about 1600, run to t = 10, BDF with the dense estimate took
35 to 50 s. With the pattern it took about 0.2 s, with the exact Jacobian 0.3 s, Radau with the pattern 0.7 s, and
CrankNicolsonSolver(dt=0.01) 0.8 s. Spectral fields keep ETDRK4Solver and the IMEX steppers. These win there,
because the linear part is diagonal in Fourier space while its pattern is dense.
The system decaying_turbulence is two-dimensional incompressible flow in vorticity-streamfunction form, and
rayleigh_benard adds Boussinesq convection, with Ra and Pr. The library also has kuramoto_sivashinsky,
complex_ginzburg_landau, swift_hohenberg, heat_l_shape, burgers, and shallow_water.
Nonlocal operators
Read a field beyond the nearest sites: smooth it against a kernel, solve for a potential, or look ahead by a distance. Each rate below changes a Gaussian bump on a line in its own way:
from typing import Annotated
import numpy as np
from sage.all__sagemath_symbolics import exp, pi, sqrt
from skcomplex.core.components.elements import Element, FieldElement
from skcomplex.core.components.interactions import RateInteraction
from skcomplex.core.components.laws.utils import convolve, displacement, inverse_helmholtz, partial, shifted
from skcomplex.core.components.spaces import LatticeSpace
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, StateSpec
class SiteElement(Element):
def __init__(
self,
label,
*,
u: Annotated[float, StateSpec()] = 0.0,
k: Annotated[float, ParameterSpec()] = 2.0,
):
super().__init__(label)
self.u = u
self.k = k
def rate_of(rate):
centers = np.arange(128) * 0.1
values = {'u': np.exp(-((centers - 6.4) ** 2))}
line = FieldElement('line', element=SiteElement, space=LatticeSpace(128, spacing=0.1), values=values)
interaction = RateInteraction('rate', target='line', state='u', rate=rate)
simulation = Simulation(System(elements=[line], interactions=[interaction]), dt=0.05)
return simulation.prepare().run(until=0.2).trajectory_.values('line.u')
def smoothing(s):
kernel = exp(-displacement(0) ** 2) / sqrt(pi)
return convolve(s.target.u, kernel) - s.target.u
def screened_drift(s):
return partial(inverse_helmholtz(s.target.u, s.target.k), 0)
def look_ahead(s):
return shifted(s.target.u, [0.25 + s.target.u]) - s.target.u
{rate.__name__: rate_of(rate)[-1].sum().round(3) for rate in (smoothing, screened_drift, look_ahead)}
{'smoothing': 17.725, 'screened_drift': 17.725, 'look_ahead': 16.691}
The operators below of skcomplex.core.components.laws.utils read beyond the nearest sites, and a law writes them as a
reference writes them:
inverse_helmholtz(expression, screening, boundary=None)is the solution of(laplacian - screening**2) psi = expression, andinverse_laplacian(u)is its case without screening. It runs on spectral fields, and on finite-difference lattices with periodic, zero-flux, or fixed edges, where the fast transforms of SciPy invert the stencil thatlaplacianuses. On networks and meshes it uses a sparse factorization. An operand that mixes states of different boundaries needsboundary=.convolve(expression, kernel, radius=None)sums an expression over the sites of a lattice against a kernel written in the symbolsdisplacement(axis)and the parameters. The kernel is sampled when the problem is compiled. It wraps around periodic edges and is cut at fixed and zero-flux edges, where the operand is padded with the fixed value or its mirror image.shifted(expression, offset, order=1)reads an expression at a physical displacement, one component per axis, which may depend on the states. A displacement of whole sites reads the neighbour tables and stays compilable to Numba. Any other one interpolates withorder=1ororder=3, following the boundary of the field.cumulative_integral(expression, axis, reverse=False)integrates an expression along one axis of a lattice, from the start of the domain to every site, or from every site to the end withreverse=True.
On a phase space, a convolution acts along the position axes at every class, and integral_transform integrates over
class axes against a kernel of two points that need not be a function of their displacement, as the
phase-space fields describe.
The stencil operators read a nonlocal result at the neighbouring sites, so partial and gradient apply to an
inverted streamfunction. Laws with these operators run on the NumPy backend, except whole-site reads. The sparsity
pattern of the Jacobian marks a convolution as coupling the sites within its radius, and an inversion as dense. So
implicit solvers stay correct but slow, and explicit or exponential solvers suit these laws best. An observable may use
the operators too, which streams a streamfunction as an image.
The misuses are issues of the compiler's report, each with the path of the law and a fix:
law-spacefor a convolution or a shifted read on a network or a mesh, and for a spectral operator on a lattice without periodic edges,kernel-statefor a kernel that reads a state,screeningfor a screening that reads a state or whose square can be negative,operator-spacesfor an operand that reads fields of more than one space.
A field can also couple its sites through a function of both ends, without an operator. A CouplingInteraction on a
field sums the function over the neighbours of every site, and reads a fixed boundary value as laplacian does:
from typing import Annotated
import numpy as np
from skcomplex.core.components.elements import Element, FieldElement
from skcomplex.core.components.interactions import CouplingInteraction
from skcomplex.core.components.spaces import LatticeSpace
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import StateSpec
class CellElement(Element):
def __init__(self, label, *, u: Annotated[float, StateSpec()] = 0.0):
super().__init__(label)
self.u = u
cells = FieldElement('cells', element=CellElement, space=LatticeSpace(32), values={'u': np.eye(32)[16]})
exchange = CouplingInteraction(
'exchange',
target='cells',
read='u',
write='u',
function=lambda s: 0.2 * (s.other.u - s.member.u),
)
system = System(elements=[cells], interactions=[exchange])
trajectory = Simulation(system, dt=0.5).prepare().run(until=2.0).trajectory_
trajectory.values('cells.u')[-1].sum().round(6)
1.0
A constant times s.other.u - s.member.u lowers to laplacian, so spectral and finite-element fields keep their own
operator.
amari_neural_field holds a bump with a convolution, double_gyre streams the streamfunction of a closed basin,
gkt_traffic brakes on the traffic read ahead, and active_viscoelastic_gel solves its force balance at every step.