Observe
Record the quantities you care about at every frame, check that conserved ones stay constant, and watch a run live.
Observables
Record the quantities you care about at every frame, such as the order of a group of oscillators, beside the states of
a run. An ObservableSpec is a label with an expression or a group reduction, which a run computes at every frame,
records in trajectories under the path of the system, and logs to Rerun.
from typing import Annotated
import numpy as np
from sage.all__sagemath_symbolics import I, exp, pi
from skcomplex.core.components.elements import Element, GroupElement
from skcomplex.core.components.laws import RateLaw
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ObservableSpec, ParameterSpec, StateSpec
class OscillatorElement(Element):
def __init__(
self,
label,
*,
theta: Annotated[float, StateSpec(period=2 * pi)] = 0.0,
omega: Annotated[float, ParameterSpec()] = 1.0,
):
super().__init__(label)
self.theta = theta
self.omega = omega
def _get_laws(self, s):
return [RateLaw(s.theta, s.omega)]
def phase_vector(member):
return exp(I * member.theta)
def order_parameter(s):
return abs(s.z)
values = {'theta': np.linspace(0, 1, 100), 'omega': np.linspace(0.9, 1.1, 100)}
oscillators = System(elements=[GroupElement('phases', element=OscillatorElement, size=100, values=values)])
observables = [ObservableSpec('z', phase_vector, group='phases'), ObservableSpec('order', order_parameter)]
order = Simulation(oscillators, dt=1.0, observables=observables).prepare().run(until=50).trajectory_.values('order')
round(float(order[0]), 3), round(float(order[-1]), 3)
(0.958, 0.12)
An observable is a frozen record of one of two kinds:
- An expression of the system namespace. Earlier observables appear in it by label, as
s.zinorder_parameter. - A reduction over the members of a group. It reduces a variable, or an expression of one member such as
phase_vector, bycount,sum,mean,min,max,var, or a function.
Expressions and reductions given as functions must be module-level functions, so that systems pickle. A lambda raises an error.
Above, the oscillators start close together and drift apart at their own frequencies, so the order falls from near 1.
Laws that read observables
Let a law react to a macroscopic quantity, such as the mean field of all oscillators. A law reads an observable through
a role bound to the path of the observable, as s.<role>.value.
from sage.all__sagemath_symbolics import cos, sin
from skcomplex.core.components.interactions import Interaction
from skcomplex.core.specs import RoleSpec
class MeanFieldInteraction(Interaction):
def __init__(
self,
label,
*,
target: Annotated[str, RoleSpec()],
X: Annotated[str, RoleSpec()],
Y: Annotated[str, RoleSpec()],
K: Annotated[float, ParameterSpec()] = 1.0,
):
super().__init__(label)
self.target = target
self.X = X
self.Y = Y
self.K = K
def _get_laws(self, s):
return [RateLaw(s.target.theta, s.K * (s.Y.value * cos(s.target.theta) - s.X.value * sin(s.target.theta)))]
def cosine(member):
return cos(member.theta)
def sine(member):
return sin(member.theta)
values = {'theta': np.linspace(0, 6, 100), 'omega': np.linspace(0.9, 1.1, 100)}
coupled = System(
elements=[GroupElement('phases', element=OscillatorElement, size=100, values=values)],
interactions=[MeanFieldInteraction('coupling', target='phases', X='X', Y='Y', K=2.0)],
)
observables = [ObservableSpec('X', cosine, group='phases'), ObservableSpec('Y', sine, group='phases')]
synchronised = Simulation(coupled, dt=1.0, observables=observables).prepare().run(until=20).trajectory_
round(float(synchronised.values('X')[-1]), 2)
-0.53
When the law reads the value depends on where the observable and the law live:
- A symbolic observable is an expression, or a reduction with a string
reduceover an element group. Laws of its own partition read it at the current state. The compiled problem evaluates it before the law, so the value the law reads equals the recorded value at every frame. - A law of another partition reads the observable as held at the last synchronisation point. This is how agents and equations exchange macroscopic quantities in a co-simulation.
- An observable with a Python function as
reduce, or one over agents, is read only from another partition. Reading it in its own partition raises an error.
Visualizing with Rerun
Watch a run live in the Rerun viewer, or save it to a file to open later. RerunObserver streams
frames to a spawned viewer, a running viewer, or a .rrd file.
import tempfile
from pathlib import Path
from skcomplex.core.components.elements import PointElement
from skcomplex.core.components.interactions import RateInteraction
from skcomplex.core.components.spaces import ContinuousSpace
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.engine.viz import RerunObserver
molecule = System(
elements=[PointElement('a', x=[0.0, 0.0, 0.0]), PointElement('b', x=[1.5, 0.0, 0.0], x_d1=[0.0, 0.5, 0.0])],
interactions=[
RateInteraction('pull', target='a', sources=['b'], rate=lambda s: 4.0 * (s.sources[0].x - s.target.x)),
RateInteraction('push', target='b', sources=['a'], rate=lambda s: 4.0 * (s.sources[0].x - s.target.x)),
],
space=ContinuousSpace(n_dim=3),
)
path = Path(tempfile.mkdtemp()) / 'molecule.rrd'
Simulation(molecule, dt=0.02, observers=[RerunObserver('molecule', save_path=path)]).prepare().run(until=5)
Entity paths follow the system tree, each element type chooses its visual, and parameters appear under parameters/.
The default visuals are these:
- Particles appear as 3D points, and populations as time series.
- Agents on grids and in continuous spaces appear as points colored by a state, and agents on networks as graph nodes.
- Reporters appear under
reporters/. - A group is logged as one batch per state.
PointsVisual(position='x', color_by='x_d1')colors its points by speed. - Points and agents in a continuous space without a given
radiustake a share of the extent of the view. A group of at most ten points draws the trail of its recent positions, whichtrail=0turns off. - A one-dimensional lattice also logs a space-time image, one row per logged frame with the oldest at the top, holding
at most
ImageVisual(history=...)rows. - A complex state of a lattice or a mesh appears as two images. Its modulus goes under
<path>/<state>/modulus, colored over the range, and its phase under<path>/<state>/phase, colored by a cyclic palette over(-π, π]. A complex scalar appears as the series<path>/<state>/realand<path>/<state>/imag.
An element or a group sets its own visual with its visual argument, such as
GroupElement(..., visual=VisualSpec('points', {...})). A VisualSpec(kind, options) names a visual of the
visualization layer, such as 'points', 'image', or 'agents'. RerunObserver builds it with the path of the
element and its recording.
Every visual subclasses BaseVisual, and implements the hook _log for elements or _log_agents for agents. Its
settings path and recording name the element and the recording it logs to. visual.prepare(problem) finds the
element in a compiled problem and sets its views views_, and visual.run(frame) logs the element at a frame.
Layouts and blueprints
Arrange the views of the viewer with a Rerun blueprint, the default one or your own. Every visual declares the Rerun
views that show what it logs, as its fitted attribute views_.
import rerun.blueprint as rrb
from skcomplex.systems import create_system
predation = create_system('lotka_volterra')
observer = RerunObserver('lotka_volterra', save_path=path.with_name('lotka_volterra.rrd'), blueprint='default')
Simulation(predation, dt=0.1, observers=[observer]).prepare().run(until=20)
type(observer.views_[0]).__name__
sorted(file.name for file in path.parent.glob('lotka_volterra.*'))
custom = rrb.Blueprint(rrb.TimeSeriesView(origin='/prey', name='prey'), collapse_panels=True)
observer = RerunObserver('lotka_volterra', save_path=path.with_name('prey.rrd'), blueprint=custom)
Simulation(predation, dt=0.1, observers=[observer]).prepare().run(until=20)
path.with_name('prey.rbl').exists()
TimeSeriesView
['lotka_volterra.rbl', 'lotka_volterra.rrd']
True
The views follow what is logged:
- Scalars go in time series views.
- Points go in 2D or 3D spatial views, by their dimension.
- Lattices and fields go in one 2D view per state, after the 2D views of their space-time images in one dimension.
- Meshes go in 3D views.
- Agents go in a 2D view, or a graph view on a network. Agents without a space get no spatial view.
RerunObserver(blueprint='default') arranges these views into a Rerun blueprint:
- Spatial views come first and side by side, with time series views below them.
- Scalar elements of one type share a time series view with at most six series.
- Parameters are left out unless
parameters=True. - The side panels are collapsed, the top panel is hidden, and the time panel follows the latest frame.
primary names the view to put first. The prepared observer keeps the views in this order as views_, so its first
view is the one that shows the signature behaviour of the system. RerunObserver sends the default blueprint or a
blueprint of your own, blueprint_, and writes it next to a saved recording as a .rbl file.
Open both files with rerun lotka_volterra.rrd lotka_volterra.rbl to see the layout.