Skip to main content

Python bindings for the Simlin system dynamics simulation engine

Project description

pysimlin - Python bindings for Simlin

Python bindings for the Simlin system dynamics simulation engine.

Features

  • Load models from XMILE, Vensim MDL, and binary protobuf formats
  • Run system dynamics simulations with full control
  • Get simulation results as pandas DataFrames
  • Analyze model structure and feedback loops
  • Edit existing models or build new ones programmatically via Python context managers
  • Full type hints for IDE support
  • Loops That Matter (LTM) analysis for feedback loop importance

Installation

pip install pysimlin

Note: Install with pip install pysimlin but import with import simlin.

Requirements

  • Python 3.11 or higher
  • numpy >= 1.22.0
  • pandas >= 1.5.0
  • cffi >= 1.15.0

Quick Start

import simlin

# Load a model (auto-detects format)
model = simlin.load("model.stmx")

# Run simulation and get results
run = model.run()
print(run.results.head())

# Access individual variables
population = run.results["population"]

# Or use low-level simulation for gaming/interactive use
with model.simulate() as sim:
    sim.run_to_end()
    run = sim.get_run()
    print(run.results.head())

Examples

Editing a flow in an existing model

"""Example showing how to edit an existing model's flow equation with pysimlin."""

from __future__ import annotations

import simlin


EXAMPLE_XMILE = b"""<?xml version='1.0' encoding='utf-8'?>
<xmile version=\"1.0\" xmlns=\"http://docs.oasis-open.org/xmile/ns/XMILE/v1.0\" xmlns:isee=\"http://iseesystems.com/XMILE\" xmlns:simlin=\"https://simlin.com/XMILE/v1.0\">
  <header>
    <name>pysimlin-edit-example</name>
    <vendor>Simlin</vendor>
    <product version=\"0.1.0\" lang=\"en\">Simlin</product>
  </header>
  <sim_specs method=\"Euler\" time_units=\"Year\">
    <start>0</start>
    <stop>80</stop>
    <dt>0.25</dt>
  </sim_specs>
  <model name=\"main\">
    <variables>
      <stock name=\"population\">
        <eqn>25</eqn>
        <inflow>net_birth_rate</inflow>
      </stock>
      <flow name=\"net_birth_rate\">
        <eqn>fractional_growth_rate * population</eqn>
      </flow>
      <aux name=\"fractional_growth_rate\">
        <eqn>maximum_growth_rate * (1 - population / carrying_capacity)</eqn>
      </aux>
      <aux name=\"maximum_growth_rate\">
        <eqn>0.10</eqn>
      </aux>
      <aux name=\"carrying_capacity\">
        <eqn>1000</eqn>
      </aux>
    </variables>
  </model>
</xmile>
"""


def run_simulation(model: simlin.Model) -> float:
    """Run the model to the configured stop time and return the ending population."""

    with model.simulate() as sim:
        sim.run_to_end()
        return float(sim.get_value("population"))


def main() -> None:
    """Demonstrate editing a flow equation and verify the change takes effect."""

    # Load model from XMILE bytes by writing to temp file first
    import tempfile
    import os

    with tempfile.NamedTemporaryFile(suffix=".stmx", delete=False) as f:
        f.write(EXAMPLE_XMILE)
        temp_path = f.name

    try:
        model = simlin.load(temp_path)
        baseline_final = run_simulation(model)

        with model.edit() as (current, patch):
            flow_var = current["net_birth_rate"]
            flow_var.flow.equation.scalar.equation = (
                "fractional_growth_rate * population * 1.5"
            )
            patch.upsert_flow(flow_var.flow)

        accelerated_final = run_simulation(model)

        if not accelerated_final > baseline_final + 10:
            raise RuntimeError(
                "Edited model did not accelerate growth as expected: "
                f"baseline={baseline_final:.2f} accelerated={accelerated_final:.2f}"
            )

        print(
            "Updated growth equation increased the final population from "
            f"{baseline_final:.1f} to {accelerated_final:.1f}."
        )
    finally:
        os.unlink(temp_path)


if __name__ == "__main__":
    main()

Building a logistic population model programmatically

"""Create a new Simlin project and build a simple population model using pysimlin's edit API."""

from __future__ import annotations

import simlin
import simlin.pb as pb


