FMI 3.0 Co-Simulation with FMPy: A Hands-On Tutorial

FMI 3.0 Co-Simulation with FMPy: A Hands-On Tutorial

FMI 3.0 Co-Simulation with FMPy: A Hands-On Tutorial

If you have ever tried to stitch a thermal model from one tool to a control model from another and have them run in lockstep, you already know the pain that the Functional Mock-up Interface was invented to kill. FMI 3.0 co-simulation is the current answer: a vendor-neutral way to package a simulation model as a self-contained black box, hand it to a master algorithm, and step it forward in time alongside other models that came from completely different toolchains. This tutorial is a hands-on, code-first walk through doing exactly that in Python using FMPy — the open-source library that has quietly become the default way engineers load, inspect, and drive Functional Mock-up Units without buying a commercial co-simulation platform.

We will install FMPy, inspect a real Functional Mock-up Unit, run a single FMU two different ways (high-level and low-level), then couple two FMUs in a master loop and exchange their signals every macro-step. Along the way we cover the FMI 3.0 features that matter in practice — clocks, array variables, the binary type, and terminals — and we finish with a debugging playbook for when the loop diverges. By the end you will have a working pattern you can drop into a co-simulation digital twin without guessing at the API.

This is a tutorial, so everything below is runnable. Where something is illustrative rather than copy-paste ready, it is marked as pseudocode.

Context: what FMI and FMUs are, and what changed in 3.0

The Functional Mock-up Interface is an open standard maintained by the Modelica Association that defines how a simulation model is exported, packaged, and executed across tools. A model exported to this standard is a functional mock-up unit (FMU): a single .fmu ZIP archive containing an XML description of the model’s interface, the compiled solver code as platform-specific shared libraries, and optional resources. The full specification lives at fmi-standard.org, and it is worth keeping the spec open while you work — the C API names map almost one-to-one onto what FMPy exposes.

There are two execution modes an FMU can support. In Model Exchange, the FMU exposes its derivatives and the importing tool supplies the numerical integrator. In Co-Simulation, the FMU ships with its own embedded solver and the master only tells it “advance from time t to time t + h.” Co-simulation is the mode that lets you combine models with wildly different internal dynamics and timescales, because each one integrates itself with whatever method its original tool chose. This tutorial is about co-simulation, which is almost always what you want when assembling a digital twin from heterogeneous parts.

FMI 2.0 vs FMI 3.0

FMI 2.0 has been the workhorse since 2014 and is still everywhere. FMI 3.0, released in 2022 and now broadly supported across the tooling ecosystem in 2026, adds several capabilities that genuinely change what you can model:

  • Clocks. FMI 3.0 introduces a formal notion of clocks — discrete activation points that let an FMU represent sampled control logic, scheduled events, and triggered behavior precisely. In 2.0 you faked this with carefully chosen step sizes; in 3.0 the model can tell the master when it needs to be activated.
  • Array variables. FMI 3.0 supports true multi-dimensional array variables. Previously every signal was a scalar, so a 100-element state vector meant 100 separately-named variables. Arrays make wide interfaces — think a finite-element temperature field or a batch of sensor channels — tractable.
  • Binary type. A new Binary variable type carries arbitrary opaque byte blobs across the interface. This is the enabler for streaming things like serialized point clouds, camera frames, or protocol buffers between an FMU and the rest of a system without abusing string variables.
  • Terminals and icons. FMI 3.0 formalizes terminals — named, structured groupings of variables that belong together (for example, the four wires of an electrical connector, or a bus). Terminals make wiring two FMUs together far less error-prone, because you connect logical ports instead of hand-matching dozens of loose scalars.

There are also new integer types of explicit width, improved unit and type definitions, and an Intermediate Update mechanism for getting values mid-step. For the full enumeration, the FMI 3.0 specification on fmi-standard.org is authoritative. The Modelica Association, which stewards both FMI and the Modelica language, publishes the governing documents at modelica.org. For a deeper architectural treatment of how these units fit into a twin, see our companion piece on FMI/FMU co-simulation in digital twin architecture.

System overview

The mental model for any co-simulation is three pieces: a master algorithm that owns the clock, and two or more FMUs that each own a slice of the physics or logic. The master never integrates anything itself — it only sets inputs, calls doStep, reads outputs, and decides how signals flow between the units at each synchronization point.

The example we build couples a plant FMU (the thing being controlled — say a heated tank or a motor) with a controller FMU (the logic that drives it). The plant’s output (a measured temperature or speed) feeds the controller’s input; the controller’s output (a command) feeds the plant’s input. That closed loop is the canonical co-simulation topology, and it is exactly where the interesting numerical behavior lives.

