Skip to main content

Constructs a Functional Mockup Interface component model from a python script (fulfilling some requirements).

Project description

The package extends the PythonFMU package. It includes the necessary modules to construct a component model according to the fmi, OSP and DNV-RP-0513 standards with focus on the following features:

  • seamless translation of a Python model to an FMU package with minimal overhead (definition of FMU interface)

  • support of vector variables (numpy)

  • support of variable units and display units

  • support of range checking of variables

Features which facilitate Assurance of Simulation Models, DNV-RP-0513 shall have a special focus in this package.

Getting Started

A new model can consist of any python code. To turn the python code into an FMU the following is necessary

  1. The model code is wrapped into a Python class which inherits from Model

  2. The exposed interface variables (model parameters, input- and output connectors) are defined as Variable objects

  3. The (model).do_step( time, dt) function of the model class is extended with model internal code, i.e. model evolves from time to time+dt.

  4. Calling the method Model.build() will then compile the FMU and package it into a suitable FMU file.

See the files example_models/bouncing_ball.py and tests/test_make_bouncingBall.py supplied with this package as a simple example of this process. The first file defines the model class and the second file demonstrates the process of making the FMU and using it within fmpy and OSP.

  1. Install the component_model package: pip install component_model

  2. Software dependencies: PythonFMU, numpy, pint, uuid, ElementTree

  3. Latest releases: Version 0.1, based on PythonFMU 0.64

Usage example

This is another BouncingBall example, using 3D vectors and units.

from math import sqrt

import numpy as np

from component_model.model import Model
from component_model.variable import Variable


