.. _component_implementation_guide:
=====================================
Guidelines on Implementing Components
=====================================
This document describes the guidelines on implementing components. It assumes
familiarity with the component structure used in SymBRiM :cite:`stienstra_2023_brim` and
the usage of SymPy mechanics and SymBRiM, as explained in the first two tutorials
:ref:`tutorials`.
SymBRiM Components
------------------
.. raw:: html
The above figure shows the UML diagram of the core components of SymBRiM. The three core
components all inherit from :class:`symbrim.core.base_classes.BrimBase`, as they share a
similar define structure. The three core components are:
- :class:`Models` - a system with its own
respective system boundaries, which can be made up of other models.
- :class:`Connections` - a utility of models
in describing the interaction between submodels.
- :class:`Load groups` - a collection of forces
and torques associated with a model or connection.
A large part of what is being shared between these components are the definition steps.
The definition steps are decoupled steps that are used to define the component. The
definition steps are:
0. **Define connections** (only in
:class:`models`): Associate the submodels with
the connections, such that the connections know the submodels they will operate on.
1. **Define objects**: Create the objects, such as symbols reference frames, without
defining any relationships between them.
2. **Define kinematics**: Establish relationships between the objects'
orientations/positions, velocities, and accelerations.
3. **Define loads**: Specifies the forces and torques acting upon the system.
4. **Define constraints**: Computes the holonomic and nonholonomic constraints to which
the system is subject.
The image below shows a schematic visualization of these steps for a rolling disc.
.. raw:: html
Usage of Base Classes
---------------------
SymBRiM uses base classes for components to specify a common structure of a component.
:class:`symbrim.bicycle.grounds.GroundBase` is an example of a base class. The advantage
of using base classes is that it allows for a common interface between components, which
makes it possible to swap out components without having to change the code. For example,
one can swap out the ground model for a different ground model without having to change
the code of the bicycle model.
In case of :class:`symbrim.bicycle.grounds.GroundBase` some of the commonly shared
properties are defined in the base class, such as a rigid body to represent the ground.
Apart from those it also prescribes several properties using :class:`abc.ABCMeta` and
:class:`abc.abstractmethod`. An example is the
:meth:`symbrim.bicycle.grounds.GroundBase.get_normal` method. These kind of abstract
methods have to be implemented by subclasses, such as
:class:`symbrim.bicycle.grounds.FlatGround`.
Setting Submodels and Connections
---------------------------------
To specify the submodels a model or connection requires, one should specify the class
property ``required_models``. This property should be a tuple of
:class:`symbrim.core.requirement.ModelRequirement`. Based on these requirements, the
metaclass automatically creates properties for each of the required submodels on
runtime. The following simple class shows how to specify the required submodels. ::
class MyModel(ModelBase):
"""My model."""
required_models: tuple[ModelRequirement, ...] = (
ModelRequirement("ground", GroundBase, "Submodel of the ground."),
ModelRequirement("other_submodel", OtherSubModel, "Other submodel."),
)
# These type hints are useful for some IDEs.
ground: GroundBase
other_submodel: OtherSubModel
The property created for ``"ground"`` will be like the following: ::
@property
def ground(self) -> GroundBase:
"""Submodel of the ground."""
return self._ground
@ground.setter
def ground(self, model: GroundBase) -> None:
"""Submodel of the ground."""
if not (model is None or isinstance(model, GroundBase)):
raise TypeError(
f"Ground should be an instance of an subclass of GroundBase, "
f"but {model!r} is an instance of {type(model)}."
)
self._ground = model
Connections should be specified similarly with the class property
``required_connections``, using
:class:`symbrim.core.requirement.ConnectionRequirement`. ::
class MyModel(ModelBase):
"""My model."""
required_connections: tuple[ConnectionRequirement, ...] = (
ConnectionRequirement("connection", MyConnection, "Connection."),
)
# These type hints are useful for some IDEs.
connection: MyConnection
Specify a Load Group Parent
---------------------------
Load groups are associated with a certain model or connection. To specify this
association, one should specify the class property ``required_parent_type``. This
property is utilized in a ``isinstance(parent, self.required_parent_type)`` check when
adding a load group to a component. An example is shown below. ::
class MyLoadGroup(LoadGroupBase):
"""My load group."""
required_parent_type: type[Union[ModelBase, ConnectionBase]] = MyModel
model = MyModel("my_model")
load_group = MyLoadGroup("my_load_group")
model.add_load_group(load_group)
assert load_group.parent is model
class MyModel2(ModelBase):
"""Some other model."""
model2 = MyModel2("my_model2")
load_group = MyLoadGroup("my_load_group")
model2.add_load_group(load_group) # Raises an error.
Implementation Define Steps
---------------------------
To implement the "define" steps in a model, connection, or load group, a leading
underscore is added to the method name. For example, ``_define_``. These methods
solely implement the "define" step for the component itself without traversing the
submodels and load groups. The base classes, like
:class:`symbrim.core.base_classes.BrimBase`, contain the implementation of the "define"
methods, including traversal, which should be called by the user. These methods follow
the format ``define_``.
We have established several helping guidelines for each of the define steps. The
subsections below discuss each of these per define step, and provide general coding
examples of the expected implementation.
Define Connections
~~~~~~~~~~~~~~~~~~
- If a connection is used, then the submodels of the connection are defined in the
``_define_connections`` method. ::
def _define_connections(self) -> None:
"""Define the connections between the submodels."""
super()._define_connections()
self.connection.submodel = self.submodel
Define Objects
~~~~~~~~~~~~~~
- Each model and connection must instantiate its own
:class:`sympy.physics.mechanics.system.System` instance and assign it to
``self._system``. Load groups automatically inherit the system from their parent.
- Symbols, such as masses and lengths, must be added to the ``self.symbols`` dictionary
with a string as key and the (dynamic)symbol as value.
- Generalized coordinates must be set/added to the mutable ``self.q`` matrix.
- Generalized speeds must be set/added to the mutable ``self.u`` matrix.
- Auxiliary speeds must be set/added to the mutable ``self.uaux`` matrix.
- The name of each symbol, generalized coordinate, and generalized speed must be created
using :meth:`symbrim.core.base_classes.BrimBase._add_prefix`. This method puts the
name of the component in front of the symbol name, such that the symbol name is
unique.
- Each symbol, generalized coordinate, and generalized speed must have a description in
the :meth:`symbrim.core.base_classes.BrimBase.descriptions` property.
- The define objects step for each connection should be called manually because there
could be dependencies between the define step of a connection and its parent model,
it is utility after all.
- Other component specific objects, such as bodies and reference frames, must be defined
in this stage, but they should not be oriented or positioned yet. ::
@property
def descriptions(self) -> dict[object, str]:
"""Descriptions of the attributes of the object."""
return {
**super().descriptions,
self.symbols["symbol_name"]: "Description of the symbol.",
self.symbols["f_noncontrib"]: "Description of the noncontributing force.",
self.q[0]: f"First generalized coordinate of {self.name}.",
self.q[1]: f"Second generalized coordinate of {self.name}.",
self.u[0]: f"First generalized speed of {self.name}.",
self.u[1]: f"Second generalized speed of {self.name}.",
self.uaux[0]: f"Auxiliary speed of {self.name}.",
}
def _define_objects(self) -> None:
"""Define the objects of the system."""
super()._define_objects()
# Create symbols and generalized coordinates and speeds.
self.symbols["symbol_name"] = symbols(self._add_prefix("symbol"))
self.symbols["f_noncontrib"] = symbols(self._add_prefix("f_noncontrib"))
self.q = MutableMatrix([dynamicsymbols(self._add_prefix("q1:3"))])
self.u = MutableMatrix([dynamicsymbols(self._add_prefix("u1:3"))])
self.uaux = MutableMatrix([dynamicsymbols(self._add_prefix("uaux"))])
# Instantiate system.
self._system = System()
# Call define objects of connections.
self.connection.define_objects() # Without leading underscore!
# Define other objects such as reference frames and bodies.
...
Define Kinematics
~~~~~~~~~~~~~~~~~
- It is generally best to first orient the reference frames in this step, and the
position of the points. Next, one can optimize the definition of the velocities. With
the introduction of the auxiliary data handler it is best practise to define the
velocity of a point based on the point w.r.t. which it has been positioned.
Parent models have to orient and define the submodels w.r.t. each other.
- The kinematical differential equations, generalized coordinates, and generalized
speeds must be added to ``self.system``.
- Possibly one can also use joints for the above.
- Again the define kinematics step of each connection should be called manually.
- Generally, make sure to define the velocity of at least one point in the model's or
connection's system.
- Noncontributing forces can be added to the auxiliary data handler. ::
def _define_kinematics(self) -> None:
"""Define the kinematics of the system."""
super()._define_kinematics()
# Orient frames.
self.frame.orient_axis(...)
# Position points and set their velocities.
self.point.set_pos(...)
self.point.set_vel(self.system.frame, ...)
# Add generalized coordinates, speeds, and kdes to the system.
self.system.add_coordinates(*self.q)
self.system.add_speed(*self.u)
self.system.add_kdes(*(self.q.diff() - self.u))
# Create and add joints.
self.system.add_joints(...)
# Call define kinematics of connections.
self.connection.define_kinematics() # Without leading underscore!
# Add noncontributing force to the auxiliary data handler.
self.auxiliary_data_handler.add_noncontributing_force(
self.point, self.frame.x, self.uaux[0], self.symbols["f_noncontrib"])
Define Loads
~~~~~~~~~~~~
- As all points and reference frames have already been defined and positioned, this step
only requires computation of the forces and torques and adding the to the
``self.system``.
- Noncontributing forces are fully handled by the auxiliary data handler.
- Again the define loads step of each connection should be called manually. ::
def _define_loads(self) -> None:
"""Define the loads of the system."""
super()._define_loads()
# Add forces, torques and actuators
self.system.add_loads(
Force(self.point, ...),
Torque(self.frame, ...),
...
)
self.system.add_actuators(...)
# Call define loads of connections.
self.connection.define_loads() # Without leading underscore!
Define Constraints
~~~~~~~~~~~~~~~~~~
- For holonomic constraints a loop is being closed most of the time using a dot product.
Just make sure to have some method to prevent the creation of constraint which are
already satisfied. Optionally, you can use
:class:`symbrim.utilities.utilities.check_zero`.
- For nonholonomic constraints the major difficulty is in the fact that one cannot
assume anything about the already defined velocities. Especially points are
susceptible to have multiple possible velocity definitions. Therefore, it is advised
to compute the velocity based on the position graph of the points and the orientation
and angular velocity graph of the reference frames. A good example of this is in the
:class:`symbrim.bicycle.tires.NonHolonomicTire` class.
- To support usage of the object in a system with noncontributing forces it is also
necessary to account for the auxiliary speeds. This can be done by specifically
requesting the auxiliary velocity of a point and adding that to the constraint. Do
also note that the ``velocity_constraints`` attribute is set to modify the velocity
constraint resulting from the holonomic constraint. ::
def _define_constraints(self) -> None:
"""Define the constraints of the system."""
super()._define_constraints()
self.system.add_holonomic_constraints(...)
self.system.add_nonholonomic_constraints(...)
# Overwrite the velocity constraints to include the auxiliary velocity.
self.system.velocity_constraints = [
self.system.holonomic_constraints[0].diff(dynamicsymbols._t) +
self.auxiliary_handler.get_auxiliary_velocity(self.point).dot(...),
*self.system.nonholonomic_constraints
]
# Call define constraints of connections.
self.connection.define_constraints() # Without leading underscore!
Auxiliary Data Handler
----------------------
The :class:`symbrim.core.auxiliary.AuxiliaryDataHandler` is a utility class that is used
to compute noncontributing forces and optimize the computation of the velocity of
points. An instance of the auxiliary data handler is automatically created at the end of
the ``define_objects`` step. This instance is shared by the root model, i.e. the
uppermost parent model, with all submodels, connections, and load groups. This makes the
auxiliary data handler accessible from all components through the
``self.auxiliary_handler`` attribute.
In the ``define_kinematics`` step modelers can register noncontributing forces that
should be computed using the
:meth:`symbrim.core.auxiliary.AuxiliaryDataHandler.add_noncontributing_force` method.
This method requires the point, the axis of the force, the auxiliary speed, and the
force symbol as arguments. From this information the auxiliary data handler can do the
rest. When defining the other kinematics it is best practise to define the velocity of a
point based on the point w.r.t. which it has been positioned. This is because the
auxiliary data handler propagates the auxiliary velocities of points to other points
based on how points are defined w.r.t. to each other.
At the end of the ``define_kinematics`` step the auxiliary data handler automatically
computes the velocity of each point in the inertial frame, while adding the auxiliary
velocity. The auxiliary speed is also automatically added to the root model's system
instance.
At the end of the ``define_loads`` step the noncontributing forces are automatically
added to the root model's system instance.
When computing the constraints in the ``define_constraints`` step it is important to
take the auxiliary speeds into account, even if you didn't define any in you component.
In many cases it is possible that other components may have defined auxiliary speeds
that do affect your constraints. To get the auxiliary velocity of a point of intereset
you can use the
:meth:`symbrim.core.auxiliary.AuxiliaryDataHandler.get_auxiliary_velocity` method.