Co-simulation master driving two coupled FMUs with signal exchange

The signals only cross the boundary at discrete synchronization points called macro-steps (or communication points). Between those points each FMU is on its own, advancing its internal solver with whatever micro-steps it likes. The size of the macro-step h and the order in which you exchange signals are the two levers that determine whether your co-simulation is accurate, stable, or garbage. We will pull both levers explicitly below.

Setting up FMPy

FMPy is a free, open-source, pure-Python library (with compiled bits handled for you) that implements an FMI master and the full import path for FMI 1.0, 2.0, and 3.0 FMUs. It is maintained by Dassault Systèmes / CATIA-Systems and is the most pragmatic on-ramp to the standard for anyone working in FMI 3.0 Python workflows.

Install it with pip. The [complete] extra pulls in the optional dependencies for the GUI, plotting, and FMU cross-checking, which are handy while you are learning:

python -m pip install --upgrade fmpy[complete]

If you only need the headless simulation engine for a server or CI job, the bare package is lighter:

python -m pip install fmpy

Inspecting an FMU before you run it

Never run an FMU blind. The first thing to do with any .fmu you receive is read its model description — the parsed modelDescription.xml — so you know its variables, their causality (input/output/parameter), their value references, and which FMI version and modes it supports. FMPy gives you both a human-readable dump and a structured object.

from fmpy import dump, read_model_description

# Human-readable summary printed to stdout
dump('plant.fmu')

# Structured access for programmatic use
model_description = read_model_description('plant.fmu')

print("FMI version:", model_description.fmiVersion)
print("Model name :", model_description.modelName)
print("Co-Sim?    :", model_description.coSimulation is not None)

# List every variable with its causality and value reference
for variable in model_description.modelVariables:
    print(
        f"{variable.name:<30} "
        f"causality={variable.causality:<10} "
        f"type={variable.type:<8} "
        f"vr={variable.valueReference}"
    )

The valueReference (often abbreviated vr) is the integer handle you will use in every low-level get/set call — names are for humans, value references are for the API. Capture the value references of the variables you intend to couple now, because you will need them in the stepping loop. A small helper makes this clean:

def value_refs(model_description, names):
    """Map a list of variable names to their integer value references."""
    by_name = {v.name: v.valueReference for v in model_description.modelVariables}
    missing = [n for n in names if n not in by_name]
    if missing:
        raise KeyError(f"Variables not found in FMU: {missing}")
    return [by_name[n] for n in names]

For FMI 3.0 FMUs, read_model_description also surfaces the new metadata: array dimensions on variables, declared clocks, and terminal groupings. Inspecting these tells you, for example, whether an output you want to read is a scalar or a 50-element array — which changes whether you call the scalar or array getter later.

Loading and stepping a Co-Simulation FMU

There are two ways to drive an FMU with FMPy, and you should know both. The high-level simulate_fmu is one function call that handles extraction, instantiation, initialization, the entire stepping loop, and result recording. It is perfect for a quick sanity check or a standalone run.

from fmpy import simulate_fmu

result = simulate_fmu(
    'plant.fmu',
    start_time=0.0,
    stop_time=20.0,
    step_size=0.01,            # macro communication step
    start_values={'u_plant': 1.0},
    output=['y_plant'],        # variables to record
)

# result is a structured NumPy array; column 'time' plus each output
print(result['time'][:5], result['y_plant'][:5])

That is the whole story when one FMU runs in isolation. But co-simulation — coupling two FMUs and routing signals between them at each macro-step — requires the lower-level API, because you need to interleave setReal/getReal and doStep calls yourself. Here is the full manual lifecycle for a single Co-Simulation FMU, which is the building block for everything that follows:

import shutil
from fmpy import read_model_description, extract
from fmpy.fmi3 import FMU3Slave   # use fmpy.fmi2.FMU2Slave for FMI 2.0 FMUs

fmu_path = 'plant.fmu'

# 1. Parse the description and unzip the FMU to a temp directory
model_description = read_model_description(fmu_path)
unzip_dir = extract(fmu_path)

# 2. Resolve the value references we will read and write
vr_u = value_refs(model_description, ['u_plant'])   # input
vr_y = value_refs(model_description, ['y_plant'])   # output

# 3. Instantiate the slave (Co-Simulation instance)
fmu = FMU3Slave(
    guid=model_description.guid,
    unzipDirectory=unzip_dir,
    modelIdentifier=model_description.coSimulation.modelIdentifier,
    instanceName='plant',
)
fmu.instantiate()

