Example

In this chapter the Ball Thrower example GUI shipped with Simian GUI is described. For new users of Simian GUI the code of this example and the descriptions in this chapter may offer a good starting point to start using Simian GUI.

Ball Thrower

The Ball Thrower example contains a form that facilitates running a Ball Thrower simulation model. The model uses the MATLAB ode45 or scipy.integrate.solve_ivp solver to calculate the trajectory of the ball. The application upon startup looks as follows:

The model allows for simulating the throw of a ball in an idealised situation without drag and wind and can optionally be run to also simulate the effects of drag and wind on the ball.

The model also has inputs for the throw speed, ball radius, mass and drag coefficient, wind speed, gravity and air density. These inputs can be set from the form.

The following sections describe how to run this example, the most interesting features of the form and the physics and math behind the ball thrower model.

Running the example

To run the Ball Thrower example in a local MATLAB session, ensure that Simian GUI and the Ball Thrower example files are on the MATLAB path and run the following command:

simian.local_v2_0_0.Uiformio("simian.examples_v2_0_0.ballthrower");

To start the form in a pywebview window, make sure Simian GUI is on the Python path and run the following command in Python (ensure that pywebview is installed though, or you will get an error):

import simian.local
simian.local.Uiformio("simian.examples.ballthrower")

To simulate the throw of a ball, click the "Throw" Button. The trajectory of the ball will be shown in the plot and the settings are stored in the table in the "Used settings" tab. The speed with which the ball is thrown and the gravity can be modified to simulate the effect of these parameters on the trajectory of the ball. To simulate the effects of wind and/or drag on the throw, tick the corresponding options. These options will enable more settings that can be modified, which will have their own effect on the trajectory of the ball.

BallThrower files

The files making up the MATLAB Ball Thrower example are stored in the simian-examples folder.

The form files are in the +simian/+examples_v2_0_0/+ballthrower package:

  • guiInit.m: contains the form definition.
  • guiEvent.m: contains the callbacks of the throw and clear buttons and connects with the model to simulate the ball's trajectory.
  • logo.jpg: image shown in the header of the form.

The model for simulating a ball throw is in the +simian/+examples_v2_0_0/@BallThrower class:

  • ballThrower.m: ballThrower class definition. Needs to be instantiated before you can call the throwBall method.
  • dragBall.m: method creating the set of differential equations that need to be solved for the throw.
  • throwBall.m: method starting the simulation of the trajectory of the ball.
  • zeroCrossing.m: method for determining the ground contact point boundary condition of the throw.

The Python Ball Thrower contains the same functionality, but instead of being split over multiple files it is all in the simian/examples/ballthrower.py module.

Features

The most interesting features of the example are discussed in the following subsections. Where possible code snippets from the example are included (sometimes slightly modified for readability) to illustrate how these features end up in code. The descriptions also contain links to other parts of the documentation where more information can be found.

We will discuss how the Ball Thrower example can conditionally enable and hide controls, conditionally override a value, add client side validation, prevent runs with invalid settings, and make parameters required. We will also show how to plot and tabulate data, add an image to the form and how we created the form's layout.

Conditionally enabled state

Wind cannot be simulated without simulating drag, as without drag the wind has no effect on the ball. To capture this in the form the "Enable wind" Checkbox is only enabled when the "Enable drag" checkbox is enabled.

To get this behaviour we set the disabled state of the "Enable wind" checkbox to true and add a Logic object to the "Enable wind" checkbox. Each Logic object needs a trigger and one or more actions. The trigger describes under which circumstances the actions in the logic object should be executed.

To ease disabling components with a trigger, the disableWhen method is available for each component. It uses the createDisableLogic function from the componentProperties package/module to disable the component when the trigger is triggered.

enableWindCheckbox.disableWhen("simple", {"enableDrag", false})
enable_wind_checkbox.disable_when(
    trigger_type="simple", trigger_value=["enableDrag", False]
)

Alternatively, when adding components from a table we can create a Logic object and put it in a struct/dict for the options column.

windLogic = componentProperties.createDisableLogic(...
    "simple", {"enableDrag", false});
windOptions.logic = windLogic;
wind_logic = component_properties.create_disable_logic(
    trigger_type="simple", trigger_value=["enableDrag", False]
)
wind_options["logic"] = wind_logic

Conditionally override a value

Disabling the "Enable wind" checkbox alone is not enough. It may already have been ticked, so we also need to ensure it is unticked when the "Enable drag" option is unticked.

