| code | ||
| documentation | ||
| README.md | ||
Initial Understanding
This project is meant to provide a simple test bed for development and learning of an RMC controller. Specifically, the project is aimed for modeling hydraulic actuators interfacing with ropes and masses.
The project provides a physics simulation for general purpose, 'similar-to-life', physics scenarios so that equations of motion do not need to be derived for a given scenario. The project also incorporates the interfaces required for interacting with real-world devices. It utilizes the Measurement Computing Company (MCC) USB-202 for analog and digital input and the MCC USB-3114 for analog outputs.
To provide a visualization for the user, there is a 3D visualization engine built using VPython. In addition, there is a wrapper for PyQtGraph so that real-time data can be plotted for physics, sensors, HITL controller, or any combination.
Initial Setup
Required Software Installation
- Install
Python 3.11.4 through Company Portal. This ensure the global configuration is set correctly for installing packages. - Install the Measurement Computing Universal Library for Windows with code/setup/mccdaq.exe. DAQami does NOT need to be installed. Only
InstaCal & Universal Library. - Run the Python dependecy installer by executing
python code/setup/dependencies.py. This includes VPython, PyQtGraph, numpy, typing, and mcculw. vpython and PyQtGraph are significantly larger than the others. They are used for 3D visualization and plotting respectively. numpy is used for faster 'C-like' vectors. typing is for type hints in the code. mcculw is for interfacing with the MCC devices.
code/setup/icalsetup.exe is used for InstaCal installation. This is not required as Python performs the device configuration for MCC3114 and MCC202. InstaCal will create a configuration file in C:/ProgramData/Measurement Computing/DAQ called CB.CFG. This file is created when you configure the MCC devices. Note, 1 configuration file is made for the entire system of devices. It is not per device. The Python code in code/MCC_Inferface configures the devices as necessary upon initialization. Therefore, a manual configuration through InstaCal is not needed.
Setting Up a HITL Simulation
It is strongly recommended to go through this guide with an example up. Before writing the code to setup a HITL simulation, have a clear idea of what joints you will need along with the springs and actuators. Each object will need specific information about how to set it up such as a joint's position, springs characteristics, and an actuator's control behavior.
Physics Elements
Controller elements are located in code/Physics_Elements.py. There are 3 main physics elements. joints, springs, and rigid actuators. StateSpace is also included as a physics element.
StateSpace
StateSpace is where the Newtonian physics takes place. Each Joint is assigned a StateSpace at initialization. As the simulation runs, the StateSpace will be integrated over its acceleration and velocity to function as the physics engine.
The user will never need to create a StateSpace manually. It is always handled through the creation of a Joint. Understanding the basic aspects of the StateSpace can help understand behavior of the physics simulation. A simple integration method is shown. This is an abstraction of what StateSpace does.
class StateSpace():
def __init__(self, pos=np.copy(ZERO_VECTOR), vel=np.copy(ZERO_VECTOR), accel=np.copy(ZERO_VECTOR), force=np.copy(ZERO_VECTOR), integration_method="simple"):
self.pos = pos
self.vel = vel
self.accel = accel
self.force = force
def integrate(self, delta_t: float):
self.vel = self.vel + self.accel * delta_t
self.pos = self.pos + self.vel * delta_t
By default a StateSpace is initialized with all zeroes. It is typical to assign a position at Joint creation which is passed to the StateSpace. integration_method is passed from the creation of a Joint to specify the type of integration to use. This allows for a mix of speed and accuracy.
Joint
A Joint is a single point in space with mass. It has its own StateSpace. Joints can either be fixed in space or free (they can also be driven by an actuator which is a special case handled during creation of an actuator).
Here is the code to create a Joint
joint = Joint(
pos: array_like, # The initial position of the joint. typically use np.array([x, y, z])
mass: float, # The mass in kg of the joint. Disregarded if fixed == True
fixed: bool, # If true, the Joint will never move.
name: str,
integration_method: str # "simple" (default), "verlet", "adams-bashforth"
)
Now that the joint is created, it can be connected to a spring, actuator, or nothing if it is fixed.
Joint Extra Attributes
The Joint class has a few extra functions that are useful for certain use.
joint.add_gravity() # Add gravity at 9.81 m/s^2
joint.add_damping( # Add either velocity or momentum damping. If mom_ratio is specified, disregard vel_ratio
vel_ratio: float, # Add a force equal to the negative velocity in m/s scaled by vel_ratio
mom_ratio: float # Add a force equal to the negative momentum in kg*m/s scaled by mom_ratio
)
joint.add_constant_force( # Add a constant force to be applied to the joint
new_force: array_like # Typically use np.array([x, y, z])
)
joint.add_variable_force( # Add a time varying force in the form of a function pointer (NOT lambda function) to be applied to the joint
force_vect: array_like # Typically use np.array([f1, f2, f3]) where f = func1(t:float)
)
Spring
A Spring connects 2 Joints and applies a force on them. Springs use the StateSpace of the parent Joint and child Joint attached to them. They calculate their \vec r = \vec x_{child} - \vec x_{parent} and \Delta l to get their force vector. Tension is treated as positive in all cases.
Here is the code to create a Spring
spring = Spring(
parent_joint: Spring,
child_joint: Spring,
unstretched_length: float, # Zero force length
contant_stiffness: float, # Constant k value. Ignored if stiffness_func != None
stiffness_func: func(length: float, unstretched_length) -> float, # Function pointer for variable stiffness
name: str
)
Now that the Spring is created, it will be simulated in the physics simulation. The physics simulation knows it exists because each Joint keeps track of the springs attached to it.
The Spring class has no other attributes for the user to work with.
RigidActuator
A RigidActuator connects 2 Joints, a "grounded" joint and a "parent" joint. The grounded Joint is fixed and will not move. The parent joint is controlled in relation to the grounded joint. All RigidActuators must be controlled by something. They will either control the length of the actuator or the velocity. The RigidActuator will only move in the direction of the \vec r = \vec x_{grounded} - \vec x_{parent}.
Here is the code to create a RigidActuator
rigid_actuator = RigidActuator(
parent_joint: Joint,
grounded_joint: Joint,
name: str,
max_length: float,
min_length: float,
control_code: int # 0 for displacement control, 1 for velocity control, 2 for acceleration control
)
Now that the RigidActuator is created, it will be simulated in the physics simulation. The physics simulation knows it exists because each Joint keeps track of the actuators attached to it.
The RigidActuator class has no other attributes for the user to work with.
Sensor Elements
Sensor elements are located in code/Sensor_Elements.py. Sensor elements are how you take something from the physics simulation and bring it to the real-world with voltage outputs.
Because the physics simulation and HITL controller processes are separate, you must create a shared object to link them. These shared objects are SharedJoint and SharedSpring. They will need to be attached to both the raw physics element (Joint, Spring) and the sensor element (LoadCellJoint, LoadCellSpring, DisplacementSensor).
LoadCell
Parent class to LoadCellJoint and LoadCellSpring
LoadCellJoint
A load cell can be attached to a Joint with LoadCellJoint. This is less developed than LoadCellSpring as the force on a point in space is less representative than a force between joints. This is due to how it represents the force. It takes the net force applied by springs in any direction on the joint and vector sums them.
Create and attach a LoadCellJoint with
load_cell_joint = LoadCellJoint(sensor_settings: dict = {
"name": str,
"mV/V": float,
"excitation": float,
"full_scale_force": float, # What is the max force on the load cell
"output_channel": int # The output channel that the load cell outputs through the MCC3114. Must be between [0, 15]
})
load_cell_joint.attach_sim_source(shared_joint)
hitl_controller.attach_load_cell(load_cell_joint)
To allow the LoadCellJoint to get data from the physics engine, attach an instance of SharedJoint named 'shared_joint' through load_cell_joint.attach_sim_source(shared_joint).
Now, the attach the LoadCellJoint to the HITL controller through hitl_controller.attach_load_cell(load_cell_joint). Now, the controller will actually know there is a sensor that needs to be output.
LoadCellSpring
A load cell can also be attached to a Spring with LoadCellSpring. This is much more representative of a load cell in the real world as it only outputs a force along a vector (defined as the spring vector \vec r).
Create and attach a LoadCellSpring with
load_cell_Spring = LoadCellSpring(sensor_settings: dict = {
"name": str,
"mV/V": float,
"excitation": float,
"full_scale_force": float, # What is the max force on the load cell
"output_channel": int # The output channel that the load cell outputs through the MCC3114. Must be between [0, 15]
})
load_cell_spring.attach_sim_source(shared_spring)
hitl_controller.attach_load_cell(load_cell_spring)
To allow the LoadCellSpring to get data from the physics engine, attach an instance of SharedSpring named 'shared_spring' through load_cell_spring.attach_sim_source(shared_spring).
Now, the attach the LoadCellSpring to the HITL controller through hitl_controller.attach_load_cell(load_cell_spring). Now, the controller will actually know there is a sensor that needs to be output.
DisplacementSensor
A position/displacement sensor can be attached to any two joints with DisplacementSensor. This allows for a scalar length between the joints to be output through the MCC3114.
Create and attach a DisplacementSensor with
displacement_sensor = DisplacementSensor(
parent_joint: SharedJoint, # Must be a SharedJoint for the sensor to get values from the sim
child_joint: SharedJoint, # Must be a SharedJoint for the sensor to get values from the sim
sensor_settings: dict = {
"name": str,
"neutral_length": float, # This is the 0 voltage value. Can be 0 for purely positive voltage output or non-zero to allow negative voltage output
"volts_per_meter": float, # How many volts per meter of length should the MCC3114 output
"output_channel": int # The output channel that the load cell outputs through the MCC3114. Must be between [0, 15]
}
)
hitl_controller.attach_displacement_sensor(displacement_sensor)
The DisplacementSensor gets data from the SharedJoints that are specified as a parent_joint and child_joint. The axis vector of the displacement sensor is given by \vec r = \vec x_{child} - \vec x_{parent}. For a scalar sensor, this plays no importance. Length is the vector length of \vec r.
Now, the attach the DisplacementSensor to the HITL controller through hitl_controller.attach_displacement_sensor(displacement_sensor). Now, the controller will actually know there is a sensor that needs to be output.
Controller Elements
Controller elements are located in code/Controller_Input_Elements.py. Controller elements are how a real-world controller can control something inside of the physics simulation.
Because the physics simulation and HITL controller processes are separate, you must create a shared object to link them. The only shared objects for a controller element is SharedRigidActuator which links the controller to the physics simulation so it can control the actuator.
In reference to the data sharing paths in the process diagram, the hitl controller pushes the commanded attribute (velocity or position) to the SharedRigidActuator and the physics sim pulls the commanded attribute.
With the MCC202, the range is [-10, +10] V. Any exceeding past this range can cause damage to the device. Digital inputs operate on [0, 5] V TTL and should not experience voltage below 0 V or above 5.5 V to avoid damage.
RigidActuatorController
A RigidActuator in the physics simulation is useless without a RigidActuatorController to move it. As a reminder, a RigidActuator connects 2 joints: the grounded and the parent joint. The grounded joint will never move. The parent joint can be controlled along the axis of the actuator. The user can specify whether the controller commands the position or the velocity of the parent joint with respect to the grounded joint.
Create and attach a RigidActuatorController with
rigid_actuator_controller = RigidActuatorController(controller_settings: dict = {
"name": str,
"analog_input_channel": int, # The analog channel on the MCC202 that controls the rigid actuator. Must be between [0, 7]
"digital_input_channel": int, # The digital channel on the MCC202 that enables control of the rigid actuator. Must be between [0, 7]
"units_per_volt": float, # m/V or m/s/V. How far or how fast the actuator should move per volt
"neutral_length": float, # Length to go to when given neutral voltage (position control). Also used for starting
"neutral_voltage": float, # The voltage at which to go to neutral_length (position control) or zero velocity (velocity control)
"controls_pos/vel/accel": int, # 0 = controls pos, 1 = controls vel, 2 = controls accel
"max_length": float,
"min_lenght": float
})
rigid_actuator_controller.attach_sim_target(shared_rigid_actuator)
hitl_controller.attach_rigid_actuator_controller(rigid_actuator_controller)
To allow the RigidActuatorController to command the physics engine to respond to real-life inputs, attach an instance of SharedRigidActuator named 'shared_rigid_actuator' with rigid_actuator_controller.attach_sim_target(shared_rigid_actuator).
Now, the attach the RigidActuatorController to the HITL controller through hitl_controller.attach_rigid_actuator_controller(rigid_actuator_controller). Now, the controller will actually know there is a rigid actuator that it needs to get inputs for and command.
Sharing Elements
Sharing elements refers to the data flow of object between processes using pipes. This is largely hidden from the user as multiprocessing.managers.BaseManager does this for us. It creates AutoProxy elements of each element we "share" and when we "attach" them to an instance of a class, they are given access to the AutoProxy object. The processes that have access to the AutoProxy objects can interact with them in 1 fashion: function calls. Direct access to internal attributes is not possible without a copy of the object. So, there are numerous getters and setters for each "shared" object for data retrieval and setting.
For the most part, sharing elements is for allowing sensor and controller elements to communicate with the elements in the physics simulation. A LoadCell needs a SharedJoint or a SharedSpring, a DisplacementSensor needs 2 SharedJoints, a RigidActuatorController needs a SharedRigidActuator. A sensor element will be connected by attaching a shared element to the sensor through "attach_sim_source". A controller element will be connected by attaching a shared element to the controller through "attach_sim_target".
Any element can also be plotted and some can be 3D visualized. Those elements will also need a shared element. This is usually taken care of by attaching the shared element to the base element through "attach_shared_attributes".
BaseManager
The BaseManager is how elements can be shared amongst processes.
Here is the code to make an instance of the BaseManager and register classes to be shared
from multiprocessing.managers import BaseManager
base_manager = BaseManager()
BaseManager.register('SharedFloat', SharedFloat)
BaseManager.register('SharedJoint', SharedJoint)
BaseManager.register('SharedSpring', SharedSpring)
BaseManager.register('SharedRigidActuator', SharedRigidActuator)
BaseManager.register('SharedLoadCellJoint', SharedLoadCellJoint)
BaseManager.register('SharedLoadCellSpring', SharedLoadCellSpring)
BaseManager.register('SharedDisplacementSensor', SharedDisplacementSensor)
BaseManager.register('SharedRigidActuatorController', SharedRigidActuatorController)
base_manager.start()
After the instance is created, each class that needs to be shared needs to be registered. When registering classes, the objects must be 'picklable'. I have never run into an instance where I could not share an object except for when the object contained a lambda function.
Each of the shared elements is setup to have strong similarity with each other and the thing they are representing. They are also supposed to do as little calculation as possible. In a best scenario, they only have getters and setters.
SharedJoint
A SharedJoint is needed to share the attributes of a Joint if it is connected to one of the following:
Here is the code to make and attach a SharedJoint to a Joint
base_manager = BaseManager()
BaseManager.register('SharedJoint', SharedJoint) # Register SharedJoint under the name 'SharedJoint'
base_manager.start()
...
joint = Joint(...) # Previously defined Joint
shared_joint: SharedJoint = base_manager.SharedJoint()
joint.attach_shared_attributes(shared_joint) # Point the Joint to push physics values to the SharedJoint
...Attach shared_joint to 3D visualization, plotter
SharedSpring
A SharedSpring is needed to share the attributes of a Spring if it is connected to one of the following:
Here is the code to make and attach a SharedSpring to a Spring
base_manager = BaseManager()
BaseManager.register('SharedSpring', SharedSpring) # Register SharedSpring under the name 'SharedSpring'
base_manager.start()
...
spring = Spring(...) # Previously defined Spring
shared_spring: SharedSpring = base_manager.SharedSpring()
spring.attach_shared_attributes(shared_spring) # Point the Spring to push physics values to the SharedSpring
...Attach shared_spring to 3D visualization, plotter
SharedRigidActuator
A SharedRigidActuator is needed to share the attributes of a RigidActuator if it is connected to one of the following:
This means that a SharedRigidActuator is required for the normal use of a RigidActuator so that the HITLController can push values to the physics simulation.
Here is the code to make and attach a SharedRigidActuator to a RigidActuator
base_manager = BaseManager()
BaseManager.register('SharedRigidActuator', SharedRigidActuator) # Register SharedRigidActuator under the name 'SharedRigidActuator'
base_manager.start()
hitl_controller = HITLController(...) # Previously defined HITLController
...
rigid_actuator = RigidActuator(...) # Previously defined RigidActuator
shared_rigid_actuator: SharedRigidActuator = base_manager.SharedRigidActuator()
rigid_actuator.attach_shared_attributes(shared_rigid_actuator) # Point the RigidActuator to pull conrolled values from the SharedRigidActuator
rigid_actuator_controller = RigidActuatorController(...) # Defined RigidActuatorController
rigid_actuator_controller.attach_sim_target(shared_rigid_actuator) # Point the controller to the SharedRigidActuator
hitl_controller.attach_rigid_actuator_controller(rigid_actuator_controller) # Attach a RigidActuatorController to the HITLController
...Attach shared_rigid_actuator to plotter
SharedLoadCellJoint
A SharedLoadCellJoint is needed to share the attributes of a LoadCellJoint if it is connected to one of the following:
Here is the code to make and attach a SharedLoadCellJoint to a LoadCellJoint
base_manager = BaseManager()
BaseManager.register('SharedLoadCellJoint', SharedLoadCellJoint) # Register SharedLoadCellJoint under the name 'SharedLoadCellJoint'
base_manager.start()
hitl_controller = HITLController(...) # Previously defined HITLController
...
joint = Joint(...) # Previously defined Joint
shared_joint: SharedJoint = base_manager.SharedJoint()
load_cell_joint = LoadCellJoint(...) # Previously defined LoadCellJoint
load_cell_joint.attach_sim_source(shared_joint) # Point the LoadCellJoint to pull physics values from the SharedJoint
shared_load_cell_joint: SharedLoadCellJoint = base_manager.SharedLoadCellJoint()
load_cell_joint.attach_shared_attributes(shared_load_cell_joint) # Point the LoadCellJoint to push values to the SharedLoadCellJoint
hitl_controller.attach_load_cell(load_cell_joint) # Attach a LoadCellJoint to the HITLController
...Attach shared_load_cell_joint to plotter
SharedLoadCellSpring
A SharedLoadCellSpring is needed to share the attributes of a LoadCellSpring if it is connected to one of the following:
Here is the code to make and attach a SharedLoadCellSpring to a LoadCellSpring
base_manager = BaseManager()
BaseManager.register('SharedLoadCellSpring', SharedLoadCellSpring) # Register SharedLoadCellSpring under the name 'SharedLoadCellSpring'
base_manager.start()
hitl_controller = HITLController(...) # Previously defined HITLController
...
spring = Spring(...) # Previously defined Spring
shared_spring: SharedJoint = base_manager.SharedSpring()
load_cell_spring = LoadCellSpring(...) # Previously defined LoadCellSpring
load_cell_spring.attach_sim_source(shared_spring) # Point the LoadCellSpring to pull physics values from the SharedSpring
shared_load_cell_spring: SharedLoadCellSpring = base_manager.SharedLoadCellSpring()
load_cell_spring.attach_shared_attributes(shared_load_cell_spring) # Point the LoadCellSpring to push values to the SharedLoadCellSpring
hitl_controller.attach_load_cell(load_cell_spring) # Attach a LoadCellSpring to the HITLController
...Attach shared_load_cell_spring to plotter
SharedDisplacementSensor
A SharedDisplacementSensor is needed to share the attributes of a DisplacementSensor if it is connected to one of the following:
Here is the code to make and attach a SharedDisplacementSensor to a DisplacementSensor
base_manager = BaseManager()
BaseManager.register('SharedDisplacementSensor', SharedDisplacementSensor)
base_manager.start()
...
displacement_sensor = DisplacementSensor(...) # Previously defined DisplacementSensor
shared_displacement_sensor: SharedDisplacementSensor = base_manager.SharedDisplacementSensor()
displacement_sensor.attach_shared_attributes(shared_displacement_sensor) # Point the DisplacementSensor to push values to the SharedDisplacementSensor
...Attach shared_displacement_sensor to plotter
SharedRigidActuatorController
A SharedRigidActuatorController is needed to share the attributes of a RigidActuatorController if it is connected to one of the following:
Here is the code to make and attach a SharedRigidActuatorController to a RigidActuatorController
base_manager = BaseManager()
BaseManager.register('SharedRigidActuatorController', SharedRigidActuatorController)
base_manager.start()
...
rigid_actuator_controller = RigidActuatorController(...)
shared_rigid_actuator_controller: SharedRigidActuatorController = base_manager.SharedRigidActuatorController()
rigid_actuator_controller.attach_shared_attributes(shared_rigid_actuator_controller)
...Attach shared_rigid_actuator_controller to plotter
BFSSimulation (Breadth First Search Simulation)
The BFSSimulation class does the physics engine work. Named 'BFS' due to how it searches for all physics elements by starting at a parent Joint and traversing in a BFS style to gather all children.
The BFSSimulation class is located in code/Simulation.py.
The physics engine does not need anything attached to it before the process start. When the BFSSimulation starts, it initializes itself by getting all joints connected to it and sorting them in ways for it perform the simulation well.
simulation = BFSSimulation(
parent_joint: Joint, # Main joint to start BFS on and get everything connected to the physics simulation
settings: dict = {
"duration": float # The total duration in seconds to run the simulation
"delta_t": float or None, # None for running in real-time. Specified in seconds to have a constant timestep
"plotting_update_period": float, # Period in seconds to update shared elements that are not connected to a sensor or controller
"sensor_update_period": float, # Period in seconds to update shared elements connected to sensors
"controller_pull_period": float # Period in seconds to pull values from shared elements for controlled elements (actuators)
}
)
shared_sim_time: SharedFloat = base_manager.SharedFloat()
simulation.attach_shared_time(shared_sim_time)
simulation_process = multiprocessing.Process(target=simulation.run_process)
time.sleep(5) # Used so that the plotter, visualization, and controller processes can spool up
simulation_process.start()
# The simulation runs until it has done its full duration
simulation_process.join()
stop_event.set() # Used to tell the other processes there day has come
The physics simulation is fairly easy to setup and start. As long as the physics elements are setup in a way that each object is reachable, it will handle itself. This does mean that there should be a clear chain from parent->child->child->child.
HITLController (Hardware-in-the-Loop Controller)
The HITLController class handles the sensor output through the MCC3114 and controller input from MCC202. With these two devices, there are 16 analog [-10, +10] V inputs on the MCC3114, 8 analog [-10, +10] V inputs on the MCC202, and 8 digital 5 V TTL inputs on the MCC202.
The HITLController class is located in code/HITL_Controller.py.
Here is the code to make a HITLController instance
stop_event = multiprocessing.Event()
hitl_controller = HITLController(
stop_event = multiprocessing.Event, # Provides a mean for the main process to stop the controller when the simulation is done
settings: dict = {
"pull_from_sim_period": float, # Period in milliseconds to pull values from the physics sim for sensor elements
"plotting_update_period": float # Period in milliseconds to update shared elements that are being plotted
}
)
...Make controller elements
hitl_controller.attach_load_cell(load_cell) # Attach either type of load cell
hitl_controller.attach_displacement_sensor(displacement_sensor)
hitl_controller.attach_rigid_actuator_controller(actuator_controller)
...
hitl_controller_process = multiprocessing.Process(target=hitl_controller.run_process)
hitl_controller_process.start()
...Setting other processes and running the physics simulation
stop_event.set()
hitl_controller_process.join()
Adding a LoadCellJoint or LoadCellSpring to a HITLController
Since the LoadCellJoint and LoadCellSpring share the same parent class, LoadCell, they are attached the same way.
hitl_controller = HITLController(...) # Previously defined HITLController
...
load_cell = LoadCell(...) # LoadCellJoint or LoadCellSpring()
hitl_controller.attach_load_cell(load_cell)
Adding a DisplacementSensor to a HITLController
hitl_controller = HITLController(...) # Previously defined HITLController
...
displacement_sensor = DisplacementSensor(...)
hitl_controller.attach_displacement_sensor(displacement_sensor)
Adding a RigidActuatorController to a HITLController
hitl_controller = HITLController(...) # Previously defined HITLController
...
rigid_actuator_controller = RigidActuatorController(...)
hitl_controller.attach_rigid_actuator_controller(rigid_actuator_controller)
Visualization
The Visualization class provides a wrapper of VPython such that it is easy for the user to quickly physics elements to be visualized. It handles only a few types of objects including joints as spheres, springs as helizes, and rigid actuators as cylinders.
The Visualization class is located in code/Visualization.py.
Here is the code to make a Visualization instance
stop_event = multiprocessing.Event()
stop_event.clear()
visualizer = Visualization(
stop_event=stop_event,
scene_settings: dict = {
"canvas_width": int, # Desired width of the screen in the browser
"canvas_height": int, # Desired height of the screen in the browser
"wall_thickness": float, # Thickness of the walls
"cube_size": float # Size of the cube surrounding the origin
}
)
...Add elements to be visualized
visualization_process = multiprocessing.Process(target=visualizer.run_process)
visualization_process.start()
...Setting other processes and running the physics simulation
stop_event.set()
visualization_process.join()
Adding a Joint to a Visualization
Joints are spheres in the 3D visualization.
visualizer = Visualization(...) # Previously defined Visualization
...
joint = Joint(...) # Previously defined Joint
shared_joint: SharedJoint = base_manager.SharedJoint() # Required to be visualized
joint.attach_shared_attributes(shared_joint)
visualizer.attach_shared_attributes(
shared_attributes: shared_joint,
object_settings: dict = {
"type": "sphere",
"color": str, # "blue", "black", "red", "green", "white"
"radius": float
}
)
Adding a Spring to a Visualization
Springs are helixes in the 3D visualization.
visualizer = Visualization(...) # Previously defined Visualization
...
spring = Spring(...) # Previously defined Spring
shared_spring: SharedSpring = base_manager.SharedSpring() # Required to be visualized
spring.attach_shared_attributes(shared_spring)
visualizer.attach_shared_attributes(
shared_attributes: shared_spring,
object_settings: dict = {
"type": "helix",
"color": str, # "blue", "black", "red", "green", "white"
"radius": float, # Radius the wire is wrapped in a coil
"thickness": float # Similar to the gauge of the wire that is coiled
}
)
Adding a RigidActuator to a Visualization
RigidActuators are cylinders in the 3D visualization.
visualizer = Visualization(...) # Previously defined Visualization
...
rigid_actuator = RigidActuator(...) # Previously defined RigidActuator
shared_rigid_actuator: SharedRigidActuator = base_manager.SharedRigidActuator()
rigid_actuator.attach_shared_attributes(shared_rigid_actuator)
visualizer.attach_shared_attributes(
shared_attributes: shared_rigid_actuator,
object_settings: dict = {
"type": "cylinder",
"color": str, # "blue", "black", "red", "green", "white"
"radius": float
}
)
Plotter
The Plotter class provides a wrapper of PyQtGraph such that it is easy for the user to quickly add any physics, sensor, or controller element to be plotted in real-time. It is typical to spawn multiple plotter processes for physics, sensor, and controller plotting to be separated. While this is not necessary, it allows the user to control the windows and be more flexible.
The Plotter class is located in code/Visualization.py.
Here is the code to make a Plotter instance
stop_event = multiprocessing.Event()
plotter = Plotter(
stop_event: multiprocessing.Event, # Provides a mean for the main process to stop the plotter when the simulation is done
plot_settings: dict = {
"title": str,
"xlabel": str,
"num_subplots": int, # Number of subplots to include in the window
"step_or_plot": str, # 'plot' for linear interpolation between points, 'step' for a step function between points
"subplots": list [ # A list of dicts for each subplot starting at index = 0 up to num_subplots - 1
{
"ylabel": str
},
... for each subplot
],
"window_length": float, # The total time window in seconds of data points to store at any given time
"pull_interval": float, # The number of milliseconds between pulling for datapoints
"update_interval": int, # The number of milliseconds between updating the graph
}
)
plotter.attach_shared_x(shared_float)
...Adding elements to plot
plotter_process = multiprocessing.Process(target=plotter.run_process)
plotter_process.start()
...Setting other processes and running the physics simulation
stop_event.set()
plotter_process.join()
This creates a plotter object to add elements to plot. After adding all elements to plot, you can start the process. When the physics simulation process joins, you can set the stop_event and join the plotter_process. Note that for PyQtGraph, you must close out the plotter windows for the process to end. This is favorable so the user as the ability to export values and keep it up if desired.
Note that a step plot has limited functionality such as lack of FF, dy/dx, and Y vs Y'.
For each element you will see that there is a plot_settings dictionary when attaching to the plotter. This dictionary follows the same general structure for each element with the following rules:
- Everything except the ylabel must be in lowercase.
- The ylabel must be unique between everything added to the same plotter.
- The subplot integer must correspond to a subplot in range [0, num_subplots-1].
For general information on PyQtGraph, go to their website.
Adding a Joint to a Plotter
Adding a Joint to a plotter requires a SharedJoint.
plotter = Plotter(...)
...
joint = Joint(...)
shared_joint: SharedJoint = base_manager.SharedJoint()
joint.attach_shared_attributes(shared_joint)
plotter.attach_shared_attributes(
shared_attributes = shared_joint,
plot_settings: dict = {
"pos": {
"x": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"y": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"z": {...},
"scalar": {...}
},
"vel": {...},
"accel": {...}
}
)
A Joint can have its position, velocity, and/or its acceleration plotted. Each of these can be specified between x, y, z, and scalar components. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
Adding a Spring to a Plotter
Adding a Spring to a plotter requires a SharedSpring.
plotter = Plotter(...)
...
spring = Spring(...)
shared_spring: SharedSpring = base_manager.SharedSpring()
spring.attach_shared_attributes(shared_spring)
plotter.attach_shared_attributes(
shared_attributes = shared_spring,
plot_settings: dict = {
"force": {
"x": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"y": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"z": {...},
"scalar": {...}
},
"length": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
}
}
)
A Spring can have its force plotted in x, y, z, and scalar components. It can also have its scalar length plotted. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
Adding a RigidActuator to a Plotter
Adding a RigidActuator to a plotter requires a SharedRigidActuator.
plotter = Plotter(...)
...
rigid_actuator = RigidActuator(...)
shared_rigid_actuator: SharedRigidActuator = base_manager.SharedRigidActuator()
rigid_actuator.attach_shared_attributes(shared_rigid_actuator)
plotter.attach_shared_attributes(
shared_attributes = shared_rigid_actuator,
plot_settings: dict = {
"pos": {
"x": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"y": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"z": {...},
"scalar": {...}
},
"vel": {...},
"length": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
}
}
)
A RigidActuator can have its position and/or velocity plotted in x, y, z, and scalar components. It can also have its scalar length plotted. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
Adding a LoadCellJoint to a Plotter
Adding a LoadCellJoint to a plotter requires a SharedLoadCellJoint.
plotter = Plotter(...)
...
load_cell_joint = LoadCellJoint(...)
shared_load_cell_joint: SharedLoadCellJoint = base_manager.SharedLoadCellJoint()
load_cell_joint.attach_shared_attributes(shared_load_cell_joint)
plotter.attach_shared_attributes(
shared_attributes = shared_load_cell_joint,
plot_settings: dict = {
"force": {
"x": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"y": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"z": {...},
"scalar": {...}
},
"true_voltage": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
},
"noisy_voltage": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
}
}
)
A LoadCellJoint can have its force plotted in x, y, z, and scalar components. It can also have the voltage output from the sensor plotted. The voltage can be the true voltage directly from the force on the load cell or the noisy voltage after the true voltage has gone through noise generation. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
Adding a LoadCellSpring to a Plotter
Adding a LoadCellSpring to a plotter requires a SharedLoadCellSpring.
plotter = Plotter(...)
...
load_cell_spring = LoadCellJoint(...)
shared_load_cell_spring: SharedLoadCellSpring = base_manager.SharedLoadCellSpring()
load_cell_spring.attach_shared_attributes(shared_load_cell_spring)
plotter.attach_shared_attributes(
shared_attributes = shared_load_cell_spring,
plot_settings: dict = {
"force": {
"x": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"y": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"z": {...},
"scalar": {...}
},
"true_voltage": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
},
"noisy_voltage": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
}
}
)
A LoadCellSpring can have its force plotted in x, y, z, and scalar components. It can also have the voltage output from the sensor plotted. The voltage can be the true voltage directly from the force on the load cell or the noisy voltage after the true voltage has gone through noise generation. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
Adding a DisplacementSensor to a Plotter
Adding a DisplacementSensor to a plotter requires 2 SharedJoints. There is a parent joint and child joint. Displacement is defined as \vec r = \vec x_{child} - \vec x_{parent}
plotter = Plotter(...)
...
parent_joint = Joint(...)
child_joint = Joint(...)
shared_parent_joint: SharedJoint = base_manager.SharedJoint()
shared_child_joint: SharedJoint = base_manager.SharedJoint()
parent_joint.attach_shared_attributes(shared_parent_joint)
child_joint.attach_shared_attributes(shared_child_joint)
displacement_sensor = DisplacementSensor(...)
shared_displacement_sensor: SharedDisplacementSensor = base_manager.SharedDisplacementSensor()
plotter.attach_shared_attributes(
shared_attributes = shared_displacement_sensor,
plot_settings: dict = {
"disp": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
},
"voltage": {
"noisy": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"true": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
}
}
)
A DisplacementSensor can have its displacement scalar plotted. It can also have the voltage output from the sensor plotted. The voltage can be the true voltage directly from the force on the load cell or the noisy voltage after the true voltage has gone through noise generation. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
Adding a RigidActuatorController to a Plotter
Adding a RigidActuatorController to a plotter requires a SharedRigidActuatorController.
plotter = Plotter(...)
...
rigid_actuator_controller = RigidActuatorController(...)
shared_rigid_actuator_controller: SharedRigidActuatorController = base_manager.SharedRigidActuatorController()
rigid_actuator_controller.attach_shared_attributes(shared_rigid_actuator_controller)
plotter.attach_shared_attributes(
shared_attributes = shared_rigid_actuator_controller,
plot_settings: dict = {
"pos": {
"x": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"y": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
},
"z": {...},
"scalar": {...}
},
"vel": {...},
"input_voltage": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
},
"digital_voltage": {
"scalar": {
"subplot": int, # Must be in range [0, num_subplots-1]
"ylabel": str,
"color": str # White is default if not listed. "b", "g", "r", "c", "m", "y", "k", "w"
}
}
}
)
A RigidActuatorController can have its position or velocity plotted in x, y, z, and scalar components. It can also have the voltage input and/or digital input plotted. Not all of these have to be included. If it doesn't need to be plotted just don't include the key in the dictionary.
General Code Review
Process Diagram and Data Sharing
In order to achieve real-time physics simulation in parallel with sensor and controller I/O, a complex process and threading structure was developed. Due to the Global Interpreter Lock of Python, multiprocessing is the primary means of parallelism. Individual processes can and do use threading. In a C++ or similar mid-level language, threading could be used to achieve the same effect that multiprocessing does without the complex data sharing paths.
For a graphical view of the processes and threads, look in documentation/code_design/Process_Diagram.drawio. There you will find when the processes are created, the main sequence of events they do when they are started, and what ends them.
The Physics Engine
The physics engine is a variable time-step Newtonian physics engine with a few noteworthy features.
Initialization
class BFFSimulation():
def __init__(self, parent_joint: Joint, settings:dict):
self.parent_joint = parent_joint
self.joints: List[Joint] = []
self.springs: List[Spring] = []
self.actuators: List[RigidActuator] = []
self.rigid_actuators: List[RigidActuator] = []
...
self.get_joints_bfs()
...
self.sort_shared_attributes_into_frequencies()
...
self.get_joints_bfs() fills the lists of joints, springs, and actuators. This is directly used in the simulation to calculate attributes for physics modeling.
self.sort_shared_attributes_into_frequencies() is used for updating shared attributes. Elements that are shared for visualization, sensors, and controllers might want to be updated at different frequencies. Updating shared attributes is cost heavy and should be minimized when possible. Therefore, we only update when we need to.
Acceleration and Integration Techniques
With the contraint of running in real-time, having an accurate yet computationally-inexpensive method for integrating acceleration into velocity and position is necessary. Errors arrive solely from the deviance of instantaneous acceleration at t = t_0 from average acceleration over the period [t_0,t_1]. If they were the same, then the physics simulation would need nothing better than simple integration. The problem arises when trying to generalize a solution for calculating the average acceleration rather than the straightforward instantaneous acceleration. Integration techniques help overcome the deviance and can provide other benefits such as energy-reducing systems rather than energy-gaining systems that tend to become extremely unstable.
All of these integration techniques (with the slight exception of Predictor-Corrector) are implemented in StateSpace of code/Physics_Elements.py.
Simple (Euler) Integration
self.vel = self.vel + self.accel * delta_t
self.pos = self.pos + self.vel * delta_t
A tried and true method for integration. Works well with small delta_t. The cheapest method in terms of computation and memory cost.
Verlet Integration
self.vel = self.vel + self.accel * delta_t
num_prev_positions = len(self.previous_positions)
match num_prev_positions:
case 0 | 1:
self.pos = self.pos + self.vel * delta_t
case 2:
new_pos = np.array([0.0, 0.0, 0.0])
for i in range(2):
new_pos += self.verlet_constants[2][i] * self.previous_positions[i]
new_pos += self.verlet_constants[2][-1] * delta_t * self.accel * delta_t
self.pos = new_pos
case 3:
new_pos = np.array([0.0, 0.0, 0.0])
for i in range(3):
new_pos += self.verlet_constants[3][i] * self.previous_positions[i]
new_pos += self.verlet_constants[3][-1] * delta_t * self.accel * delta_t
self.pos = new_pos
...
Verlet integration is a multistep method that comes from the idea of finite differences. It is possible to calculate the instantaneous acceleration of an object using its past position values. Re-arranging the equation can yield an expression for the next position as a function of current instantaneous acceleration and the past positions.
In order to calculate the coefficients for each term, you must solve a system of equations of the Taylor expansion about each point. A script to do this can be found in code/useful_scripts/verlet_coefficients_calculator.py.
This method has proven worse than simple integration for reasons unknown for now. Increasing the number of position points seems to make the error worse. The leading theory is that the past positions all have error from their instantaneous acceleration and any error continues to propagate rather than dissipate. It is worth noting that Verlet integration assumes a constant timestep which is not true for this simulation.
How the integration method is setup now, the velocity and position are very loosely related and likely to drift from each other. This can become a signifant issue. There is also not a direct way for a controller to control the velocity of a joint and the position be automatically integrated. It is possible to perform the same matrix solver for calcuting the next velocity from past acceleration.
Adams-Bashforth Integration
num_prev_accel = len(self.previous_accel)
match num_prev_accel:
case 0 | 1:
self.vel = self.vel + self.accel * delta_t
case 2:
self.vel = self.vel + delta_t * (1.5 * self.previous_accel[-1] - 0.5 * self.previous_accel[-2])
case 3:
self.vel = self.vel + delta_t * (23/12 * self.previous_accel[-1] - 4/3 * self.previous_accel[-2] + 5/12 * self.previous_accel[-3])
...
self.pos = self.pos + self.vel * delta_t
Adams-Bashforth integration is a multistep method similar to Verlet integration. However, it takes a more direct approach on working around the source of the error from the acceleration. Adams-Bashforth
Adams-Bashforth integration has proven to do quite well while maintaining a fairly cheap computation and memory cost. 2 steps peforms similar to Euler integration while decaying the energy of the system. For that reason, it is default. 3 steps performs even better at maintaining energy. However, it tends to increase the total energy over time. Steps above 3 offer little to sometimes worse performance.
It is worth noting that Adams-Bashforth integration assumes a constant timestep which is not true for this simulation.
Mixed Integration Technique
In order to prioritize certain joints that may experience larger peturbations or need a higher fidelity, it is possible to specify the integration technique upon Joint creation. Adams-Bashforth is the default, however, for simulations with large number of joints, it can be beneficial to set them to use simple integration.
Predictor-Corrector Methods for Acceleration (Not Implemented)
Re-iterating the issues that causes needs for better integration, we want to minimize the difference between the instantaneous acceleration that we apply as if it was the average acceleration over the entire timestep. Reducing timestep is a way to minimize the error propagated into velocity and position values. It is also possible to estimate better accelerations. Adams-Bashforth integration does this by weighting past accelerations to get a better idea of the average acceleration over the next timestep.
Predictor-Corrector methods can come in many forms. Common ones are to calculate the instantaneous acceleration at points x_0 and x_0 + \Delta x then average them to be applied over the timestep. Another variant is to calculate the acceleration at x_0 + \frac{1}{2} \Delta x. The new estimated acceleration is applied and integrated over the timestep to get the next velocity and position.
It is also possible to iteratively run a predictor-corrector method until a finite error is achieved. By continuously integrating an acceleration, calculating new acceleration at the pseudo position, and looping until the changes in acceleration approach some value, the error can be controlled. However, for real-time systems, this is hard to be useful as the timestep increases at O(n) with n iterations and the accuracy of Euler integration is O(\Delta t^2) for position.
Mixed Predictor-Corrector Acceleration Technique (Not Implemented)
Tying back to mixed integration technique, applying predictor-corrector to only a handful of joint is a valid technique for minimizing the impact to timestep while getting better position estimates. For a completely generalized approach, it is possible for the simulation to decide which joints need more attention than others. By keeping track of factors such as last acceleration, current acceleration, and velocity, you can estimate how rapidly the calculated change in position would change if a more accurate acceleration was calculated. In a more definable method, a 1 step predictor-corrector method can be applied to every joint. For joints with large discrepancies in the predicted and corrected acclerations, more iterations can be completed. This has the benefit of minimizing the waste of timestep used to calculate accelerations that end up with diminishing returns.
As an example, a center joint of large mass is surrounded in a circle by much smaller masses connected through springs. The larger mass would find that the variance between the initial acceleration and the corrected acceleration would be small due to the symmetrical properties of the springs and its large mass. On the other hand, the smaller masses would experience large differences in initial and corrected accelerations due to their single spring nature and smaller mass.
General code would take the form
real_statespace = list of all joint statespace
predicted_statespace = list of all joint statespace for use in correcting acceleration
corrected_statespace = list of all joint statespace after using corrected acceleration
while self.sim_time < self.duration:
calculate predicted spring forces using real_statespace positions
calculate predicted force per spring and acceleration per spring at each joint using real_statespace positions
integrate predicted_statespace starting at t_0 over 1/2 delta_t using predicted accelerations
calculate corrected spring forces using predicted_statespace positions
calculate corrected force per spring and acceleration per spring at each joint using predicted_statespace positions
for every joint:
for every spring connected to joint:
while (corrected acceleration - predicted acceleration) / corrected_acceleration is large:
integrate predicted_statespace starting at t_0 over 1/2 delta_t using corrected accelerations
save previous corrected accelerations into predicted accelerations for comparison
calculate corrected spring forces using predicted_statespace positions
calculate corrected force per spring and acceleration per spring at each joint using predicted_statespace positions
set real_statespace acceleratiosn to corrected acceleration
integrate real_statespace starting at t_0 over delta_t using corrected acceleration
Because calculating the length of the springs is a large part of the time complexity, taking a shortcut could be beneficial during each iteration. Rather than recalculate the \vec r for every spring at each iteration, assuming a constant \vec r should suffice with such small changes. In addition, to recalculate the length at a correction step, one could take \Delta l = l_0 + [(\vec v_{parent} - \vec v_{child})(\Delta t) + \frac{1}{2}(\vec a_{parent} - \vec a_{child})(\Delta t)^2]\cdot (\vec r) where parent and child refer to the parent joint and child joint of the spring. \vec r_{spring} is defined as \vec x_{child} - \vec x_{parent} and should not be updated in correct steps.
Determining what is considered a large change in corrected vs predicted acceleration is abstract. It can be a pre-computed constant or a dynamic number that changes depending on the scenario. In situations where joints are close to each other (the length of the spring is small), what is considered a large acceleration should be smaller than if the distances were far apart. A simple method could be used to calculate what a large acceleration is. Assuming a constant acceleration, the error of change in position (error represented by \delta) due to error in acceleration is given as \delta \Delta \vec x = \frac{1}{2}(\delta \vec a)(\Delta t)^2. Since \Delta t is known and \delta \vec a was calculated, defining a large acceleration as some ratio \frac{\delta \Delta \vec x}{l_{spring}} can be of use.
A notable downfall of this method is in situations such as the large mass surrounded by smaller masses. The generalized solution would end up having to apply the iterative method to each joint due to the setup.
Collision Dection
None :)
Adding a collision detection would prove useful for dealing with runaway energy systems. However, it is computationally expensive, requiring distance calculations for n choose 2 or (\frac{n}{2}) which is \frac{n^2 - n}{2}. A more practical approach is to only consider collisions between joints with springs between them. This way the length is already computed.
Once a collision takes place, performing an elastic collision between the 2 is trivial. Ideally, collision detection would not be a thing. In a more life-like simulation, there should be repulsive springs between every joint. This would also be \frac{n^2 - n}{2} springs that are computed at all times. However, timesteps would currently be too large to be of usefulness.
Examples
1D Mass-Spring Oscillator (No HITL Elements)
The example code in located in code/_example_1d_mass_spring_oscillator.py provides a simple getting started with Joints, Springs, the plotter, and visualizer.
Above is the 3D Visualization before the physics simulation starts. The red sphere represents main_mass while the white helix on the left represents spring.
import numpy as np
import time
import multiprocessing
from multiprocessing.managers import BaseManager
from Visualization import Visualization, Plotter
from Simulation import BFSSimulation
from Physics_Elements import Joint, Spring, SharedJoint, SharedSpring
from Object_Sharing import SharedFloat
We start with the necessary imports.
'''Setting up the BaseManager so data can be shared between processes'''
base_manager = BaseManager()
BaseManager.register('SharedFloat', SharedFloat)
BaseManager.register('SharedJoint', SharedJoint)
BaseManager.register('SharedSpring', SharedSpring)
base_manager.start()
We continue by setting up the BaseManager for object sharing. We need an instance of BaseManager and we need to register the objects we intend to share. Then we start it.
'''Creating instances for plotter and visualizer'''
# Setup the plotter and visualization processes
stop_event = multiprocessing.Event()
stop_event.clear()
# Create a synchronized time between the processes so the plotter and physics sim are in sync
shared_sim_time:SharedFloat = base_manager.SharedFloat()
shared_sim_time.set(0.0) # Set the initial time = 0.0 seconds
# Create a real-time plotter for physics plotting. 1 for the mass and the other for the spring
mass_plotter = Plotter(
stop_event = stop_event,
plot_settings = {...}
)
mass_plotter.attach_shared_x(shared_sim_time)
spring_plotter = Plotter(
stop_event = stop_event,
plot_settings = {...}
)
spring_plotter.attach_shared_x(shared_sim_time)
# Create a 3D visualization for the entire model
visualizer = Visualization(
stop_event = stop_event,
scene_settings = {...}
)
This section creates instances for plotting and visualization. It starts by making an Event for simple communication between the processes. After physics simulation ends, the stop_event is set and the plotter and visualization processes will know to stop. There is also a SharedFloat. This is created for synchronization between physics simulation and plotters. It is initialized at t = 0.0. In this example, there is a dedicated plotter for the main_mass and the spring. This does not have to be this way. Multiple physics elements can be combined on the same plotter. Only a single visualization is made.
'''Setting up physics simulation'''
# Create the physics elements
main_mass = Joint(
pos = np.array([0, 5, 0]),
mass = 5,
fixed = False,
name = "Main mass",
integration_method = "adams-bashforth"
)
# Before we create a spring, we need to create the Joint on the wall for the Spring to mount to
wall_joint = Joint(
pos = np.array([-10, 5, 0]),
mass = 1, # Does not matter because it is fixed
fixed = True, # We do not want it to ever move
name = "Wall Joint"
)
spring = Spring(
parent_joint = main_mass,
child_joint = wall_joint,
unstretched_length = 13,
constant_stiffness = 100,
name = "Spring"
)
These are all of the elements needed for the physics simulation. main_mass starts in the center raised by 5 meters. wall_joint is created so that there is a second joint for the spring to be attached to. It is invisible (because it was not added to the 3D visualizer) and is on the left wall. It is required for fixed = True. Finally the spring is made. Please note that parent_joint = main_mass. When we create the BFSSimulation, we specifiy main_mass as the parent_joint for the physics simulation. Because of how we defined spring, the breadth-first-search will see the main_mass, then the spring, then the wall_joint. If the spring had its parent be the wall_joint, the physics simulation would never see the wall_joint.
'''Adding items to be plotted'''
# Since we want to plot the physics of the mass and spring, we need shared attributes for them
shared_main_mass: SharedJoint = base_manager.SharedJoint()
main_mass.attach_shared_attributes(shared_main_mass)
shared_spring: SharedSpring = base_manager.SharedSpring()
spring.attach_shared_attributes(shared_spring)
# Attach the shared elements to the plotter
mass_plotter.attach_shared_attributes(
shared_attributes = shared_main_mass,
plot_settings = {...}
)
spring_plotter.attach_shared_attributes(
shared_attributes = shared_spring,
plot_settings = {...}
)
We first create and attach the shared objects so that data can be passed from the physics simulation to the plotters. Then we attach them to the plotter with plot settings.
'''Adding items to be visualized'''
# We aready have shared_main_mass and shared_spring from the plotting, so there is no need to make more shared elements
visualizer.attach_shared_attributes(
shared_attributes = shared_main_mass,
object_settings = {...}
)
visualizer.attach_shared_attributes(
shared_attributes = shared_spring,
object_settings = {...}
)
Because the shared objects are already created, we don't need to create them again. So all we need to do is attach them to the visualization object with the object settings.
'''Create the physics simulation'''
simulation = BFSSimulation(
parent_joint = main_mass, # Because spring is a child of main_mass, it will be reached by BFS
settings = {...}
)
simulation.attach_shared_time(shared_sim_time)
Like mentioned, specifiying parent_joint = main_mass provides a mean for the BFS to reach every element in the we created and linked together. We then attach the SharedFloat we created to synchronize the time bewteen the processes.
'''Setup the processes and run them'''
mass_plotter_process = multiprocessing.Process(target=mass_plotter.run_process)
spring_plotter_process = multiprocessing.Process(target=spring_plotter.run_process)
visualization_process = multiprocessing.Process(target=visualizer.run_process)
simulation_process = multiprocessing.Process(target=simulation.run_process)
mass_plotter_process.start()
spring_plotter_process.start()
visualization_process.start()
time.sleep(5) # Give the plotters and visualizer time to startup before the physics sim runs.
# Join the process
simulation_process.start()
simulation_process.join() # This blocks until the simulation finishes
mass_plotter_process.join()
spring_plotter_process.join()
visualization_process.join()
# Close the manager
base_manager.shutdown()
For each plotter, visualizer, and physics simulation, we create a process using the built in run_process() function. Then we start the plotters and visualizers so that they can launch their windows and be ready for when the physics simulation starts. We sleep for 5 seconds and then start the simulation process. This will run until the duration is up and then join back in the main code. We join the rest of the processes and shutdown the base_manager to exit the program.
1D Mass-Spring Oscillator with Actuator
2D Mass-Spring Oscillator with 2 Actuators
Stage 1 Hanging Mass
Debugging
For a graphical view of the processes and threads, look in documentation/code_design/Process_Diagram.drawio.
Not Plotting Correctly?
Plotter Window Doesn't Spawn?
Make sure you made the Plotter instance, make a process for it, and started it.
Plotter Comes Up But Doesn't Show Lines?
Make sure you made a Shared______ and attached it to the plotter AND the physics engine source correctly.
Plotting Some Parts But Not All?
This is likely a problem in Plotter.pull_data(). There is a chain of if-elif statements that check for every type of Shared______. For example, if a Joint is not being plotted correctly, go to elif isinstance(foo, SharedJoint):. It is possible there is a missed attribute that needs to be considered and handled correctly.
Make sure you gave the ylabel a unique name.
Not Visualizing Correctly?
Visualization Doesn't Spawn?
The Visualizer spwawns in your default browser. Make sure it isn't spawning in a browser you didn't know was open.
Make sure you made the Visualizer instance, make a process for it, and started it.
Visualization Doesn't Show an Object?
Make sure you made a Shared______ and attached it to the visualizer correctly.
Make sure the physics engine source (Joint, Spring, RigidActuator) has a unique name.