# 4. Initialize: set start values inside the init bracket
start_time, stop_time, h = 0.0, 20.0, 0.01
fmu.enterInitializationMode(startTime=start_time, stopTime=stop_time)
fmu.setFloat64(vr_u, [1.0])          # FMI 3.0 names the 64-bit real "Float64"
fmu.exitInitializationMode()

# 5. The macro-step loop: set input, step, read output
time = start_time
trace = []
while time < stop_time:
    fmu.setFloat64(vr_u, [1.0])                 # drive the input
    fmu.doStep(
        currentCommunicationPoint=time,
        communicationStepSize=h,
    )
    y = fmu.getFloat64(vr_y)                     # read the output
    trace.append((time, y[0]))
    time += h

# 6. Tear down and clean up the temp directory
fmu.terminate()
fmu.freeInstance()
shutil.rmtree(unzip_dir, ignore_errors=True)

A few things worth noticing. In FMI 3.0 the 64-bit floating-point getter and setter are getFloat64 / setFloat64, reflecting the standard’s new explicit-width types; in FMI 2.0 the same calls are getReal / setReal on an FMU2Slave. Both take and return lists of values aligned to the list of value references you pass — that batching is deliberate and matters for performance once you have many signals. The doStep call advances the FMU’s embedded solver from currentCommunicationPoint by communicationStepSize; the master is responsible for keeping its own time in sync.

Sequence diagram of the master stepping a single FMU through its lifecycle

The diagram above shows the call sequence the master executes against each FMU instance: instantiate, initialize, then loop set–step–get until the stop time, then terminate. Internalize this sequence — coupling two FMUs is just running this loop for two instances and adding one line that copies outputs to inputs in the middle.

Coupling two FMUs

Now the real thing. We instantiate two FMUs — plant and controller — and at each macro-step we exchange their signals before stepping forward. The plant produces a measurement y_plant that becomes the controller’s input meas; the controller produces a command cmd that becomes the plant’s input u_plant.

The single most important design decision is the coupling scheme, and there are two classic choices:

  • Jacobi (parallel). Freeze all inputs at the values from the previous macro-step, step every FMU over [t, t+h] independently (they could even run on separate cores), then exchange all outputs at once. Simple, parallelizable, but introduces one macro-step of signal delay across the coupling.
  • Gauss-Seidel (sequential). Step the first FMU, immediately push its fresh output into the second FMU’s input, then step the second. The second FMU sees current-step data from the first, which usually improves stability — at the cost of being inherently serial and order-dependent.

Jacobi versus Gauss-Seidel coupling of two FMUs over one macro-step

Here is a complete, runnable Gauss-Seidel master coupling two Co-Simulation FMUs. It reuses the value_refs helper from earlier:

import shutil
import numpy as np
from fmpy import read_model_description, extract
from fmpy.fmi3 import FMU3Slave


def load_slave(fmu_path, instance_name):
    """Extract, instantiate, and return (fmu, model_description, unzip_dir)."""
    md = read_model_description(fmu_path)
    unzip_dir = extract(fmu_path)
    fmu = FMU3Slave(
        guid=md.guid,
        unzipDirectory=unzip_dir,
        modelIdentifier=md.coSimulation.modelIdentifier,
        instanceName=instance_name,
    )
    fmu.instantiate()
    return fmu, md, unzip_dir


start_time, stop_time, h = 0.0, 20.0, 0.01
setpoint = 1.0

# --- Load both FMUs ---
plant, plant_md, plant_dir = load_slave('plant.fmu', 'plant')
ctrl, ctrl_md, ctrl_dir = load_slave('controller.fmu', 'controller')

# --- Resolve coupling value references ---
vr_plant_u = value_refs(plant_md, ['u_plant'])   # plant input  <- ctrl.cmd
vr_plant_y = value_refs(plant_md, ['y_plant'])   # plant output -> ctrl.meas
vr_ctrl_meas = value_refs(ctrl_md, ['meas'])     # ctrl input   <- plant.y
vr_ctrl_set = value_refs(ctrl_md, ['setpoint'])  # ctrl parameter
vr_ctrl_cmd = value_refs(ctrl_md, ['cmd'])       # ctrl output  -> plant.u

# --- Initialize both FMUs ---
for fmu in (plant, ctrl):
    fmu.enterInitializationMode(startTime=start_time, stopTime=stop_time)

ctrl.setFloat64(vr_ctrl_set, [setpoint])
plant.setFloat64(vr_plant_u, [0.0])

for fmu in (plant, ctrl):
    fmu.exitInitializationMode()