For this we create an untick action. This action should change the checkbox's value property to false. We simply add this new action to the actions property of the existing Logic object of the "Enable wind" control, as it already has the correct trigger for this action.

windLogic.actions{end + 1} = struct( ...
    "type", "value",  ...
    "value", "value = false");
wind_logic.actions.append(
    {
        "type": "value",
        "value": "value = false"
    }
)

Conditionally hide controls

When drag and or wind are disabled, the controls for the ball properties and the wind speed are not needed and can be hidden. For this we do not use a Logic object, but a Conditional object which has a more concise definition. First the Conditional object is created with the control we want to hide as input. Then we put the key of the control the hiding depends on in the when property. The value of that control for which our control is to be hidden is put in the eq property.

The following statements hide the ballPanel Panel when the "Enable drag" checkbox is unticked:

% Hide ball panel when drag is not enabled.
ballCondition       = componentProperties.Conditional(ballPanel);
ballCondition.when  = "enableDrag";
ballCondition.eq    = false;

% Or when using table's 'options' column:
ballOptions.conditional.when = "enableDrag";
ballOptions.conditional.eq   = false;
# Hide ball panel when drag is not enabled.
drag_enabled = component_properties.Conditional(ballPanel)
drag_enabled.when = "enableDrag"
drag_enabled.eq = False

# Or when using table's 'options' column:
ball_options = {"conditional": {"when": "enableDrag", "eq": False}}

When controls are hidden, their values are not included in the payload that is sent to the back-end. To ensure that values of hidden controls are included in the payload and user defined values persist when the control is (temporarily) hidden, set the clearOnHide property of the control to false (default is true).

ballRadius.clearOnHide = false;
ball_radius.clearOnHide = False

Client side validation

For some controls we need to limit the range of values that can be entered. The ball's radius for instance should not be negative. To enforce this we can add a Validate object to a control. For the ball radius text field we create a Validate object with the control as input and set the min property to zero. This will ensure that when negative values are entered an error message is shown to highlight the problem.

This type of validation can also be done in the back-end, but doing it in the form provides quicker feedback and shows it close to the invalid value:

% Allow values larger than 0.
radiusValidate      = componentProperties.Validate(ballRadius);
radiusValidate.min  = 0;

% Or when using table's 'options' column:
radiusOptions.validate.min = 0;
# Allow values larger than 0.
ball_radius_validate = component_properties.Validate(ballRadius)
ball_radius_validate.min = 0

# Or when using table's 'options' column:
radius_options = {"validate": {"min":  0}}

Prevent run with invalid settings

To prevent running the model with values that are flagged invalid by the validation the "Throw" Button is disabled. This can simply be done by setting the disableOnInvalid property of the throw button control to true. This ensures that when the validations in the form detects an invalid value, the throw button is automatically disabled and only enabled again when all values in the form are valid.

throwButton.disableOnInvalid = true;
throw_button.disableOnInvalid = True

Required parameters

When empty values are not allowed for a setting, using the setRequired() method of the component will ensure that empty values are marked invalid. The setRequired() method creates a Validate object for the control (if none exist yet) and sets the required property to true (default is false). When you add a new Validate object to the control after using the setRequired() method, the control will lose its required state. Alternatively you can directly set the required property of the Validate object to true.

Controls that have a required value are indicated with a red asterisk and empty values are marked invalid.

ballRadius = component.Number("ballRadius");
ballRadius.setRequired();

% or 

ballRadiusValidate = componentProperties.Validate(ballRadius);
ballRadiusValidate.required = true;

% Or when using table's 'options' column:
radiusOptions.validate.required = true;
ball_radius = component.Number("ballRadius")
ball_radius.setRequired()

# or
ball_radius_validate = component_properties.Validate(ball_radius)
ball_radius_validate.required = True

# Or when using table's 'options' column:
radius_options["validate"].update({"required": True})

Plotting results

To plot ball trajectories in the form after clicking the "Throw" button, we add a Plotly object in the definition of the form and set its "title", "xaxis" and "yaxis" properties to make it look nicer:

% Create a plot window to plot the ball trajectories in.
myPlot                      = component.Plotly("plot", column_2);
myPlot.defaultValue.data    = {};
myPlot.defaultValue.layout  = struct(...
    "title", struct("text", "Balls thrown"), ...
    "xaxis", struct("title", "Distance [m]"), ...
    "yaxis", struct("title", "Height [m]")); 
myPlot.defaultValue.config  = struct(...
    "displaylogo", false);
