Connected components
Assemble a system from components that connect through ports, as engineers do with circuits and plants. Each component states equations and leaves open which variable each equation determines. So one resistor gives a current under a voltage source and a voltage under a current source.
from typing import Annotated, ClassVar
from skcomplex.core.components.elements import Element
from skcomplex.core.components.interactions import ConnectionInteraction
from skcomplex.core.components.laws import EquationLaw
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ConnectorSpec, ParameterSpec, StateSpec
CHEMICAL = ConnectorSpec('chemical', potential='mu', flow='n')
class ReservoirElement(Element):
ports: ClassVar = {'a': CHEMICAL}
def __init__(
self,
label,
*,
a_mu: Annotated[float, StateSpec(algebraic=True)] = 0.0,
a_n: Annotated[float, StateSpec(algebraic=True)] = 0.0,
level: Annotated[float, ParameterSpec()] = 0.0,
):
super().__init__(label)
self.a_mu = a_mu
self.a_n = a_n
self.level = level
def _get_laws(self, s):
return [EquationLaw(s.a_n, s.a_mu - s.level)]
system = System(
elements=[ReservoirElement('first', level=1.0), ReservoirElement('second', level=3.0)],
interactions=[ConnectionInteraction('node', members=['first', 'second'], ports=['a', 'a'])],
)
trajectory = Simulation(system, dt=0.5).prepare().run(until=1.0).trajectory_
assert trajectory.values('first.a_mu')[-1] == 2.0
assert trajectory.values('second.a_n')[-1] == -1.0
trajectory.values('first.a_mu')[-1], trajectory.values('second.a_n')[-1]
(2.0, -1.0)
The two reservoirs share one node. The node makes their potentials equal and their flows sum to zero, so the potential settles between the two levels and a flow runs from one reservoir into the other.
Algebraic states
StateSpec(algebraic=True) declares a state that no law integrates and that the equations of the system determine at
every time.
- An algebraic state has order 1 and no lags.
- Its initial value is the starting guess of a nonlinear solve.
- A law other than an
EquationLawthat targets it raisesAlgebraicStateError. - The trajectory records it at every frame, so observables and visuals read it like any other state.
- The simulation keeps it at its initial value in
state_, since the solvers integrate only the other states. - A law given as a function that reads values by name sees that initial value. Read it through a symbolic law instead.
Ports and connections
ConnectorSpec(name, potential, flow) names the variables of a port, and the core holds no named connector. An element
type declares its ports in the class variable ports, a mapping from the name of a port to its connector. It takes one
algebraic state per variable of a port, named <port>_<variable>. A flow is positive into the element.
ConnectionInteraction(label, members=..., ports=...) is one node.
- It binds the elements of the node through a variadic role, and names the port of each in the same order.
- It returns the equations that make the potentials equal and the flows sum to zero.
- A node with many ports is one connection.
- A port that no connection names has a zero flow.
A circuit takes one connector and one element class per component. Here a capacitor discharges through a resistor.
from typing import Annotated, ClassVar
import numpy as np
from skcomplex.core.components.elements import Element
from skcomplex.core.components.interactions import ConnectionInteraction
from skcomplex.core.components.laws import EquationLaw
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 ConnectorSpec, ParameterSpec, StateSpec
ELECTRICAL = ConnectorSpec('electrical', potential='v', flow='i')
class TwoPinElement(Element):
ports: ClassVar = {'p': ELECTRICAL, 'n': ELECTRICAL}
def __init__(
self,
label,
*,
p_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
p_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
n_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
n_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
):
super().__init__(label)
self.p_v = p_v
self.p_i = p_i
self.n_v = n_v
self.n_i = n_i
def _get_laws(self, s):
return [EquationLaw(s.p_i + s.n_i, 0)]
class ResistorElement(TwoPinElement):
def __init__(
self,
label,
*,
p_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
p_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
n_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
n_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
R: Annotated[float, ParameterSpec()] = 1.0,
):
super().__init__(label, p_v=p_v, p_i=p_i, n_v=n_v, n_i=n_i)
self.R = R
def _get_laws(self, s):
return [*super()._get_laws(s), EquationLaw(s.p_v - s.n_v, s.R * s.p_i)]
class CapacitorElement(TwoPinElement):
def __init__(
self,
label,
*,
p_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
p_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
n_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
n_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
u: Annotated[float, StateSpec()] = 0.0,
C: Annotated[float, ParameterSpec()] = 1.0,
):
super().__init__(label, p_v=p_v, p_i=p_i, n_v=n_v, n_i=n_i)
self.u = u
self.C = C
def _get_laws(self, s):
return [*super()._get_laws(s), EquationLaw(s.C * derivative(s.u, 1), s.p_i), EquationLaw(s.u, s.p_v - s.n_v)]
class GroundElement(Element):
ports: ClassVar = {'p': ELECTRICAL}
def __init__(
self,
label,
*,
p_v: Annotated[float, StateSpec(algebraic=True)] = 0.0,
p_i: Annotated[float, StateSpec(algebraic=True)] = 0.0,
):
super().__init__(label)
self.p_v = p_v
self.p_i = p_i
def _get_laws(self, s):
return [EquationLaw(s.p_v, 0)]
circuit = System(
elements=[CapacitorElement('tank', u=1.0, C=0.5), ResistorElement('load', R=2.0), GroundElement('ground')],
interactions=[
ConnectionInteraction('top', members=['tank', 'load'], ports=['p', 'p']),
ConnectionInteraction('bottom', members=['tank', 'load', 'ground'], ports=['n', 'n', 'p']),
],
)
trajectory = Simulation(circuit, dt=0.1).prepare().run(until=1.0).trajectory_
assert np.isclose(trajectory.values('tank.u')[-1], np.exp(-1.0), rtol=1e-3)
assert np.allclose(trajectory.values('load.p_i').ravel(), trajectory.values('tank.u').ravel() / 2.0)
trajectory.values('tank.u')[-1].round(4)
0.3679
The voltage of the capacitor decays as exp(-t / RC), and the current through the load is that voltage over R. The
systems of the field engineering in the catalog hold electrical and thermal components built in the same way. A port
component drives a mechanism through an EquationLaw whose target is a coordinate of an EulerLagrangeInteraction,
which adds to the equation of that coordinate, as dc_motor shows.
The solve
OrdersPass solves the equations of a system with algebraic states inside the right side of its differential
equations, so its solvers run unchanged.
- The unknowns are the highest derivatives and the algebraic states.
- A maximum bipartite matching pairs the equations with the unknowns. The strongly connected components of their dependency graph, in topological order, are the blocks.
- A block linear in its unknowns is solved symbolically up to six unknowns, and by a numeric linear solve above.
- Any other block runs
scipy.optimize.rooton the NumPy backend, with the Jacobian Sage derives, at every evaluation, from the values of the last evaluation. When that fails it starts again one unit away, and then raisesSolverError. - The laws that read an algebraic state read its solution. A function argument, an event guard, or a reset that reads
an algebraic state of a nonlinear block raises
ValidationError, because these need a symbolic value.
The solve has no index reduction, so the structural check below reports a system of a higher index.
Issues of connected systems
PassCompiler reports these issues with the other issues of a system, before compilation.
port-connector: the ports of one connection have different connectors, or a connection names a port that no single element declares. Connect declared ports of one connector.port-connections: a port appears in more than one connection. List the ports of the node in one connection.port-states: an element type declares a port without an algebraic state for one of its variables. Declare<port>_<variable>withStateSpec(algebraic=True).structural-singularity: the matching leaves equations or unknowns unmatched, as for two capacitors in parallel. The issue names the constrained states. Add a compliant element, such as a resistance, between them.algebraic-law: a law other than anEquationLawtargets an algebraic state. State its value with anEquationLaw.
A connection under discrete time gives the law-time issue of every EquationLaw.