# --- Co-simulation loop (Gauss-Seidel) ---
time = start_time
rows = []
y = plant.getFloat64(vr_plant_y)                 # seed with initial output

while time < stop_time:
    # 1. Controller sees the plant's latest measurement, then steps
    ctrl.setFloat64(vr_ctrl_meas, y)
    ctrl.doStep(currentCommunicationPoint=time, communicationStepSize=h)
    cmd = ctrl.getFloat64(vr_ctrl_cmd)

    # 2. Plant receives the fresh command, then steps
    plant.setFloat64(vr_plant_u, cmd)
    plant.doStep(currentCommunicationPoint=time, communicationStepSize=h)
    y = plant.getFloat64(vr_plant_y)

    rows.append((time, y[0], cmd[0]))
    time += h

results = np.array(rows, dtype=[('time', 'f8'), ('y', 'f8'), ('cmd', 'f8')])

# --- Tear down ---
for fmu, unzip_dir in ((plant, plant_dir), (ctrl, ctrl_dir)):
    fmu.terminate()
    fmu.freeInstance()
    shutil.rmtree(unzip_dir, ignore_errors=True)

print(f"Final measurement: {results['y'][-1]:.4f} (setpoint {setpoint})")

To convert this to a Jacobi scheme, the only change is that both FMUs step against the previous step’s exchanged values, and you read both outputs only after both have stepped. As pseudocode, the inner loop becomes:

# PSEUDOCODE — Jacobi inner loop (both FMUs use last step's values)
while time < stop_time:
    ctrl.setFloat64(vr_ctrl_meas, y_prev)    # frozen from previous step
    plant.setFloat64(vr_plant_u, cmd_prev)   # frozen from previous step
    ctrl.doStep(time, h)                      # order no longer matters
    plant.doStep(time, h)
    cmd_prev = ctrl.getFloat64(vr_ctrl_cmd)   # exchange AFTER both step
    y_prev = plant.getFloat64(vr_plant_y)
    time += h

In practice Gauss-Seidel is the safer default for a tightly coupled control loop, and Jacobi is the right call when you want parallelism and the coupling is weak. For a wider discussion of when to reach for co-simulation at all versus a single monolithic model, see digital twin vs simulation architecture decisions for 2026.

Debugging the loop

Co-simulations fail in characteristic ways, and the failures look alarming until you know the four or five usual suspects. Here is the troubleshooting flow we follow.

Co-simulation troubleshooting decision flow

Algebraic loops. If FMU A’s output depends instantaneously on FMU B’s output and vice versa within the same macro-step, you have an algebraic (direct feedthrough) loop. A plain Jacobi or single-pass Gauss-Seidel step cannot resolve it and you get a delayed, oscillating, or biased answer. The fix is either to break the loop with a unit-delay or low-pass element in one path, or to iterate the macro-step — repeat the set/step/get exchange within the same [t, t+h] interval until the coupled outputs stop changing. Iterating requires FMUs that support rolling back to the start of the step (via saved state), which FMI 3.0 advertises in the model description.

Step size. Too large a macro-step h is the number-one cause of instability in co-simulation, because every coupled signal is effectively held constant (zero-order hold) across the interval. If your output oscillates or diverges, halve h and re-run before changing anything else. There is a real cost — accuracy and stability improve as h shrinks, but wall-clock time grows — so find the largest h that stays stable rather than reflexively using the smallest.

Unit and scale mismatches. If a coupled value comes out off by a clean factor (1000, 60, π/180), you are almost certainly connecting a variable in one unit to a port expecting another — kelvin to celsius, rad/s to rpm, degrees to radians. FMI 3.0’s richer unit definitions in the model description let you catch this programmatically: read the unit attribute on both sides of every coupling and assert they match before you start stepping.

Error control and doStep status. Always check the return status of doStep. A status of “discard” means the FMU could not complete the requested step and may be able to do a shorter one; “error” means something is genuinely wrong. Do not ignore these — a silent failure here is how you end up debugging “correct code that produces nonsense.” Wrap the call:

from fmpy.fmi3 import fmi3OK

status = fmu.doStep(
    currentCommunicationPoint=time,
    communicationStepSize=h,
)
if status != fmi3OK:
    raise RuntimeError(
        f"doStep failed at t={time:.4f} with status {status}; "
        f"try reducing h or checking input ranges"
    )

(Note: FMPy raises on hard errors by default, but checking the returned status explicitly is good hygiene for “discard” cases where you want to retry with a smaller step rather than crash.)