class BouncingBall3D(Model):
   """Another Python-based BouncingBall model, using PythonFMU to construct a FMU.

   Special features:

   * The ball has a 3-D vector as position and speed
   * As output variable the model estimates the next bouncing point
   * As input variables, the restitution coefficient `e`, the gravitational acceleration `g`
      and the initial speed can be changed.
   * Internal units are SI (m,s,rad)

   Args:
      pos (np.array)=(0,0,1): The 3-D position in of the ball at time [m]
      speed (np.array)=(1,0,0): The 3-D speed of the ball at time [m/s]
      g (float)=9.81: The gravitational acceleration [m/s^2]
      e (float)=0.9: The coefficient of restitution (dimensionless): |speed after| / |speed before| collision
      min_speed_z (float)=1e-6: The minimum speed in z-direction when bouncing stops [m/s]
   """

   def __init__(
      self,
      name: str = "BouncingBall3D",
      description="Another Python-based BouncingBall model, using Model and Variable to construct a FMU",
      pos: tuple = ("0 m", "0 m", "10 inch"),
      speed: tuple = ("1 m/s", "0 m/s", "0 m/s"),
      g: float = "9.81 m/s^2",
      e: float = 0.9,
      min_speed_z: float = 1e-6,
      **kwargs,
   ):
      super().__init__(name, description, author="DNV, SEACo project", **kwargs)
      self._pos = self._interface("pos", pos)
      self._speed = self._interface("speed", speed)
      self._g = self._interface("g", g)
      self.a = np.array((0, 0, -self.g), float)
      self._e = self._interface("e", e)
      self.min_speed_z = min_speed_z
      self.stopped = False
      self.time = 0.0
      self._p_bounce = self._interface("p_bounce", ("0m", "0m", "0m"))  # Note: 3D, but z always 0
      self.t_bounce, self.p_bounce = (-1.0, self.pos)  # provoke an update at simulation start

   def do_step(self, _, dt):
      """Perform a simulation step from `self.time` to `self.time + dt`.

      With respect to bouncing (self.t_bounce should be initialized to a negative value)
      .t_bounce <= .time: update .t_bounce
      .time < .t_bounce <= .time+dt: bouncing happens within time step
      .t_bounce > .time+dt: no bouncing. Just advance .pos and .speed
      """
      if not super().do_step(self.time, dt):
            return False
      if self.t_bounce < self.time:  # calculate first bounce
            self.t_bounce, self.p_bounce = self.next_bounce()
      while self.t_bounce <= self.time + dt:  # bounce happens within step or at border
            dt1 = self.t_bounce - self.time
            self.pos = self.p_bounce
            self.speed += self.a * dt1  # speed before bouncing
            self.speed[2] = -self.speed[2]  # speed after bouncing if e==1.0
            self.speed *= self.e  # speed reduction due to coefficient of restitution
            if self.speed[2] < self.min_speed_z:
               self.stopped = True
               self.a[2] = 0.0
               self.speed[2] = 0.0
               self.pos[2] = 0.0
            self.time += dt1  # jump to the exact bounce time
            dt -= dt1
            self.t_bounce, self.p_bounce = self.next_bounce()  # update to the next bounce
      if dt > 0:
            # print(f"pos={self.pos}, speed={self.speed}, a={self.a}, dt={dt}")
            self.pos += self.speed * dt + 0.5 * self.a * dt**2
            self.speed += self.a * dt
            self.time += dt
      if self.pos[2] < 0:
            self.pos[2] = 0
      return True

   def next_bounce(self):
      """Calculate time of next bounce and position where the ground will be hit,
      based on .time, .pos and .speed.
      """
      if self.stopped:  # stopped bouncing
            return (1e300, np.array((1e300, 1e300, 0), float))
            # return ( float('inf'), np.array( (float('inf'), float('inf'), 0), float))
      else:
            dt_bounce = (self.speed[2] + sqrt(self.speed[2] ** 2 + 2 * self.g * self.pos[2])) / self.g
            p_bounce = self.pos + self.speed * dt_bounce  # linear. not correct for z-direction!
            p_bounce[2] = 0
            return (self.time + dt_bounce, p_bounce)

   def setup_experiment(self, start: float):
      """Set initial (non-interface) variables."""
      super().setup_experiment(start)
      # print(f"SETUP_EXPERIMENT g={self.g}, e={self.e}")
      self.stopped = False
      self.time = start

   def exit_initialization_mode(self):
      """Initialize the model after initial variables are set."""
      super().exit_initialization_mode()
      self.a = np.array((0, 0, -self.g), float)

   def _interface(self, name: str, start: float | tuple):
      """Define a FMU2 interface variable, using the variable interface.

      Args:
            name (str): base name of the variable
            start (str|float|tuple): start value of the variable (optionally with units)

      Returns:
            the variable object. As a side effect the variable value is made available as self.<name>
      """
      if name == "pos":
            return Variable(
               self,
               name="pos",
               description="The 3D position of the ball [m] (height in inch as displayUnit example.",
               causality="output",
               variability="continuous",
               initial="exact",
               start=start,
               rng=((0, "100 m"), None, (0, "10 m")),
            )
      elif name == "speed":
            return Variable(
               self,
               name="speed",
               description="The 3D speed of the ball, i.e. d pos / dt [m/s]",
               causality="output",
               variability="continuous",
               initial="exact",
               start=start,
               rng=((0, "1 m/s"), None, ("-100 m/s", "100 m/s")),
            )
      elif name == "g":
            return Variable(
               self,
               name="g",
               description="The gravitational acceleration (absolute value).",
               causality="parameter",
               variability="fixed",
               start=start,
               rng=(),
            )
      elif name == "e":
            return Variable(
               self,
               name="e",
               description="The coefficient of restitution, i.e. |speed after| / |speed before| bounce.",
               causality="parameter",
               variability="fixed",
               start=start,
               rng=(),
            )
      elif name == "p_bounce":
            return Variable(
               self,
               name="p_bounce",
               description="The expected position of the next bounce as 3D vector",
               causality="output",
               variability="continuous",
               start=start,
               rng=(),
            )

The following might be noted:

  • The interface variables are defined in a separate local method _interface_variables, keeping it separate from the model code.

  • The do_step() method contains the essential code, describing how the ball moves through the air. It calls the super().do_step() method, which is essential to link it to Model. The return True statement is also essential for the working of the emerging FMU.

  • The next_bounce() method is a helper method.

  • In addition to the extension of do_step(), here also the setup_experiment() method is extended. Local (non-interface) variables can thus be initialized in a convenient way.