# Create a plot window to plot the ball trajectories in.
my_plot = component.Plotly("plot", column_2)
my_plot.defaultValue['data'] = []
my_plot.defaultValue['layout'] = {
    "title": {"text": "Balls thrown"}, 
    "xaxis": {"title": "Distance [m]"},
    "yaxis": {"title": "Height [m]"},
    "margin": {"t": 40, "b": 30, "l": 50}
}
my_plot.defaultValue['config'] = {
    "displaylogo": False
}

To plot a ball trajectory in the form we create a Plotly object from the data in the form via the getSubmissionData utility function. We can then use the utils.Plotly.plot() method (MATLAB) or Plotly Figure.add_scatter() method (Python) to plot the x and y values that were returned by the BallThrower model. In MATLAB we can extend the legend by first getting its existing values and then recreating the legend with the existing and new values. In Python the legend is automatically extended with the name of the added scatter trace. We then put the Plotly object in the payload using the setSubmissionData utility function.

% Create a plotly object from the data in the form, put the current data in the plotly
% object and get the legends.
plotly          = utils.getSubmissionData(payload, "plot");
dataSpec        = plotly.data;
existingNames   = cellfun(@(x) x.name, plotly.data, "UniformOutput", false);

% Plot the newly calculated x and y values, append the legend and put the plotly
% object in the submission data.
nr = numel(dataSpec) + 1;
plotly.plot(x, y)
plotly.legend(existingNames{:}, sprintf("Attempt %d", nr));
payload = utils.setSubmissionData(payload, "plot", plotly);
# Create a Plotly object from the information in the payload.
plot_obj = utils.getSubmissionData(payload, key='plot')[0]

# Plot the new newly calculated x and y values, append the legend and put the plotly
# object in the submission data.
nr = len(plot_obj.figure.data) + 1
plot_obj.figure.add_scatter(x=x, y=y, name=f"Attempt {nr}", mode="lines")
utils.setSubmissionData(payload, key='plot', data=plot_obj)

Tabulating used settings

To keep track of the settings used for the simulations of the throws, we can add them to a table. In the form definition we create a DataGrid and use its setContent method to add nine non-editable columns with TextFields.

% Create a table to store the throw settings in.
tableOut        = component.DataGrid("summary", tabB);
tableOut.label  = "Used settings per throw";
tableOut.setContent(...
    ["Attempt", "Horizontal speed", "Vertical speed", "Wind speed", "Ball mass", ...
    "Radius", "Drag Coeff.", "Gravity", "Air density"], ...
    ["nr", "u0", "v0", "w", "m", "r", "Cd", "g", "rho"], ...
    repmat("TextField", 1, 9), ...
    false(1, 9));
# Create a table to store the throw settings in.
table_out = component.DataGrid("summary", tab_b)
table_out.setContent(
    labels=["Attempt", "Horizontal speed", "Vertical speed", "Wind speed", "Ball mass",
            "Radius","Drag Coeff.", "Gravity", "Air density"],
    keys=["nr", "u0", "v0", "w", "m", "r", "Cd", "g", "rho"],
    component_types=["TextField"] * 9,
    is_editable=[False] * 9)
table_out.label = "Used settings per throw"

When the Throw button is clicked we create a new row with the settings that were used in the simulation. This new row is appended to the values already in the table and using the setSubmissionData() function in the utils, it is added to the payload that will be sent to the form.

% Update the table with the settings used for the throws.
newRow = {nr, horSpeed, verSpeed, windSpeed, massBall, radiusBall, dragCoeff, ...
    gravity, rhoAir};

if nr == 1
    % First throw. Only put the new row in the table.
    newTableValues  = newRow;
else
    tableValues     = sub_getTableValues(payload, "summary");
    newTableValues  = [tableValues; newRow];
end

payload = utils.setSubmissionData(payload, "summary", newTableValues);
# Update the table with the settings used for the throws.
new_row = [nr, hor_speed, ver_speed, wind_speed, mass_ball, radius_ball, drag_coeff,
            gravity, rho_air]

if nr == 1:
    # First throw. Only put the new row in the table.
    new_table_values = [new_row]
else:
    # Extra row. Append the row to the DataFrame from the submission data.
    new_table_values = utils.getSubmissionData(payload, key="summary")[0]
    new_table_values = new_table_values.append(
        DataFrame(data=[new_row], columns=new_table_values.columns))

utils.setSubmissionData(payload, key="summary", data=new_table_values)

Including images

Images can be included in the form by creating an HtmlElement and using its setLocalImage() method to point to the image that should be shown. For stability it is best to use a relative file name. In this example the image is next to the guiInit.m file in which the path to the image is set. Using fileparts(mfilename("fullpath")) we get the folder containing the guiInit.m and image files. Using that as first input for the fullfile() function we can make the path to the image file relative:

