From 937c10a9448677032181d20577ecdc4d3ca58dce Mon Sep 17 00:00:00 2001 From: Stephen Smith Date: Sat, 14 Nov 2026 12:04:59 +0000 Subject: [PATCH] Add a guide for migrating from PythonFMU docs/migration.md explains how to turn a PythonFMU (FMI 2.0) model into a PythonFMU3 (FMI 3.0) one: a quick-reference table of renamed packages, classes, variable types and CLI commands, step-by-step instructions, and a before/after example. It also covers the behaviour differences users hit: class-level metadata is ignored, setup_experiment is never called, time must be declared as the independent variable, and leading underscores are stripped from variable names. The guide is added to the docs navigation, and a test runs the PythonFMU3 example from it. --- docs/migration.md | 188 +++++++++++++++++++++++++ mkdocs.yml | 1 + pythonfmu3/tests/test_migration_doc.py | 34 +++++ 3 files changed, 223 insertions(+) create mode 100644 docs/migration.md create mode 100644 pythonfmu3/tests/test_migration_doc.py diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..0dcb1c9 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,188 @@ +# Migrating from PythonFMU + +[PythonFMU](https://github.com/NTNU-IHB/PythonFMU) exports FMI 2.0 Co-simulation FMUs. +PythonFMU3 started as a fork of it and exports FMI 3.0 Co-simulation and Model Exchange +FMUs. A model is written the same way in both: a class that registers its attributes as +variables and implements `do_step`. Most of the translation is renaming. This page lists +the renames and the few behaviour changes to check. + +PythonFMU3 builds **FMI 3.0** FMUs only, so the simulator using them must support FMI 3.0 +(for example FMPy, OMSimulator or `fmusim`). If you still need FMI 2.0 FMUs, keep +PythonFMU for those; both packages can be installed side by side. + +## Quick reference + +| PythonFMU (FMI 2.0) | PythonFMU3 (FMI 3.0) | +| --- | --- | +| `pip install pythonfmu` | `pip install pythonfmu3` | +| `from pythonfmu import ...` | `from pythonfmu3 import ...` | +| `Fmi2Slave` | `Fmi3Slave` (Co-simulation); `Fmi3SlaveBase` + `ModelExchange` for Model Exchange | +| `Fmi2Causality`, `Fmi2Variability`, `Fmi2Initial`, `Fmi2Status` | `Fmi3Causality`, `Fmi3Variability`, `Fmi3Initial`, `Fmi3Status` (same members) | +| `Real` | `Float64` | +| `Integer` | `Int32` (FMI 2.0 integers are 32-bit); `Int64` and `UInt64` also exist | +| `Boolean`, `String` | unchanged | +| time is implicit | register a `Float64` with `causality=Fmi3Causality.independent` | +| `setup_experiment(start_time, stop_time, tolerance)` | not called: use `enter_initialization_mode()` | +| `do_step(...)` returns `bool` | returns `bool` or an `Fmi3StepResult` | +| class attributes `author = ...`, `description = ...` | set them in `__init__` (`self.author = ...`) | +| `pythonfmu build -f model.py` | `pythonfmu3 build -f model.py` | +| `pythonfmu deploy -f model.fmu` | `pythonfmu3 deploy -f model.fmu` | +| `pythonfmu buildcsv` | not available | +| `--interpolate-inputs` | not available | + +These are unchanged: `register_variable` (including `getter=`/`setter=` and dotted names +for nested objects), `enter_initialization_mode`, `exit_initialization_mode`, +`terminate`, `self.log(...)`, `self.resources`, `DefaultExperiment`, project files and +the `requirements.txt`/`environment.yml` deployment, and the `--no-external-tool`, +`--no-variable-step`, `--only-one-per-process`, `--handle-state` and +`--serialize-state` build options. + +New in PythonFMU3: Model Exchange, `Int64`/`UInt64`/`Enumeration` variables, NumPy array +variables, units, structural parameters and `Fmi3StepResult`. See the +[Co-simulation](usage.md) and [Model Exchange](usageMX.md) pages. + +## Step by step + +### 1. Rename imports, classes and types + +Replace `pythonfmu` with `pythonfmu3`, every `Fmi2...` with `Fmi3...`, `Real` with +`Float64` and `Integer` with `Int32`. + +Integer, Boolean and String variables can't be continuous. FMI treats a missing +variability as continuous, so give them `variability=Fmi3Variability.discrete` (or +`fixed`, `tunable` or `constant` for parameters). PythonFMU didn't check this either, so +migration is a good moment to fix it. + +### 2. Declare time + +FMI 2.0 FMUs had an implicit time variable. In FMI 3.0 the independent variable is +declared like any other, so register one and keep it up to date: + +```python skip +self.time = 0.0 +self.register_variable(Float64("time", causality=Fmi3Causality.independent, + variability=Fmi3Variability.continuous)) +``` + +In `do_step`, set `self.time = current_time + step_size`. Model Exchange classes must +define `self.time` (the importer sets it). + +### 3. Move class-level metadata into `__init__` + +PythonFMU read `author`, `description`, `license`, `version` and `copyright` from class +attributes. PythonFMU3 only uses instance attributes, so class-level values are silently +dropped from the model description. Set them after `super().__init__(**kwargs)`: + +```python skip +def __init__(self, **kwargs): + super().__init__(**kwargs) + self.author = "Jane Doe" + self.description = "Integrates its input" +``` + +### 4. Replace `setup_experiment` + +FMI 3.0 removed `fmi2SetupExperiment`; the start time, stop time and tolerance are +passed to `fmi3EnterInitializationMode` instead. PythonFMU3 never calls +`setup_experiment`, and does not pass these values to Python. Move any initialisation you +did there into `enter_initialization_mode()` (or `exit_initialization_mode()`), and use +`current_time` in `do_step` if you need the time. + +### 5. Check variable names that start with `_` + +PythonFMU3 strips leading underscores when it looks up the attribute behind a variable: +`Float64("_x")` reads and writes `self.x`, not `self._x`. Either rename such variables or +pass `getter=` and `setter=` explicitly. + +### 6. Rebuild and test + +```bash +pip install pythonfmu3 +pythonfmu3 build -f model.py +``` + +Then run the new FMU in an FMI 3.0 importer and compare the results with your FMI 2.0 +FMU, e.g. with FMPy: + +```python skip +import fmpy +result = fmpy.simulate_fmu("Integrator.fmu", stop_time=10.0, start_values={"gain": 2.0}) +``` + +## Complete example + +**Before**: PythonFMU (FMI 2.0) + +```python skip +from pythonfmu import Fmi2Causality, Fmi2Slave, Fmi2Variability, Integer, Real + + +class Integrator(Fmi2Slave): + + author = "Jane Doe" + description = "Integrates its input" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.u = 0.0 + self.gain = 1.0 + self.y = 0.0 + self.steps = 0 + + self.register_variable(Real("u", causality=Fmi2Causality.input)) + self.register_variable(Real("gain", causality=Fmi2Causality.parameter, + variability=Fmi2Variability.tunable)) + self.register_variable(Real("y", causality=Fmi2Causality.output)) + self.register_variable(Integer("steps", causality=Fmi2Causality.output, + variability=Fmi2Variability.discrete)) + + def setup_experiment(self, start_time, stop_time, tolerance): + self.y = 0.0 + self.steps = 0 + + def do_step(self, current_time, step_size): + self.y += self.gain * self.u * step_size + self.steps += 1 + return True +``` + +**After**: PythonFMU3 (FMI 3.0) + +```python +from pythonfmu3 import Fmi3Causality, Fmi3Slave, Fmi3Variability, Float64, Int32 + + +class Integrator(Fmi3Slave): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.author = "Jane Doe" # was a class attribute + self.description = "Integrates its input" + + self.time = 0.0 # new: the independent variable + self.u = 0.0 + self.gain = 1.0 + self.y = 0.0 + self.steps = 0 + + self.register_variable(Float64("time", causality=Fmi3Causality.independent, + variability=Fmi3Variability.continuous)) + self.register_variable(Float64("u", causality=Fmi3Causality.input, start=0.0)) + self.register_variable(Float64("gain", causality=Fmi3Causality.parameter, + variability=Fmi3Variability.tunable, start=1.0)) + self.register_variable(Float64("y", causality=Fmi3Causality.output)) + self.register_variable(Int32("steps", causality=Fmi3Causality.output, + variability=Fmi3Variability.discrete)) + + def enter_initialization_mode(self): # was setup_experiment + self.y = 0.0 + self.steps = 0 + + def do_step(self, current_time, step_size): + self.y += self.gain * self.u * step_size + self.steps += 1 + self.time = current_time + step_size + return True +``` diff --git a/mkdocs.yml b/mkdocs.yml index 87ab0a0..9e932c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,5 +5,6 @@ nav: - Co-simulation: usage.md - Model Exchange: usageMX.md - Dependencies: dependencies.md + - Migrating from PythonFMU: migration.md - Changelog: changelog.md theme: readthedocs \ No newline at end of file diff --git a/pythonfmu3/tests/test_migration_doc.py b/pythonfmu3/tests/test_migration_doc.py new file mode 100644 index 0000000..788ab23 --- /dev/null +++ b/pythonfmu3/tests/test_migration_doc.py @@ -0,0 +1,34 @@ +"""Check that the PythonFMU3 example in docs/migration.md works (source checkout only).""" +import pathlib + +import pytest + +from pythonfmu3.tests.test_docs import extract_python_code_from_md + +GUIDE = pathlib.Path(__file__).parent.parent.parent / "docs" / "migration.md" + + +@pytest.mark.doc +def test_migration_guide_example(): + if not GUIDE.exists(): + pytest.skip("docs/migration.md is only available in a source checkout") + + namespace = {} + for code in extract_python_code_from_md(GUIDE): # the FMI 2.0 blocks are marked `python skip` + exec(code, namespace) + + model = namespace["Integrator"](instance_name="test") + description = model.to_xml() + assert description.get("author") == "Jane Doe" + assert [v.causality.name for v in model.vars.values()][0] == "independent" + + model.gain, model.u = 2.0, 1.5 + model.enter_initialization_mode() + model.exit_initialization_mode() + t = 0.0 + for _ in range(10): + model.do_step(t, 0.1) + t += 0.1 + assert model.y == pytest.approx(3.0) + assert model.steps == 10 + assert model.time == pytest.approx(1.0)