Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# fmuloader ⚙️

A lightweight, **zero-dependency** Python library for loading and calling FMI 2.0
and 3.0 shared-library binaries via ctypes.

[![PyPI version](https://badge.fury.io/py/fmuloader.svg)](https://badge.fury.io/py/fmuloader)

## Installation 📦

Add `fmuloader` to your project with `uv`:

```bash
uv add fmuloader
```

> To install `uv`, see <https://docs.astral.sh/uv/getting-started/installation/>

## How to use 🚀

### FMI 2.0 Co-Simulation

```python
from fmuloader.fmi2 import Fmi2Slave, Fmi2Type

slave = Fmi2Slave("model.fmu", model_identifier="MyModel")

slave.instantiate("instance1", Fmi2Type.CO_SIMULATION, guid="{...}")
slave.setup_experiment(start_time=0.0, stop_time=10.0)
slave.enter_initialization_mode()
slave.exit_initialization_mode()

t, dt = 0.0, 0.01
while t < 10.0:
slave.do_step(t, dt)
t += dt

values = slave.get_real([1, 2])
slave.terminate()
slave.free_instance()
```

### FMI 3.0 Co-Simulation

```python
from fmuloader.fmi3 import Fmi3Slave

slave = Fmi3Slave("model.fmu", model_identifier="MyModel")

slave.instantiate_co_simulation("instance1", instantiation_token="{...}")
slave.enter_initialization_mode(start_time=0.0, stop_time=10.0)
slave.exit_initialization_mode()

t, dt = 0.0, 0.01
while t < 10.0:
result = slave.do_step(t, dt)
t += dt

values = slave.get_float64([1, 2])
slave.terminate()
slave.free_instance()
```

### FMI 3.0 Model Exchange

```python
from fmuloader.fmi3 import Fmi3Slave

slave = Fmi3Slave("model.fmu", model_identifier="MyModel")

slave.instantiate_model_exchange("instance1", instantiation_token="{...}")
slave.enter_initialization_mode(start_time=0.0)
slave.exit_initialization_mode()

result = slave.update_discrete_states()
while result.discrete_states_need_update:
result = slave.update_discrete_states()
slave.enter_continuous_time_mode()

nx = slave.get_number_of_continuous_states()
slave.set_time(0.0)
derivs = slave.get_continuous_state_derivatives(nx)

slave.terminate()
slave.free_instance()
```

### FMI 3.0 Scheduled Execution

```python
from fmuloader.fmi3 import Fmi3Slave

slave = Fmi3Slave("model.fmu", model_identifier="MyModel")

slave.instantiate_scheduled_execution("instance1", instantiation_token="{...}")
slave.enter_initialization_mode(start_time=0.0)
slave.exit_initialization_mode()

slave.activate_model_partition(clock_reference=1001, activation_time=0.0)

slave.terminate()
slave.free_instance()
```

## Features ✨

- Load and call FMI **2.0** and **3.0** shared-library binaries via ctypes
- Full Co-Simulation, Model Exchange, and Scheduled Execution support
- All FMI 3.0 data types: `Float32`, `Float64`, `Int8`–`Int64`, `UInt8`–`UInt64`, `Boolean`, `String`, `Binary`, `Clock`
- FMU state management: get, set, serialize, and deserialize
- Directional and adjoint derivatives
- Clock interval and shift functions
- Context manager support for automatic cleanup
- Automatic platform detection (macOS, Linux, Windows; x86, x86_64, aarch64)

## Design philosophy 💡

**fmuloader intentionally separates binary loading from modelDescription.xml parsing.**

Most FMI libraries tightly couple XML parsing with binary invocation, pulling in
heavy dependencies and making it hard to use one without the other. fmuloader
takes a different approach:

- **fmuloader** handles only the **binary loading** — extracting the shared
library from an `.fmu` archive, binding every C function via ctypes, and
exposing thin Python wrappers.
- **modelDescription.xml parsing** is left to the user or to a dedicated library
like [fmureader](https://github.com/time-integral/fmureader).

This means:

- **Zero runtime dependencies** — fmuloader uses only the Python standard library.
- **Bring your own parser** — use fmureader, FMPy, lxml, or anything else to
read GUIDs, value references, and variable metadata. Then pass them straight
to fmuloader.
- **Minimal surface area** — each module (`fmi2`, `fmi3`) is a single file you
can vendor into your own project if needed.

```python
# Example: combine fmureader (parsing) with fmuloader (execution)
import fmureader.fmi3 as reader
from fmuloader.fmi3 import Fmi3Slave

md = reader.read_model_description("model.fmu")

slave = Fmi3Slave("model.fmu", model_identifier=md.co_simulation.model_identifier)
slave.instantiate_co_simulation("inst", instantiation_token=md.instantiation_token)
# ... use md.model_variables to look up value references, then call slave.get_float64() etc.
```

## Related projects 🔗

- [fmureader](https://github.com/time-integral/fmureader) — Lightweight Pydantic-based modelDescription.xml parser for FMI 2.0 and 3.0
- [FMPy](https://github.com/CATIA-Systems/FMPy) — Full-featured FMI library with simulation, GUI, and more

## Licensing 📄

The code in this project is licensed under MIT license.
See the [LICENSE](LICENSE) file for details.
139 changes: 139 additions & 0 deletions tests/test_readme_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Tests that mirror the README.md code examples using real reference FMUs.

Each test corresponds to a code block in the README, ensuring the
documented API actually works.
"""

import platform

import pytest
from fmuloader.fmi2 import Fmi2Slave, Fmi2Type
from fmuloader.fmi3 import Fmi3Slave

# FMI 2.0 reference FMUs don't ship aarch64-darwin binaries
skip_arm64 = pytest.mark.skipif(
platform.machine() == "arm64",
reason="FMI 2.0 reference FMU binaries not available on ARM64",
)


class TestReadmeFmi2CoSimulation:
"""README § FMI 2.0 Co-Simulation."""

@skip_arm64
def test_fmi2_co_simulation(self, reference_fmus_dir):
# -- mirrors README example: FMI 2.0 Co-Simulation --
from fmuloader.fmi2 import Fmi2Slave, Fmi2Type

slave = Fmi2Slave(
reference_fmus_dir / "2.0/BouncingBall.fmu",
model_identifier="BouncingBall",
)

slave.instantiate(
"instance1",
Fmi2Type.CO_SIMULATION,
guid="{1AE5E10D-9521-4DE3-80B9-D0EAAA7D5AF1}",
)
slave.setup_experiment(start_time=0.0, stop_time=10.0)
slave.enter_initialization_mode()
slave.exit_initialization_mode()

t, dt = 0.0, 0.01
while t < 1.0: # shortened from 10.0 for speed
slave.do_step(t, dt)
t += dt

values = slave.get_real([1, 3])
assert len(values) == 2
slave.terminate()
slave.free_instance()


class TestReadmeFmi3CoSimulation:
"""README § FMI 3.0 Co-Simulation."""

def test_fmi3_co_simulation(self, reference_fmus_dir):
# -- mirrors README example: FMI 3.0 Co-Simulation --
from fmuloader.fmi3 import Fmi3Slave

slave = Fmi3Slave(
reference_fmus_dir / "3.0/BouncingBall.fmu",
model_identifier="BouncingBall",
)

slave.instantiate_co_simulation(
"instance1",
instantiation_token="{1AE5E10D-9521-4DE3-80B9-D0EAAA7D5AF1}",
)
slave.enter_initialization_mode(start_time=0.0, stop_time=10.0)
slave.exit_initialization_mode()

t, dt = 0.0, 0.01
while t < 1.0: # shortened from 10.0 for speed
result = slave.do_step(t, dt)
t += dt

values = slave.get_float64([1, 3])
assert len(values) == 2
slave.terminate()
slave.free_instance()


class TestReadmeFmi3ModelExchange:
"""README § FMI 3.0 Model Exchange."""

def test_fmi3_model_exchange(self, reference_fmus_dir):
# -- mirrors README example: FMI 3.0 Model Exchange --
from fmuloader.fmi3 import Fmi3Slave

slave = Fmi3Slave(
reference_fmus_dir / "3.0/VanDerPol.fmu",
model_identifier="VanDerPol",
)

slave.instantiate_model_exchange(
"instance1",
instantiation_token="{BD403596-3166-4232-ABC2-132BDF73E644}",
)
slave.enter_initialization_mode(start_time=0.0)
slave.exit_initialization_mode()

result = slave.update_discrete_states()
while result.discrete_states_need_update:
result = slave.update_discrete_states()
slave.enter_continuous_time_mode()

nx = slave.get_number_of_continuous_states()
assert nx == 2
slave.set_time(0.0)
derivs = slave.get_continuous_state_derivatives(nx)
assert len(derivs) == nx

slave.terminate()
slave.free_instance()


class TestReadmeFmi3ScheduledExecution:
"""README § FMI 3.0 Scheduled Execution."""

def test_fmi3_scheduled_execution(self, reference_fmus_dir):
# -- mirrors README example: FMI 3.0 Scheduled Execution --
from fmuloader.fmi3 import Fmi3Slave

slave = Fmi3Slave(
reference_fmus_dir / "3.0/Clocks.fmu",
model_identifier="Clocks",
)

slave.instantiate_scheduled_execution(
"instance1",
instantiation_token="{C5F142BA-B849-42DA-B4A1-4745BFF3BE28}",
)
slave.enter_initialization_mode(start_time=0.0)
slave.exit_initialization_mode()

slave.activate_model_partition(clock_reference=1001, activation_time=0.0)

slave.terminate()
slave.free_instance()