imageLogo = component.HtmlElement("logo");
imageLogo.setLocalImage( ...
    fullfile(fileparts(mfilename("fullpath")), "logo.jpg"));
image_logo = component.HtmlElement("logo")
image_logo.setLocalImage(
    os.path.join(os.path.dirname(__file__), "logo.png")
)

Layout of controls

Controls in the form can be laid out using panels, containers, columns, tables and tabs.
Panels allow for grouping controls and allow for hiding them by collapsing the panel.
Containers allow for grouping controls without adding borders or background colors.
Columns allow for putting controls and panels next to each-other, making better use of the space in the form. When the window containing the form gets smaller, the contents of the columns are placed below each-other to reduce horizontal scrolling. Tables allow for laying components out in a grid. Tabs allow for grouping and showing/hiding larger parts of the form.

Background

In this section the physics and math side of throwing a ball are discussed for an idealized situation without drag and wind, and for situations where drag, and wind and drag are simulated.

Idealized situation

Let"s throw a ball in an ideal world without drag and wind. Defining a starting point, throwing speed and gravitation will do the trick:

\[\sum \vec{F} = m \cdot \vec{g},\]

therefore, we can derive that:

\[ \begin{align} x & = u_0 t \\ y & = v_0 t - \frac{1}{2}gt^2 \end{align} \]

This can be solved analytically:
\[ \frac{d^2}{dt^2} \begin{bmatrix} x \\ y \end{bmatrix}
= \begin{bmatrix} 0 \\ -g \end{bmatrix} \]

The begin position and speed are enough to determine the final result:
\[x_0, y_0, u_0, v_0\]

Such that:
\[ \begin{align} x(t) & = x_0 + u_0 t \\ y(t) & = y_0 + v_0 t - \frac{1}{2}gt^2 \\ u(t) & = u_0 \\ v(t) & = v_0 - gt \\ \end{align} \]

Adding drag

When we add drag to the idealized situation, things become more complicated:

\[\vec{F}_{drag} = -\frac{1}{2} \rho A |\vec{v}|^2 C_d \hat{v} \]

And thus: \[\sum \vec{F} = \vec{F}_{drag} + \vec{F}_g.\]

This can no longer be solved analytically, this can only be solved numerically. Therefore we need to solve the following equations using a MATLAB ode45 solver.

\[ \dfrac{d}{dt} \begin{bmatrix} x \\ \dot{x} \\ y \\ \dot{y} \end{bmatrix}
= \begin{bmatrix} \dot{x} \\
-\frac{1}{2m} \rho \dot{x}^2 C_d A \dfrac{\dot{x}}{|\dot{x}|} \\
\dot{y} \\
-\frac{1}{2m} \rho \dot{y}^2 C_d A \dfrac{\dot{y}}{|\dot{y}|} + g \end{bmatrix} \]

The begin position and speed are not enough to determine the final result:
\[x_0, y_0, u_0, v_0\]

For this situation we need more parameters:

  • \( m \): Mass of the ball
  • \( \rho \): Air density
  • \( r \): Area of the surface, assumed a sphere here, so radius is asked

Adding wind

When we add wind, the equations become slightly more complicated.

\[\vec{F}_{drag + wind} = -\frac{1}{2} \rho A |\vec{v_r}|^2 C_d \hat{v_r} \]

where \(\vec{v_r} = \vec{v} + \vec{w}\), and

\[\sum \vec{F} = \vec{F}_{drag + wind} + \vec{F}_g.\]

The equations using that need to be solved with the ode45 solver become:

\[ \dfrac{d}{dt}\begin{bmatrix} x \\ \dot{x} \\ y \\ \dot{y} \end{bmatrix}
= \begin{bmatrix} \dot{x} \\
-\frac{1}{2m} \rho (\dot{x} - w)^2 C_d A \dfrac{\dot{x} - w}{|\dot{x} - w|} \\
\dot{y} \\
-\frac{1}{2m} \rho \dot{y}^2 C_d A \dfrac{\dot{y}}{|\dot{y}|} + g \end{bmatrix} \]

The begin position and speed are not enough to determine the final result: \[x_0, y_0, u_0, v_0\]

For this situation we need more parameters:

  • \( m \): Mass of the ball
  • \( \rho \): Air density
  • \( r \): Area of the surface, assumed a sphere here, so radius is asked
  • \( w \): Wind Speed