Stocks and flows
Build stocks that change only through flows, as in system dynamics, check that what flows is conserved, and find the
feedback loops that drive them. A stock is a state tagged with a quantity, such as StateSpec(quantity='people').
from typing import Annotated
from skcomplex.core.components.elements import Element
from skcomplex.core.components.laws import Sink, Source, TransferLaw
from skcomplex.core.components.laws.utils import lookup
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ObservableSpec, ParameterSpec, StateSpec
def birth_rate(s):
return s.b * s.n * lookup(s.n, [(0, 1.0), (100, 1.0), (200, 0.0)])
class PeopleElement(Element):
def __init__(
self,
label,
*,
n: Annotated[float, StateSpec(quantity='people')] = 10.0,
b: Annotated[float, ParameterSpec()] = 0.03,
d: Annotated[float, ParameterSpec()] = 0.01,
):
super().__init__(label)
self.n = n
self.b = b
self.d = d
def _get_laws(self, s):
return [
TransferLaw(Source(), s.n, birth_rate(s), label='births'),
TransferLaw(s.n, Sink(), s.d * s.n, label='deaths'),
]
crowd = System(elements=[PeopleElement('crowd')])
observed = [ObservableSpec('birth_rate', birth_rate, element='crowd')]
trajectory = Simulation(crowd, dt=1.0, observables=observed).prepare().run(until=10).trajectory_
round(float(trajectory.values('crowd.births')[-1]), 3), round(float(trajectory.values('crowd.deaths')[-1]), 3)
round(float(trajectory.values('crowd.birth_rate')[-1]), 3)
(0.366, 0.122)
0.366
A TransferLaw(source, sink, amount, label=...) moves an amount of the quantity from one stock to another. It exchanges
the quantity with the environment through Source() and Sink(). What the amount means depends on the laws around it:
- In a system of rate laws the amount is a rate.
- In a system of update laws it is moved once per step.
- With
rate=...it is the size of a jump with that propensity.
Trajectories record every flow under <element path>.<label>, such as crowd.births.
An intermediate expression, such as the birth rate, is a function of the namespace that the laws call.
ObservableSpec(label, function, element=element) records the same function as the observable <element>.<label>,
which Rerun logs under the element. lookup(x, points) is a table function, piecewise linear between its points and
flat outside them.
Conservation and loops
Check that the stocks change only through flows, and see how much of each quantity enters from outside.
from skcomplex.core.analysis import LoopAnalysis, SymbolicAnalysis
from skcomplex.core.engine.runtime import ConservationObserver
SymbolicAnalysis('conservation').prepare(crowd).run().inflows_
monitored = Simulation(crowd, dt=1.0, observers=[ConservationObserver()]).prepare().run(until=10).trajectory_
LoopAnalysis('flow-matrix').prepare(monitored).run().matrix_.sum(axis=1).tolist()
{'people': b_crowd*n_crowd*where(n_crowd < 0.0, 1.0, where(n_crowd < 100.0, 1.0, where(n_crowd < 200.0, -0.01*n_crowd + 2.0, 0.0))) - d_crowd*n_crowd}
[0.0, 0.0]
- A rate or update law outside flows that changes a tagged state raises
ConservationErrorwhen the system compiles. SymbolicAnalysis('conservation')returns the net inflow of every quantity from the environment, after checking that the internal flows cancel.- The compiler accumulates that inflow in the hidden state
__boundary.<quantity>. The observerConservationObserverchecks at every frame that each total changed only by it. LoopAnalysis('flow-matrix', frame=frame)gives the transaction-flow matrix of a frame of a trajectory, with one row per flow and one column per holder. Its rows sum to zero.
distributed_delay(value, time, order, conserved=True) is a material delay of any order. It is a chain of hidden stocks
in series, and it returns their outflow. With conserved=False it is an information smoothing. It adds hidden stocks
<element path>.<kind>_delay<order>_<index> to the rate laws of single elements and interactions, and they start in
equilibrium with their input. The system dynamics names delay1, delay3, and smooth of
skcomplex.core.components.laws.utils are its cases.
LoopAnalysis('loops') derives the signed dependency graph of a system from its laws, and lists its feedback loops with
their polarity. LoopAnalysis('dominance') ranks the loops by their gains at every frame of a trajectory.
analysis = LoopAnalysis().prepare(crowd).run()
[(polarity, ' -> '.join(nodes)) for nodes, polarity in zip(analysis.loops_, analysis.polarities_, strict=True)]
dominance = LoopAnalysis('dominance').prepare(trajectory).run()
dominance.polarities_[int(dominance.dominant_[-1])]
[('reinforcing', 'crowd.n -> crowd.births'), ('balancing', 'crowd.n -> crowd.deaths')]
reinforcing
An edge of the graph goes from one state to another when the rate of the second depends on the first, with the sign of
the entry of the Jacobian. In systems that declare flows, the edges also go through the flows: from a state to every
flow it drives, and from a flow to its sink, with sign +, and to its source, with sign -.
The graph is that of any system with a vector field or a map, not only of stocks and flows. At one prey and one predator, prey growth closes a reinforcing self-loop, predator death a balancing one, and predation a balancing loop through both populations.
from skcomplex.systems import create_system
predation = LoopAnalysis(at=[1.0, 1.0]).prepare(create_system('lotka_volterra')).run()
dict(zip(predation.loops_, predation.polarities_, strict=True))
{('predators.n',): 'balancing', ('prey.n',): 'reinforcing', ('predators.n', 'prey.n'): 'balancing'}
Conserved quantities between agents
Move money or goods between agents so that the totals stay constant, and let the run check it.
from typing import Annotated
from skcomplex.core.components.elements import Element, GroupElement
from skcomplex.core.components.laws import RuleLaw
from skcomplex.core.components.laws.utils import transfer
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ObservableSpec, StateSpec
def net_worth(agent):
return agent.cash + agent.claims - agent.equity
def lend(agent, system):
borrower = system.agents('traders')[0]
if borrower != agent:
transfer(agent, borrower, 1.0, 'cash')
transfer(borrower, agent, 1.0, 'claims')
class TraderElement(Element):
def __init__(
self,
label,
*,
cash: Annotated[float, StateSpec(quantity='money')] = 10.0,
claims: Annotated[float, StateSpec(quantity='claims')] = 0.0,
equity: Annotated[float, StateSpec()] = 10.0,
):
super().__init__(label)
self.cash = cash
self.claims = claims
self.equity = equity
def _get_laws(self, s):
return [RuleLaw(lend)]
balance = ObservableSpec('net_worth', net_worth, group='traders', reduce='sum')
market = System(elements=[GroupElement('traders', element=TraderElement, size=3)])
simulation = Simulation(market, observables=[balance], invariants={'net_worth': 1e-9})
traders = simulation.prepare().run(until=3).trajectory_
float(traders.agents('traders', 3).values['cash'].sum())
30.0
Agents move conserved quantities between each other inside rules. transfer(source, sink, amount, state) moves an
amount between two agents at once. A negative holding is an amount owed, and total holdings stay constant.
A run checks that an expression keeps its first value with Simulation(invariants={name: tolerance}), where the name is
an observable of the run. At every frame the run compares the observable with its value at the first frame. It raises
ConservationError, naming the observable and the first frame that breaks it. Removing an agent that still holds a
tagged state also raises ConservationError, until its holdings are settled with transfers.
Translating systems
Turn a Vensim or Stella file into a system you can run and analyse. SystemDynamicsTranslator(path) reads a Vensim
(.mdl) or XMILE (.xmile, .xml, .stmx) file and writes the source of a system module.
import importlib
import sys
import tempfile
from pathlib import Path
from skcomplex.core.analysis import LoopAnalysis
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.engine.solvers import EulerSolver
from skcomplex.core.engine.translators import SystemDynamicsTranslator
text = """
Population = INTEG(Births - Deaths, 100) ~ People ~ |
Births = Population * Birth Fraction ~ People/Year ~ |
Deaths = Population / Lifetime ~ People/Year ~ |
Birth Fraction = 0.03 ~ 1/Year ~ |
Lifetime = 70 ~ Years ~ |
Doubling Share = Population / 200 ~ Dmnl ~ |
INITIAL TIME = 0 ~ Years ~ |
FINAL TIME = 100 ~ Years ~ |
TIME STEP = 0.25 ~ Years ~ |
SAVEPER = 1 ~ Years ~ |
"""
folder = Path(tempfile.mkdtemp())
_ = (folder / 'people.mdl').write_text(text)
translator = SystemDynamicsTranslator(folder / 'people.mdl').prepare().run()
_ = (folder / 'translated_people.py').write_text(translator.source_)
sys.path.insert(0, str(folder))
module = importlib.import_module('translated_people')
translator.settings_.until, module.DT
system = module.create_system()
simulation = Simulation(system, dt=module.SAVE_EVERY, observables=module.observables(system))
trajectory = simulation.prepare().run(until=module.UNTIL).trajectory_
trajectory.values('people.births')[0], trajectory.values('people.doubling_share')[0]
LoopAnalysis().prepare(system).run().polarities_
(100.0, 0.25)
(3.0, 0.5)
['reinforcing', 'balancing']
PySD reads the file. The translator writes source_ from PySD's abstract model, and settings_ from the control
section of the file. You save the source and import it like any module. The module defines:
- one element class, whose states are the stocks and whose parameters are the constants,
- the functions
create_system()andobservables(system), - the constants
START,UNTIL,DT, andSAVE_EVERY.
Time in the module runs from zero, and Time in the file becomes t + START. The parts of the file become these:
- A flow variable that leaves one stock and enters another is a
TransferInteractionbetween the two states. A flow variable of one stock is aTransferInteractionwithSource()orSink(). The trajectory reports each flow as<element>.<flow>. - The rest of the net rate of a stock is a
RelaxationInteractionwhen it has the form(goal - stock) / timeor(goal - stock) * rate, and aRateInteractionotherwise. - An auxiliary is a private function
_name(s, t)that the laws call, and anObservableSpecunder its snake-case name. - A lookup is a
lookuptable. The built-in functions arewhere,step,ramp,pulse,pulse_train,delay1,delay3,smooth,distributed_delay,delayed,minimum,maximum,clip, and the drawsuniformandnormal. - A system whose stocks are all subscripted over one range is a
GroupElementwith one member per subscript.SUMover the range istotal, and a read of one subscript is atotalover that member.
The default solver finds the exact solution. Vensim and Stella integrate with Euler steps by default. A run with
solver=EulerSolver(dt=module.DT) and dt=module.DT reproduces their run step by step. A run with the default solver
differs from it by the Euler error of the time step of the file.
stepped = Simulation(system, solver=EulerSolver(dt=module.DT), dt=module.DT)
final = stepped.prepare().run(until=module.UNTIL).trajectory_.values('people.population')[-1]
round(float(final), 3)
479.872
One ValidationError lists every construct that does not translate, with its variable. These are GAME, ALLOCATE,
the vector functions, TREND, FORECAST, SAMPLE IF TRUE, data read from spreadsheets, macros, variables subscripted
over more than one range, systems that mix subscripted and unsubscripted stocks, delays and smoothings whose initial
value differs from their input, and DELAY FIXED of anything other than a stock.
The library sets these limits on what a translated module does:
- The core takes random draws only in update and renewal laws. A module with a random function translates, but raises
IncompatibleLawsErrorwhen its system is prepared. - A run cannot record an auxiliary that reads
DELAY FIXED, or a delay that no law reads, soobservablesleaves it out. - A trajectory records the flow of one member from the values of that member. So in a group, a flow variable that reads
SUMor one named subscript stays in the rate of its stock, without a flow, and its value stays an observable.
The catalog systems built on flows are these:
- engineering processes:
bathtub,rice_milling,solid_waste,food_relief, andirrigation, - markets:
car_dealer,commodity_cycle, andbeer_game, - innovation:
bass_diffusion, - macroeconomics:
stock_flow_consistent_economy, - organizations:
success_to_the_successful,shifting_the_burden, andescalation.