It should be self-evident that thorough testing of any model is necessary before translation to a FMU. The simulation orchestration engine (e.g. OSP) used to run FMUs obfuscates error messages, such that first stage assurance of a model should aways done using e.g. pytest.

The minimal code to make the FMU file package is

from component_model.model import Model
from fmpy.util import fmu_info

asBuilt = Model.build("../component_model/example_models/bouncing_ball.py")
info = fmu_info(asBuilt.name)  # not necessary, but it lists essential properties of the FMU

The model can then be run using fmpy

from fmpy import plot_result, simulate_fmu

result = simulate_fmu(
    "BouncingBall.fmu",
    stop_time=3.0,
    step_size=0.1,
    validate=True,
    solver="Euler",
    debug_logging=True,
    logger=print,
    start_values={"pos[2]": 2}, # optional start value settings
)
plot_result(result)

Similarly, the model can be run using OSP (or rather libcosimpy - OSP wrapped into Python):

from libcosimpy.CosimEnums import CosimExecutionState
from libcosimpy.CosimExecution import CosimExecution
from libcosimpy.CosimSlave import CosimLocalSlave

sim = CosimExecution.from_step_size(step_size=1e7)  # empty execution object with fixed time step in nanos
bb = CosimLocalSlave(fmu_path="./BouncingBall.fmu", instance_name="bb")

print("SLAVE", bb, sim.status())

ibb = sim.add_local_slave(bb)
assert ibb == 0, f"local slave number {ibb}"

reference_dict = {var_ref.name.decode(): var_ref.reference for var_ref in sim.slave_variables(ibb)}

# Set initial values
sim.real_initial_value(ibb, reference_dict["pos[2]"], 2.0)

sim_status = sim.status()
assert sim_status.current_time == 0
assert CosimExecutionState(sim_status.state) == CosimExecutionState.STOPPED
infos = sim.slave_infos()
print("INFOS", infos)

# Simulate for 1 second
sim.simulate_until(target_time=3e9)

This is admittedly more complex than the fmpy example, but it should be emphasised that fmpy is made for single component model simulation (testing), while OSP is made for multi-component systems.

Contribute

Anybody in the FMU and OSP community is welcome to contribute to this code, to make it better, and especially including other features from model assurance, as we firmly believe that trust in our models is needed if we want to base critical decisions on the support from these models.

Project details


Download files

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

Source Distribution

component_model-0.0.2b3.tar.gz (841.7 kB view details)

Uploaded Source

Built Distribution

component_model-0.0.2b3-py3-none-any.whl (42.0 kB view details)

Uploaded Python 3

File details

Details for the file component_model-0.0.2b3.tar.gz.

File metadata

  • Download URL: component_model-0.0.2b3.tar.gz
  • Upload date:
  • Size: 841.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/5.1.1 CPython/3.12.7

File hashes

Hashes for component_model-0.0.2b3.tar.gz
Algorithm Hash digest
SHA256 e0507f7efb072cc94469933a5557adf7b01acd400fb793dbacc6fce01f988957
MD5 e56a33fca00662106ae2f8bf984b8c85
BLAKE2b-256 c8e80273f3789568375fc1b313c765f4557bfa5a714e28faa04dfe2271b0846f

See more details on using hashes here.

Provenance

The following attestation bundles were made for component_model-0.0.2b3.tar.gz:

Publisher: publish_release.yml on dnv-opensource/component-model

Attestations:

File details

Details for the file component_model-0.0.2b3-py3-none-any.whl.

File metadata

File hashes

Hashes for component_model-0.0.2b3-py3-none-any.whl
Algorithm Hash digest
SHA256 d4aa0c620d582bb98d385821b0ea43ddf8c1d27d681031efb6b4f01bdcb9966a
MD5 f9c4b5601e2c504e47fb7f1348bff86b
BLAKE2b-256 9f771060245189eefbceb5d188e7c334b4c9783adac2d14631e6a3a7976a08f0

See more details on using hashes here.

Provenance

The following attestation bundles were made for component_model-0.0.2b3-py3-none-any.whl:

Publisher: publish_release.yml on dnv-opensource/component-model

Attestations:

Supported by

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