def build_population_project() -> simlin.Project:
    """Return a project containing a logistic population model created via model.edit()."""

    project = simlin.Project.new(
        name="pysimlin-population-example",
        sim_start=0.0,
        sim_stop=80.0,
        dt=0.25,
        time_units="years",
    )

    project.set_sim_specs(
        start=0.0,
        stop=80.0,
        dt={"value": 0.25},
        time_units="years",
    )

    model = project.get_model()
    with model.edit() as (_, patch):
        population = pb.Variable.Stock()
        population.ident = "population"
        population.equation.scalar.equation = "50"
        population.inflows.extend(["births"])
        population.outflows.extend(["deaths"])
        patch.upsert_stock(population)

        births = pb.Variable.Flow()
        births.ident = "births"
        births.equation.scalar.equation = "population * birth_rate"
        patch.upsert_flow(births)

        deaths = pb.Variable.Flow()
        deaths.ident = "deaths"
        deaths.equation.scalar.equation = "population * birth_rate * (population / 1000)"
        patch.upsert_flow(deaths)

        birth_rate = pb.Variable.Aux()
        birth_rate.ident = "birth_rate"
        birth_rate.equation.scalar.equation = "0.08"
        patch.upsert_aux(birth_rate)

    return project


def validate_population_curve(values: list[float]) -> None:
    """Ensure the generated population series shows logistic (S-shaped) growth."""

    if len(values) < 3:
        raise RuntimeError("Population series is unexpectedly short")

    if any(b < a for a, b in zip(values, values[1:])):
        raise RuntimeError("Population should not decline in this model")

    initial = values[0]
    mid = values[len(values) // 2]
    last = values[-1]
    growth_first_half = mid - initial
    growth_second_half = last - mid

    if not growth_first_half > 0:
        raise RuntimeError("Population failed to grow early in the simulation")

    if not growth_second_half > 0:
        raise RuntimeError("Population failed to grow late in the simulation")

    if not growth_second_half < growth_first_half:
        raise RuntimeError("Logistic growth should slow over time")

    if not 950 <= last <= 1025:
        raise RuntimeError(
            "Population should approach the carrying capacity (~1000), "
            f"but ended at {last:.2f}"
        )


def main() -> None:
    """Build, simulate, and validate the population model."""

    project = build_population_project()
    errors = project.get_errors()
    if errors:
        raise RuntimeError(f"Generated project contains validation errors: {errors}")

    model = project.get_model()
    with model.simulate() as sim:
        sim.run_to_end()
        population_series = [float(value) for value in sim.get_series("population")]

    validate_population_curve(population_series)

    print(
        "Population grows from "
        f"{population_series[0]:.1f} to {population_series[-1]:.1f}, forming an S-shaped trajectory."
    )


if __name__ == "__main__":
    main()

Both examples live under src/pysimlin/examples/ and are executed by scripts/pysimlin-tests.sh.

API Reference

Loading Models

import simlin

# Load a model (auto-detects format from extension)
model = simlin.load("model.stmx")  # .stmx, .mdl, .json, etc.

# Access the underlying project if needed
project = model.project

# Create a new blank project/model programmatically
from simlin import Project
project = Project.new(name="my-project", sim_start=0, sim_stop=100, dt=0.25)
model = project.get_model()

Working with Models

# Access model structure via properties
stocks = model.stocks        # Tuple of Stock objects
flows = model.flows          # Tuple of Flow objects
auxs = model.auxs            # Tuple of Aux objects
variables = model.variables  # All variables (stocks + flows + auxs)

# Access individual variable properties
for stock in model.stocks:
    print(f"{stock.name}: initial = {stock.initial_equation}")

for flow in model.flows:
    print(f"{flow.name}: {flow.equation}")

# Get time configuration
time_spec = model.time_spec
print(f"Simulation: t={time_spec.start} to {time_spec.stop}, dt={time_spec.dt}")

# Analyze variable dependencies
incoming_deps = model.get_incoming_links("population")

# Get causal links
links = model.get_links()
for link in links:
    print(f"{link.from_var} --{link.polarity}--> {link.to_var}")

# Check for model issues
issues = model.check()
for issue in issues:
    print(f"{issue.severity}: {issue.message}")

# Get explanation for a variable
explanation = model.explain("population")
print(explanation)

Model Editing

import simlin.pb as pb

# Edit existing model variables using context manager
with model.edit() as (current, patch):
    # Access current variables by name
    stock_var = current["population"]

    # Modify the variable's properties
    stock_var.stock.equation.scalar.equation = "100"  # Change initial value

    # Apply the change
    patch.upsert_stock(stock_var.stock)

# Create new variables programmatically
with model.edit() as (current, patch):
    # Create a new auxiliary variable
    new_aux = pb.Variable.Aux()
    new_aux.ident = "growth_rate"
    new_aux.equation.scalar.equation = "0.05"
    patch.upsert_aux(new_aux)

    # Create a new flow variable
    new_flow = pb.Variable.Flow()
    new_flow.ident = "births"
    new_flow.equation.scalar.equation = "population * growth_rate"
    patch.upsert_flow(new_flow)

Running Simulations

# High-level API: run and get results immediately
run = model.run(analyze_loops=False)
print(run.results.head())

# Run with variable overrides
run = model.run(overrides={"initial_population": 1000}, analyze_loops=False)

# Use the cached base case
base_case = model.base_case  # Automatically cached
print(base_case.results["population"].plot())

# Low-level API: create simulation for step-by-step control
with model.simulate() as sim:
    sim.run_to(50.0)        # Run to specific time
    sim.set_value("growth_rate", 0.10)  # Intervention
    sim.run_to_end()        # Continue to end
    run = sim.get_run()     # Get results as Run object

# Enable Loops That Matter analysis
with model.simulate(enable_ltm=True) as sim:
    sim.run_to_end()
    run = sim.get_run()
    print(run.dominant_periods)

Accessing Results

# Results are pandas DataFrames
run = model.run(analyze_loops=False)
df = run.results  # Time series for all variables

# Access specific variables
population = df["population"]
gdp = df["gdp"]

# Standard pandas operations
print(df.describe())
print(df.tail())
df["population"].plot()

# Get metadata
time_spec = run.time_spec
overrides = run.overrides  # Dict of variable overrides used

Model Interventions

# Run with different initial conditions
scenarios = {}
for initial_pop in [100, 500, 1000]:
    run = model.run(
        overrides={"initial_population": initial_pop},
        analyze_loops=False
    )
    scenarios[f"pop_{initial_pop}"] = run.results["population"]

# Compare scenarios
import pandas as pd
comparison = pd.DataFrame(scenarios)
comparison.plot()

Feedback Loop Analysis

# Get structural feedback loops
loops = model.loops
for loop in loops:
    print(f"Loop {loop.id} ({loop.polarity}): {' -> '.join(loop.variables)}")

# Run with loop behavior analysis
run = model.run(analyze_loops=True)

# Access loops with behavioral importance
for loop in run.loops:
    if loop.behavior_time_series is not None:
        avg_importance = loop.average_importance()
        print(f"Loop {loop.id}: avg importance = {avg_importance:.3f}")

# Analyze dominant periods
for period in run.dominant_periods:
    print(f"t=[{period.start_time}, {period.end_time}]: {period.dominant_loops}")

Loops That Matter (LTM)

# Run simulation with LTM enabled
sim = model.simulate(enable_ltm=True)
sim.run_to_end()

# Get links with importance scores over time
links = sim.get_links()
for link in links:
    if link.has_score():
        print(f"{link.from_var} -> {link.to_var}")
        print(f"  Average score: {link.average_score():.4f}")
        print(f"  Max score: {link.max_score():.4f}")

# Get relative loop scores
loops = project.get_loops()
if loops:
    loop_scores = sim.get_relative_loop_score(loops[0].id)

Model Export

# Export to different formats
xmile_bytes = project.to_xmile()    # Export as XMILE
pb_bytes = project.serialize()      # Export as protobuf

# Save to file
Path("exported.stmx").write_bytes(xmile_bytes)
Path("model.pb").write_bytes(pb_bytes)

Error Handling

from simlin import (
    SimlinError,
    SimlinImportError,
    SimlinRuntimeError,
    SimlinCompilationError,
    ErrorCode
)

try:
    model = simlin.load("model.stmx")
    project = model.project
except SimlinImportError as e:
    print(f"Import failed: {e}")
    if e.code == ErrorCode.XML_DESERIALIZATION:
        print("Invalid XML format")

# Check for compilation errors
errors = project.get_errors()
for error in errors:
    print(f"{error.code.name} in {error.model_name}/{error.variable_name}")
    print(f"  {error.message}")

Complete Example

import simlin
import pandas as pd
import matplotlib.pyplot as plt

# Load and run a population model
model = simlin.load("population_model.stmx")

# Run baseline simulation
with model.simulate() as sim:
    sim.run_to_end()
    baseline = sim.get_run().results

# Run intervention scenario
with model.simulate() as sim:
    sim.set_value("birth_rate", 0.03)
    sim.run_to_end()
    intervention = sim.get_run().results

# Compare results
fig, ax = plt.subplots()
ax.plot(baseline.index, baseline["population"], label="Baseline")
ax.plot(intervention.index, intervention["population"], label="Intervention")
ax.set_xlabel("Time")
ax.set_ylabel("Population")
ax.legend()
plt.show()

Supported Platforms

  • macOS (ARM64)
  • Linux (ARM64, x86_64)

License

Apache License 2.0

Development

For development setup and contribution guidelines, see the main Simlin repository.

Running Tests

cd src/pysimlin
pip install -e ".[dev]"
pytest

Building from Source

cd src/pysimlin
python -m build

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

pysimlin-0.3.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (12.8 MB view details)

Uploaded CPython 3.13manylinux: glibc 2.17+ x86-64

pysimlin-0.3.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (12.5 MB view details)

Uploaded CPython 3.13manylinux: glibc 2.17+ ARM64

pysimlin-0.3.3-cp313-cp313-macosx_11_0_arm64.whl (3.0 MB view details)

Uploaded CPython 3.13macOS 11.0+ ARM64

pysimlin-0.3.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (12.8 MB view details)

Uploaded CPython 3.12manylinux: glibc 2.17+ x86-64

pysimlin-0.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (12.5 MB view details)

Uploaded CPython 3.12manylinux: glibc 2.17+ ARM64

pysimlin-0.3.3-cp312-cp312-macosx_11_0_arm64.whl (3.0 MB view details)

Uploaded CPython 3.12macOS 11.0+ ARM64

pysimlin-0.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (12.8 MB view details)

Uploaded CPython 3.11manylinux: glibc 2.17+ x86-64

pysimlin-0.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (12.5 MB view details)

Uploaded CPython 3.11manylinux: glibc 2.17+ ARM64

pysimlin-0.3.3-cp311-cp311-macosx_11_0_arm64.whl (3.0 MB view details)

Uploaded CPython 3.11macOS 11.0+ ARM64

File details

Details for the file pysimlin-0.3.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 a62f78e5ea126c42c2b0a7e129c9527ed9868ad78b53919c4d09f49e468d996f
MD5 d9b691af7a37fa760cd2c422039501c5
BLAKE2b-256 c344efa986f5b08784b848abbe51ffd1f811c61b2c7cb8e8f4388a9ffafb1bd4

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 2bb80e58ace427eefab4f6a17f909585b947e4ae54fe1c67d4d659d87e73cab5
MD5 48003919822a01ee7f1dbd500eadc7fb
BLAKE2b-256 cd92df45cd469f40abc445f44e8a0167592e7c3c6a23d5c6fb8d2cf9bbe17b55

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp313-cp313-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp313-cp313-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 e81d766b98abb049357a85e1b863e030daa8c0203d97c12a23590c9e3990a975
MD5 c87b05e207d1aeeb31528fb9f4e1ee45
BLAKE2b-256 42b1669e775103796e2c873d9a9b3a3f4a82794e66fb7d18baeb6b4b40c401c8

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 4f9b74ac9e2edb4a6cf48aff2845c110f9cb5e076ea660f993232b76c499d6e6
MD5 643f44b343726aabd518e9c148bc18ae
BLAKE2b-256 5961fa910bcc8333c682eacee9155c49bbba645cd0d028756e99aafab44e7d57

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 50a076f3742f5bb40a8698419df252d4a0dcedb2bf90ec7ba5ffdfc8af09bd21
MD5 12ac4a9eeb52497419881a8ac19dd0d6
BLAKE2b-256 58add645569d62d6b0d79d98b2e6c68d2c6fe5bc161d7edeb10659a50efbee77

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp312-cp312-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp312-cp312-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 ec7ab15ed946921c70807d38f5d6f01d744142eb6dc88dfeff0d807a2daa773a
MD5 a9d9081b4e143259d09387e93d54c981
BLAKE2b-256 8c28422b02615fd68e70bb0546596fd19b0d84862b143e377ff495f26ea92786

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 4ee74b44671ae63648375abab2c170e9275c86e2c1fc5f655f1ce7c35bb290b1
MD5 3473e92aa5566d7fdba356683ead9e37
BLAKE2b-256 159d1bb764259059cceea9c5e8d8efb07f34044af5df89fa5512f16bd0d772cc

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 9ccf34ff1d6e16649dafee1a018abb24b41b4ed45d89e077f8831edc9f2e7f67
MD5 acebe04eb086479dd6fcd6b9aa4f965d
BLAKE2b-256 3c4a3d12fb44c85c26b5c5fce41fc9641c7c3586bd88e5db86a83f5aaf71312f

See more details on using hashes here.

File details

Details for the file pysimlin-0.3.3-cp311-cp311-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pysimlin-0.3.3-cp311-cp311-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 23a67b45ecd6b133271e26d3328d7177fdd5921a761acaad7e28ad9d1ac62614
MD5 300af9b605b72b46274ccbd4e78c53eb
BLAKE2b-256 8a24cbc2c4526d7a21d00978eaecca3a4d3631313b3b69ca640a1f8fc5b365e9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page