Log everything. When a run misbehaves, the fastest path to the cause is to record every coupled signal at every macro-step and plot them. A signal that is flat when it should move, or that jumps a step before its driver, points straight at a wiring or ordering bug. The rows list in the coupling example above is your audit trail — never throw it away while debugging.

Trade-offs and gotchas

Co-simulation vs model exchange. Co-simulation buys you encapsulation: each FMU keeps its own solver, its IP stays hidden inside compiled binaries, and you combine tools that would never otherwise interoperate. The cost is the macro-step coupling error and the loss of a single global error controller — there is no master integrator that sees the whole system’s dynamics. Model exchange gives you that global controller and tighter accuracy, but forces every model into one integrator and one tool’s numerical assumptions. For heterogeneous twins, co-simulation almost always wins despite the coupling error.

Numerical stability is your responsibility. The master, not the FMUs, owns stability. Zero-order hold on coupled signals, fixed macro-steps, and naive Jacobi exchange can destabilize a loop that each FMU handles fine in isolation. Treat h and the coupling scheme as tuning parameters you validate, not constants you assume.

IP protection cuts both ways. The compiled-binary packaging that lets a supplier ship you an FMU without revealing their model is a feature for them and a constraint for you — you cannot inspect the internal solver, you cannot always roll back a step, and a buggy FMU is a black box. Insist that suppliers declare canGetAndSetFMUState (needed for step iteration) in the model description if you intend to do anything beyond single-pass stepping.

Platform binaries. An FMU contains compiled libraries for specific platforms. An FMU built only for Windows will not instantiate on a Linux CI runner. Check the binaries folder inside the .fmu (or have FMPy’s validation flag it) before you are surprised in a pipeline.

Practical recommendations and checklist

Before you ship a co-simulation, walk this list:

  • Inspect first. dump and read_model_description every FMU; capture value references, causalities, units, and the supported FMI version and modes.
  • Validate platform binaries match where the code will run.
  • Start with the largest stable macro-step, then shrink only as accuracy demands. Document the h you chose and why.
  • Prefer Gauss-Seidel for tightly coupled control loops; reserve Jacobi for weakly coupled, parallelizable subsystems.
  • Assert unit matches on every coupling before the loop starts.
  • Check doStep status on every call; handle “discard” by retrying with a smaller step where the FMU supports state rollback.
  • Log every coupled signal every macro-step during development.
  • Verify canGetAndSetFMUState if you need step iteration or rollback.
  • Tear down cleanlyterminate, freeInstance, and remove temp extract directories so long-running services don’t leak disk.

For a worked end-to-end example of these ideas in a manufacturing context, see our CNC machine digital twin tutorial for 2026, which couples a machine model with control logic using the same patterns.

FAQ

What is the difference between FMI co-simulation and model exchange?
In co-simulation the FMU contains its own numerical solver and the master only asks it to advance from time t to t + h. In model exchange the FMU exposes its derivatives and the importing tool supplies the integrator. Co-simulation is what you use to combine models from different tools; model exchange gives tighter global accuracy but forces everything into one solver.

Does FMPy support FMI 3.0?
Yes. FMPy supports FMI 1.0, 2.0, and 3.0 for both import and simulation, including the new FMI 3.0 types such as Float64, integer types of explicit width, arrays, the Binary type, and clocks. Use fmpy.fmi3.FMU3Slave for the low-level co-simulation API on 3.0 FMUs.

How do I choose the macro-step size?
Start with the largest step at which the coupled outputs remain stable and accurate, then reduce it until further reduction stops changing the answer meaningfully. Too large a step holds coupled signals constant across the interval and is the leading cause of instability; too small wastes compute. It is a tuning parameter, not a fixed constant.

Jacobi or Gauss-Seidel — which coupling scheme should I use?
Use Gauss-Seidel (step one FMU, push its fresh output into the next, then step that one) for tightly coupled loops like a plant–controller pair, because the downstream FMU sees current-step data and stability improves. Use Jacobi (freeze inputs, step all FMUs in parallel, then exchange) when the coupling is weak and you want to parallelize across cores.

Why does my co-simulation oscillate or blow up?
Most often the macro-step is too large, an algebraic loop is unresolved, or signals are coupled in mismatched units. Halve the step size first; if that does not fix it, look for direct feedthrough between FMUs (which needs step iteration or a delay element) and verify the units on both sides of every coupling.

Can I hide my model’s internals when sharing an FMU?
Yes — that is a core reason co-simulation exists. The FMU ships your solver as compiled platform binaries, so recipients can run your model without seeing the equations or source. If you also want to forbid step rollback or limit introspection, control what you declare in the model description.

Further reading

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *