diff --git a/README.md b/README.md index 5c3e8d9..765eebd 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ This project is a fork of the original PythonFMU repository available at https:/ ### Support: -Please take a look at the examples to see the supported features. -We currently support both the Model exchange and Cosimulation Interfaces. +Both the Model Exchange and Co-simulation interfaces are supported, with Float64, Int32, +Int64, UInt64, Boolean, String and Enumeration variables (plus NumPy arrays of the numeric +types). See the [documentation](https://pythonfmu3.readthedocs.io/) for the full list of +supported features and current limitations, and the `examples` folder for more models. See the [changelog](docs/changelog.md) for what changed in each release. @@ -46,35 +48,37 @@ pip install pythonfmu3 ``` 2. Create a new class extending the `Fmi3Slave` class declared in the `pythonfmu3.fmi3slave` module (see below for an example). + Keep a single FMU class per script. 3. Run `pythonfmu3 build` to create the fmu. ``` -usage: pythonfmu3 build [-h] -f SCRIPT_FILE [-d DEST] [--doc DOCUMENTATION_FOLDER] [--terminals TERMINALS_FILE] [--no-external-tool] - [--no-variable-step] [--interpolate-inputs] [--only-one-per-process] [--handle-state] - [--serialize-state] [--use-memory-management] - [Project files [Project files ...]] +usage: pythonfmu3 build [-h] -f SCRIPT_FILE [-d DEST] [--doc DOCUMENTATION_FOLDER] [--terminals TERMINALS] + [--no-external-tool] [--only-one-per-process] [--handle-state] [--serialize-state] + [--no-variable-step] [--needs-completed-step] + [Project files ...] Build an FMU from a Python script. positional arguments: Project files Additional project files required by the Python script. -optional arguments: +options: -h, --help show this help message and exit -f SCRIPT_FILE, --file SCRIPT_FILE Path to the Python script. -d DEST, --dest DEST Where to save the FMU. --doc DOCUMENTATION_FOLDER Documentation folder to include in the FMU. - --terminals TERMINALS_FILE + --terminals TERMINALS Terminals file (terminalsAndIcons.xml) to include in the FMU. --no-external-tool If given, needsExecutionTool=false - --no-variable-step If given, canHandleVariableCommunicationStepSize=false - --interpolate-inputs If given, canInterpolateInputs=true --only-one-per-process If given, canBeInstantiatedOnlyOncePerProcess=true - --handle-state If given, canGetAndSetFMUstate=true - --serialize-state If given, canSerializeFMUstate=true + --handle-state If given, canGetAndSetFMUState=true + --serialize-state If given, canSerializeFMUState=true + --no-variable-step If given, canHandleVariableCommunicationStepSize=false + --needs-completed-step + If given, needsCompletedIntegratorStep=true ``` ### How do I build an FMU from python code with third-party dependencies? @@ -84,7 +88,7 @@ _PythonFMU_ does not package a full environment within the FMU. However, you can package a `requirements.txt` or `environment.yml` file within your FMU following these steps: 1. Install _pythonfmu_ package: `pip install pythonfmu3` -2. Create a new class extending the `Fmi3Slave` class declared in the `pythonfmu3.fmi3slave` module (see below for an example). +2. Create a new class extending the `Fmi3Slave` class declared in the `pythonfmu3.fmi3slave` module (see the examples below). 3. Create a `requirements.txt` file (to use _pip_ manager) and/or a `environment.yml` file (to use _conda_ manager) that defines your dependencies. 4. Run `pythonfmu3 build -f myscript.py requirements.txt` to create the fmu including the dependencies file. @@ -126,6 +130,8 @@ class PythonSlave(Fmi3Slave): self.author = "John Doe" self.description = "A simple description" + # Create attributes before registering them: register_variable only adds a setter + # (needed for inputs and parameters) for attributes that already exist. self.time = 0.0 self.intOut = 1 self.realOut = 3.0 @@ -165,7 +171,7 @@ To create a model exchange FMU you must inherit from `Fmi3SlaveBase` and one of ```python -from pythonfmu3 import Fmi3Causality, Fmi3SlaveBase, Float64, ModelExchange +from pythonfmu3 import Fmi3Causality, Fmi3Initial, Fmi3SlaveBase, Fmi3Variability, Float64, ModelExchange from typing import List @@ -200,7 +206,9 @@ class PythonSlaveMX(Fmi3SlaveBase, ModelExchange): ``` -Note: There is a hard requirement that we have a `self.time` member in the derived class. +Note: Model Exchange classes must define a `self.time` member. Derivative variables give the +value reference of their state with `derivative=`; value references are assigned in +registration order starting at 0 (here `time` = 0, `x0` = 1, `x1` = 2). #### Create the FMU diff --git a/docs/changelog.md b/docs/changelog.md index 0eaeb5c..18e3e03 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,10 @@ `resources/pythonfmu_libpython.txt` records the building interpreter's Python library. - The builder finds the model class by importing the script, so class definitions whose base classes span several lines work. A script must define a single FMU class. +- `Fmi3UpdateDiscreteStatesResult` fields renamed to the FMI names + `discreteStatesNeedUpdate` and `nominalContinuousStatesChanged`. The old names + (`discreteStateNeedsUpdate`, `nominalsOfContinuousStatesChanged`) still work as aliases. +- Python 3.9 or later is required; the package is built from `pyproject.toml`. ### Fixed - Crash (use-after-free) when freeing an instance after a Python exception. @@ -30,3 +34,11 @@ building from Python (e.g. `canHandleVariableCommunicationStepSize` came out `false`). - `--terminals` packed the wrong file as `terminalsAndIcons.xml`. - Array variable setters on NumPy 2.5 or later. +- `discreteStatesNeedUpdate` and `nominalContinuousStatesChanged` returned from + `update_discrete_states` were ignored because of the field-name mismatch above. +- FMUs built on Apple Silicon and ARM Linux use the correct platform folders + (`aarch64-darwin`, `aarch64-linux`). +- Documentation: corrected the PID controller and quick-start examples (their inputs and + parameters could not be set), the `pythonfmu3 build` options, the list of supported + types, and added a limitations section. Documentation examples are now checked to + produce working models in the test suite. diff --git a/docs/dependencies.md b/docs/dependencies.md index f691ff8..8837ebb 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -5,7 +5,7 @@ _PythonFMU3_ does not package a full environment within the FMU. However, you can package a `requirements.txt` or `environment.yml` file within your FMU following these steps: 1. Install _pythonfmu3_ package: `pip install pythonfmu3` -2. Create a new class extending the `Fmi3Slave` class declared in the `pythonfmu3.fmi3slave` module (see below for an example). +2. Create a new class extending the `Fmi3Slave` class declared in the `pythonfmu3.fmi3slave` module (see the [Co-simulation examples](usage.md)). 3. Create a `requirements.txt` file (to use _pip_ manager) and/or a `environment.yml` file (to use _conda_ manager) that defines your dependencies. 4. Run `pythonfmu3 build -f myscript.py requirements.txt` to create the fmu including the dependencies file. diff --git a/docs/index.md b/docs/index.md index 6100c88..25b9ce5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,9 +18,9 @@ PythonFMU3 bridges the gap between Python's rich ecosystem and the Functional Mo - **🔄 Dual FMU Support**: Create both Co-simulation and Model Exchange FMUs - **🐍 Pure Python**: Write your models in familiar Python syntax - **📦 Easy Packaging**: Simple CLI tool to build FMUs from Python scripts -- **🔧 Rich Variable Types**: Support for Float64, Int32, Boolean, String, and arrays -- **⚡ High Performance**: Optimized C++ runtime with embedded Python interpreter -- **🌐 Cross-Platform**: Windows, Linux, and macOS support +- **🔧 Variable Types**: Float64, Int32, Int64, UInt64, Boolean, String and Enumeration, plus NumPy arrays of the numeric types +- **⚡ Native FMU binaries**: a C++ FMI 3.0 implementation that runs your Python code in an embedded interpreter +- **🌐 Cross-Platform**: prebuilt for Windows (x86_64) and Linux (x86_64); macOS and ARM build from source - **📊 NumPy Integration**: Native support for array operations and scientific computing - **🎛️ Parameter Management**: Configurable parameters with units and constraints - **🔍 Debugging Support**: Built-in logging and error handling @@ -60,13 +60,14 @@ class MyModel(Fmi3Slave): def __init__(self, **kwargs): super().__init__(**kwargs) - # Register variables - self.register_variable(Float64("input", causality=Fmi3Causality.input)) - self.register_variable(Float64("output", causality=Fmi3Causality.output)) - - # Initialize values + # Create the attributes first: register_variable only adds a setter + # (needed for inputs and parameters) for attributes that already exist self.input = 0.0 self.output = 0.0 + + # Register variables + self.register_variable(Float64("input", causality=Fmi3Causality.input, start=0.0)) + self.register_variable(Float64("output", causality=Fmi3Causality.output)) def do_step(self, current_time, step_size): # Your model logic here @@ -80,6 +81,7 @@ pythonfmu3 build -f my_model.py ``` **3. Use in simulation:** + ```python import fmpy result = fmpy.simulate_fmu('MyModel.fmu', stop_time=10.0) @@ -91,15 +93,17 @@ packages; see [Running FMUs outside Python](install.md#running-fmus-outside-pyth ## Architecture -PythonFMU3 uses a hybrid architecture combining the performance of C++ with the flexibility of Python: +An FMU built by PythonFMU3 contains your script, a copy of the `pythonfmu3` package and a +native library implementing the FMI 3.0 C API: -- **C++ Runtime**: High-performance FMI interface implementation -- **Embedded Python**: Isolated Python interpreter for model execution +- **C++ wrapper**: implements the FMI functions and forwards each call to your Python + class (`do_step`, the variable getters and setters, ...) +- **Python interpreter**: the host's interpreter when the importer is itself Python (e.g. + FMPy), otherwise one started inside the simulation process. Your model's packages must + be installed in that Python; see [Dependencies](dependencies.md) - **Python loader (Linux/macOS)**: the FMU's library has no link-time dependency on - Python. It uses the host's interpreter when there is one, and otherwise loads - `libpython` on first instantiation, so native (non-Python) importers can load the FMU -- **Smart Bridging**: Efficient data exchange between C++ and Python -- **Memory Management**: Automatic resource cleanup and garbage collection + Python, and loads `libpython` on first instantiation when the host has none, so native + (non-Python) importers can load the FMU This project is a fork of the original PythonFMU repository available at https://github.com/NTNU-IHB/PythonFMU, which was used as the basis for adding support for FMI 3.0. While we have made efforts to expand the functionality of this project, it currently has some limitations and does not support all the features of FMI 3.0. We would like to acknowledge and give credit to the original PythonFMU project for their contributions to this work. @@ -108,40 +112,53 @@ This project is a fork of the original PythonFMU repository available at https:/ ### FMI 3.0 Features - ✅ **Co-simulation FMUs** with `do_step()` method - ✅ **Model Exchange FMUs** with derivative functions -- ✅ **Variable types**: Float64, Int32, UInt64, Boolean, String -- ✅ **Array variables** with dimensions and structural parameters +- ✅ **Variable types**: Float64, Int32, Int64, UInt64, Boolean, String, Enumeration +- ✅ **Array variables** (NumPy) of the numeric and Boolean types, with fixed sizes or + sizes given by a structural parameter - ✅ **Parameters** with fixed, tunable, and constant variability -- ✅ **Units and display units** for physical quantities +- ✅ **Units** for physical quantities - ✅ **Initial values** and causality declarations -- ✅ **Event handling** and discrete state updates -- ✅ **State serialization** and deserialization -- ✅ **Logging** and error reporting +- ✅ **Event indicators** and discrete state updates (Model Exchange) +- ✅ **Getting, setting and serializing FMU state** (opt-in with `--handle-state` and + `--serialize-state`; see the limitations below) +- ✅ **Logging**, with Python exceptions reported with their traceback ### Advanced Capabilities - ✅ **NumPy integration** for scientific computing - ✅ **Custom getters/setters** for complex variable access - ✅ **Nested variables** with structured naming - ✅ **Resource management** for external files and dependencies -- ✅ **Multi-platform support** (Windows, Linux, macOS) +- ✅ **Runs in any FMI 3.0 importer**: Python-based (FMPy) and native (OMSimulator, + `fmusim`, Simulink, ...) - ✅ **Memory-efficient** variable handling ### Limitations -- ⚠️ **Binary variables** not yet supported -- ⚠️ **Clock variables** and scheduled execution not implemented -- ⚠️ **Some FMI 3.0 advanced features** are still in development +- ⚠️ **Variable types** Float32, Int8, Int16, UInt8, UInt16, UInt32, Binary and Clock are not + supported, nor are String arrays +- ⚠️ **Scheduled Execution** is not supported, and Co-simulation ignores Event Mode and + early return +- ⚠️ **Directional and adjoint derivatives** are not available +- ⚠️ **Array sizes** are fixed once the FMU is instantiated: changing a structural + parameter in Configuration Mode does not resize arrays +- ⚠️ **FMU state** only covers registered variables, and serialization uses JSON (no NumPy + arrays). Override `_get_fmu_state`/`_set_fmu_state` if your model has other internal state +- ⚠️ **Prebuilt binaries** exist for Windows x86_64 and Linux x86_64 only; on macOS and ARM, + build the wrapper from source (see [Installation](install.md)) ## Examples and Documentation Explore comprehensive examples and documentation: -- **[Co-simulation Examples](usage.md)**: PID controllers, filters, state machines -- **[Model Exchange Examples](usageMX.md)**: Van der Pol oscillator, Robertson problem -- **[API Reference](api.md)**: Complete class and method documentation -- **[Installation Guide](install.md)**: Setup instructions for all platforms +- **[Co-simulation Examples](usage.md)**: PID controller, units, arrays, step results +- **[Model Exchange Examples](usageMX.md)**: Van der Pol oscillator, bouncing ball with events +- **[Installation Guide](install.md)**: installing, building from source, and running FMUs + outside Python +- **[Dependencies](dependencies.md)**: third-party packages and which Python an FMU uses +- **[Changelog](changelog.md)** ## Community and Support - 🐛 **Bug Reports**: [GitHub Issues](https://github.com/StephenSmith25/PythonFMU3/issues) -- 💡 **Feature Requests**: [GitHub Discussions](https://github.com/StephenSmith25/PythonFMU3/discussions) +- 💡 **Feature Requests**: [GitHub Issues](https://github.com/StephenSmith25/PythonFMU3/issues) - 📖 **Documentation**: [Read the Docs](https://pythonfmu3.readthedocs.io/) - 📦 **PyPI Package**: [pythonfmu3](https://pypi.org/project/pythonfmu3/) diff --git a/docs/install.md b/docs/install.md index b3f1981..e539a76 100644 --- a/docs/install.md +++ b/docs/install.md @@ -11,13 +11,19 @@ Alternatively, the project can installed from source. First clone the repo, git clone https://github.com/StephenSmith25/PythonFMU3.git ``` -Next build the C++ code, +Next build the C++ wrapper. You need CMake (3.10 or later), a C++17 compiler and the +Python development headers (e.g. `python3-dev` on Debian/Ubuntu). The library is written +to `pythonfmu3/resources/binaries//`. + ```bash -mkdir build -cd build -cmake -S ../pythonfmu3/pythonfmu-export/ -make +cd PythonFMU3 +cmake -S pythonfmu3/pythonfmu-export -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release ``` + +On Windows, add `-A x64` to the first command for a 64-bit build. Prebuilt libraries in the +PyPI package cover Windows x86_64 and Linux x86_64; on macOS (Intel or Apple Silicon) and +ARM Linux, install from source this way. Next the python project may be installed with either, ```bash diff --git a/docs/usage.md b/docs/usage.md index fb758c7..ea42ecc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,10 +17,15 @@ The typical Co-simulation FMU lifecycle follows this sequence: ### Required Methods For Co-simulation FMUs, you must implement: -- `__init__()`: Initialize variables and register them -- `do_step()`: Advance simulation by one time step +- `__init__()`: create the attributes, then register them as variables +- `do_step(current_time, step_size)`: advance the simulation by one step. Return `True` + (success), `False` (the step is discarded) or an `Fmi3StepResult` (see below) - Optional: `enter_initialization_mode()`, `exit_initialization_mode()`, `terminate()` +Create each attribute **before** registering it: `register_variable` only creates a +setter for attributes that already exist, and without a setter the importer cannot set +inputs or parameters. Alternatively pass `getter=` and `setter=` explicitly. + ## Key Implementation Details ### Variable Registration @@ -42,7 +47,7 @@ self.register_variable(Float64("gain", causality=Fmi3Causality.parameter, ### PID Controller Example ```python -from pythonfmu3 import Fmi3Causality, Fmi3Slave, Float64 +from pythonfmu3 import Fmi3Causality, Fmi3Slave, Fmi3Variability, Float64 class PIDController(Fmi3Slave): """PID Controller implementation""" @@ -56,14 +61,14 @@ class PIDController(Fmi3Slave): # Inputs self.setpoint = 0.0 self.measurement = 0.0 - + # Outputs - self.control_output = 0.0 + self.output = 0.0 # Parameters - self.kp = 1.0 # Proportional gain - self.ki = 0.1 # Integral gain - self.kd = 0.01 # Derivative gain + self.Kp = 1.0 # Proportional gain + self.Ki = 0.1 # Integral gain + self.Kd = 0.01 # Derivative gain # Internal states self.integral_error = 0.0 @@ -74,30 +79,30 @@ class PIDController(Fmi3Slave): self.register_variable(Float64("setpoint", causality=Fmi3Causality.input, start=0.0)) self.register_variable(Float64("measurement", causality=Fmi3Causality.input, start=0.0)) self.register_variable(Float64("output", causality=Fmi3Causality.output)) - self.register_variable(Float64("Kp", causality=Fmi3Causality.parameter, start=1.0)) - self.register_variable(Float64("Ki", causality=Fmi3Causality.parameter, start=0.1)) - self.register_variable(Float64("Kd", causality=Fmi3Causality.parameter, start=0.01)) + self.register_variable(Float64("Kp", causality=Fmi3Causality.parameter, variability=Fmi3Variability.tunable, start=1.0)) + self.register_variable(Float64("Ki", causality=Fmi3Causality.parameter, variability=Fmi3Variability.tunable, start=0.1)) + self.register_variable(Float64("Kd", causality=Fmi3Causality.parameter, variability=Fmi3Variability.tunable, start=0.01)) def do_step(self, current_time, step_size): # Calculate error error = self.setpoint - self.measurement # Proportional term - p_term = self.kp * error + p_term = self.Kp * error # Integral term self.integral_error += error * step_size - i_term = self.ki * self.integral_error + i_term = self.Ki * self.integral_error # Derivative term if self.first_step: d_term = 0.0 self.first_step = False else: - d_term = self.kd * (error - self.previous_error) / step_size + d_term = self.Kd * (error - self.previous_error) / step_size # PID output - self.control_output = p_term + i_term + d_term + self.output = p_term + i_term + d_term # Store error for next iteration self.previous_error = error @@ -108,18 +113,21 @@ class PIDController(Fmi3Slave): ### Units -Units can be added to this example through the `Unit` class along with `register_units` and the `unit` kwarg in `register_variable` +Units are declared with the `Unit` class (SI base-unit exponents as keyword arguments), +registered with `register_units`, and referenced by name through the `unit` argument of a +variable: ```python from pythonfmu3 import Unit + +# inside __init__ velocity_unit = Unit(name="m/s", m=1, s=-1) -``` -which can now be utilized in the register variables call, -``` -velocity_unit = Unit(name="m/s", m=1, s=-1) self.register_units([velocity_unit]) -self.register_variable(Float64("v", causality=Fmi3Causality.output, start=0, variability=Fmi3Variability.continuous, initial=Fmi3Initial.exact, unit=velocity_unit.name)) + +self.v = 0.0 +self.register_variable(Float64("v", causality=Fmi3Causality.output, start=0, variability=Fmi3Variability.continuous, + initial=Fmi3Initial.exact, unit=velocity_unit.name)) ``` @@ -164,26 +172,46 @@ class LinearTransform(Fmi3Slave): return True ``` -The `do_step` function can also return a tuple of flags, corresponding to those expected by the FMI standard. This can be achieved through use of the `FmiStepResult` tuple, +Array sizes are taken from the dimensions when the FMU is instantiated. Changing a +structural parameter such as `m` in Configuration Mode does not resize the arrays. + +### Step results + +Instead of `True`/`False`, `do_step` can return an `Fmi3StepResult` with the flags the FMI +standard defines for `fmi3DoStep`: +| Field | Default | Meaning | +| --- | --- | --- | +| `status` | `Fmi3Status.ok` | Status of the step (`ok`, `warning`, `discard`, `error`, `fatal`) | +| `terminateSimulation` | `False` | The model asks the importer to stop the simulation | +| `eventHandlingNeeded` | `False` | Passed to the importer as `eventHandlingNeeded` | +| `earlyReturn` | `False` | Passed to the importer as `earlyReturn` | ```python from pythonfmu3 import Fmi3Status, Fmi3StepResult def do_step(self, current_time, step_size): - self.y = 1 + 2 + 3 + 4 + ... - terminate = False - status = Fmi3Status.ok - return Fmi3StepResult(status=Fmi3Status.ok, terminateSimulation=terminate) + self.y = self.scalar * self.A.dot(self.u) + self.offset + finished = current_time + step_size >= 10.0 + return Fmi3StepResult(status=Fmi3Status.ok, terminateSimulation=finished) ``` +### Logging + +`self.log(msg, status=Fmi3Status.ok, category=None, debug=False)` sends a message to the +importer's logger. If `category` is omitted it is derived from the status +(`logStatusWarning`, `logStatusError`, ...); debug messages are only forwarded when the +importer enables debug logging. Exceptions raised in your code are reported with their +Python traceback and make the call fail with `fmi3Fatal`. + ### Create the FMU ```bash - pythonfmu3 build -f pythonslave.py myproject +pythonfmu3 build -f pythonslave.py myproject ``` -In this example a python class named `PythonSlave` that extends `Fmi3Slave` is declared in a file named `pythonslave.py`, +In this example a python class named `PythonSlave` that extends `Fmi3Slave` is declared in a file named `pythonslave.py` +(keep a single FMU class per script), where `myproject` is an optional folder containing additional project files required by the python script. Project folders such as this will be recursively copied into the FMU. Multiple project files/folders may be added. diff --git a/docs/usageMX.md b/docs/usageMX.md index f91f090..1c20e19 100644 --- a/docs/usageMX.md +++ b/docs/usageMX.md @@ -9,10 +9,20 @@ Model Exchange FMUs are used for continuous-time simulation where an external so - **Co-Simulation**: Your FMU manages its own time stepping with `do_step()` ### Required Methods -For Model Exchange FMUs, you must implement: -- `get_continuous_state_derivatives()`: Returns derivatives of continuous states -- `get_nominals_of_continuous_states()`: Returns nominal values for scaling -- Optional: `get_event_indicators()`, `update_discrete_states()` +Model Exchange FMUs inherit from `Fmi3SlaveBase` and the `ModelExchange` mixin (add +`CoSimulation` too for an FMU that supports both interfaces). You must: +- define `self.time` in `__init__` (the importer sets it through `fmi3SetTime`) +- implement `get_continuous_state_derivatives()`: return the derivatives of the + continuous states, in the order the derivative variables were registered + +Optional: +- `get_nominals_of_continuous_states(n)`: nominal values for scaling (default: all `1.0`) +- `get_event_indicators()`: one value per variable registered with `has_event_indicator=True` +- `update_discrete_states()`: return an `Fmi3UpdateDiscreteStatesResult` (see below) + +The continuous states are the variables referenced by each derivative's `derivative=` +value reference: the importer reads and writes them through `fmi3GetContinuousStates` and +`fmi3SetContinuousStates`. @@ -39,7 +49,10 @@ self.register_variable(Float64("x0", self.register_variable(Float64("derx0", causality=Fmi3Causality.local, variability=Fmi3Variability.continuous, - derivative=1)) # Derivative of variable 1 (x0) + derivative=1)) # value reference of x0 + +# Value references are assigned in registration order, starting at 0: +# here time = 0, x0 = 1, derx0 = 2 ``` @@ -112,7 +125,7 @@ pythonfmu3 build -f van_der_pol.py ## Usage Example -The generated FMU can be used with any FMI-compatible tool: +The generated FMU can be used with any FMI 3.0 Model Exchange importer: ```python @@ -152,19 +165,26 @@ self.register_variable(Float64("h", causality=Fmi3Causality.output, start=1, var Then define the event methods, `get_event_indicators()` and `update_discrete_states`. +An event is triggered when an indicator changes sign. ```python def get_event_indicators(self): - return [0.2] + return [self.h] # one value per registered event indicator def update_discrete_states(self): """Handle discrete state updates after events""" - fdsr = Fmi3UpdateDiscreteStatesResult() - fdsr.valuesOfContinuousStatesChanged = True + result = Fmi3UpdateDiscreteStatesResult() + result.valuesOfContinuousStatesChanged = True + return result ``` +`Fmi3UpdateDiscreteStatesResult` has the fields of `fmi3UpdateDiscreteStates`, all +defaulting to `False` (and `nextEventTime` to `0.0`): `discreteStatesNeedUpdate`, +`terminateSimulation`, `nominalContinuousStatesChanged`, `valuesOfContinuousStatesChanged`, +`nextEventTimeDefined` and `nextEventTime`. + ### Example diff --git a/pythonfmu3/modelexchange.py b/pythonfmu3/modelexchange.py index 7848b24..b0154dd 100644 --- a/pythonfmu3/modelexchange.py +++ b/pythonfmu3/modelexchange.py @@ -3,13 +3,28 @@ @dataclass class Fmi3UpdateDiscreteStatesResult: - discreteStateNeedsUpdate: bool = False + """Return value of `update_discrete_states`, mirroring the outputs of fmi3UpdateDiscreteStates.""" + discreteStatesNeedUpdate: bool = False terminateSimulation: bool = False - nominalsOfContinuousStatesChanged: bool = False + nominalContinuousStatesChanged: bool = False valuesOfContinuousStatesChanged: bool = False nextEventTimeDefined: bool = False nextEventTime: float = 0.0 + +def _alias(field_name: str) -> property: + return property( + lambda self: getattr(self, field_name), + lambda self, value: setattr(self, field_name, value), + doc=f"Deprecated alias of `{field_name}`.", + ) + + +# Earlier releases used these names, which the FMU wrapper never read (so the flags were +# silently ignored). Kept as aliases so existing models keep working - and now take effect. +Fmi3UpdateDiscreteStatesResult.discreteStateNeedsUpdate = _alias("discreteStatesNeedUpdate") +Fmi3UpdateDiscreteStatesResult.nominalsOfContinuousStatesChanged = _alias("nominalContinuousStatesChanged") + class RequireTimeMeta(ABCMeta): def __init__(cls, name, bases, namespace): super().__init__(name, bases, namespace) diff --git a/pythonfmu3/tests/test_docs.py b/pythonfmu3/tests/test_docs.py index 2c8907c..b12f311 100644 --- a/pythonfmu3/tests/test_docs.py +++ b/pythonfmu3/tests/test_docs.py @@ -3,6 +3,45 @@ import re import tempfile +from pythonfmu3 import Fmi3Causality, Fmi3SlaveBase, Fmi3Variability + + +def check_model(cls): + """Instantiate a documented model and check it would work as an FMU. + + Every variable must be readable, inputs and non-constant parameters must be + settable, and the model description must be generated without errors. + """ + instance = cls(instance_name="doc_test") + for var in instance.vars.values(): + var.getter() + settable = var.causality in (Fmi3Causality.input, Fmi3Causality.parameter, Fmi3Causality.structuralParameter) + if settable and var.variability != Fmi3Variability.constant: + assert var.setter is not None, f"{cls.__name__}.{var.name}: no setter (is the attribute created before register_variable?)" + instance.to_xml() + return instance + + +def models_in(namespace): + return [ + obj for obj in namespace.values() + if isinstance(obj, type) and issubclass(obj, Fmi3SlaveBase) and obj.__module__ == "__main__" + and obj.__name__ not in ("Fmi3Slave", "Fmi3SlaveBase") + ] + + +def run_blocks(md_file): + namespace = {"__name__": "__main__"} + exec("from pythonfmu3 import *", namespace) + for i, code in enumerate(extract_python_code_from_md(md_file)): + try: + exec(code, namespace) + except Exception as e: + pytest.fail(f"{pathlib.Path(md_file).name}: code block {i+1} failed: {e}\nCode:\n{code}") + for model in models_in(namespace): + check_model(model) + return namespace + def extract_python_code_from_md(md_file): """Extract Python code blocks from markdown, excluding skip blocks""" content = pathlib.Path(md_file).read_text() @@ -21,78 +60,48 @@ def extract_python_code_from_md(md_file): return python_blocks +ROOT = pathlib.Path(__file__).parent.parent.parent + + +def _doc(*parts): + path = ROOT.joinpath(*parts) + if not path.exists(): + pytest.skip(f"{path.name} is only available in a source checkout") + return path + + @pytest.mark.doc def test_readme(): - test_dir = pathlib.Path(__file__).parent - fpath = test_dir.parent.parent / "README.md" - - # extract Python code - code_blocks = extract_python_code_from_md(fpath) + namespace = run_blocks(_doc("README.md")) + assert "PythonSlave" in namespace and "PythonSlaveMX" in namespace - namespace = {} - exec("from pythonfmu3 import *", namespace) - - for i, code in enumerate(code_blocks): - try: - exec(code, namespace) - print(f"✅ Code block {i+1} executed successfully") - except Exception as e: - pytest.fail(f"Code block {i+1} failed: {e}\nCode:\n{code}") - +@pytest.mark.doc +def test_index(): + namespace = run_blocks(_doc("docs", "index.md")) + assert "MyModel" in namespace @pytest.mark.doc def test_cosimulation_examples(): """Test that all Python examples in usage.md actually work""" - test_dir = pathlib.Path(__file__).parent - usage_file = test_dir.parent.parent / "docs" / "usage.md" - - # Extract Python code - code_blocks = extract_python_code_from_md(usage_file) - - # Create a test namespace - namespace = {} - exec("from pythonfmu3 import *", namespace) - - # Execute each code block - for i, code in enumerate(code_blocks): - try: - exec(code, namespace) - print(f"✅ Code block {i+1} executed successfully") - except Exception as e: - pytest.fail(f"Code block {i+1} failed: {e}\nCode:\n{code}") - - assert 'LinearTransform' in namespace, "LinearTransform class not found" - assert 'PIDController' in namespace, "PIDController class not found" - - # Test instantiation - lt = namespace['LinearTransform'](instance_name="test") - pc = namespace['PIDController'](instance_name="test") + namespace = run_blocks(_doc("docs", "usage.md")) + assert "LinearTransform" in namespace, "LinearTransform class not found" + assert "PIDController" in namespace, "PIDController class not found" @pytest.mark.doc def test_model_exchange_examples(): """Test Model Exchange examples""" - test_dir = pathlib.Path(__file__).parent - mx_file = test_dir.parent.parent / "docs" / "usageMX.md" - - code_blocks = extract_python_code_from_md(mx_file) - - namespace = {} - exec("from pythonfmu3 import *", namespace) - - for i, code in enumerate(code_blocks): - try: - exec(code, namespace) - except Exception as e: - pytest.fail(f"MX Code block {i+1} failed: {e}") - - # Test that classes were actually created - assert 'VanDerPol' in namespace, "VanDerPol class not found" - assert 'BouncingBall' in namespace, "BouncingBall class not found" - - # Test instantiation - vdp = namespace['VanDerPol'](instance_name="test") - - bb = namespace['BouncingBall'](instance_name="test") \ No newline at end of file + namespace = run_blocks(_doc("docs", "usageMX.md")) + assert "VanDerPol" in namespace, "VanDerPol class not found" + assert "BouncingBall" in namespace, "BouncingBall class not found" + + +def test_update_discrete_states_result_aliases(): + from pythonfmu3 import Fmi3UpdateDiscreteStatesResult + + result = Fmi3UpdateDiscreteStatesResult() + result.discreteStateNeedsUpdate = True + result.nominalsOfContinuousStatesChanged = True + assert result.discreteStatesNeedUpdate and result.nominalContinuousStatesChanged