Building a system using YAML

This tutorial explains how to define mechanical systems using YAML configuration files. YAML is the recommended approach for complex models with many components, since it separates geometry data from simulation code.

Overview

The YAML workflow has three steps:

  1. Write a YAML file — define points, segments, and other components in a structured text file
  2. Load with load_sys_struct_from_yaml — parses the YAML and calls the same Julia constructors used in the Julia tutorial
  3. Compile and simulate — same as the Julia path: SymbolicAWEModel → init! → next_step!

The YAML loader does as little as possible: it parses YAML, applies the variables block, converts string enum values, and calls constructors. All defaults and derived calculations happen in the component constructors.

YAML file structure

A YAML file can contain any of these top-level blocks:

BlockPurpose
variablesNamed values and property sets reused across the file
pointsPoint masses (nodes in the system)
segmentsSpring-damper connections
pulleysEqual-tension constraints
stationsDeformable wing sections (twist DOF)
tethersWinch-controlled segment groups
winchesTorque-controlled motors
wingsAerodynamic bodies
bodiesPlain rigid bodies
tubesInflated tubes between two bodies
transformsSpherical coordinate positioning

A file needs at least a points or a bodies block; everything else is optional.

Each block uses a headers + data format:

points:
  headers: [name, pos_cad, type, extra_mass]
  data:
    - [anchor, [0, 0, 0], STATIC, 0.0]
    - [mass, [0, 0, -50], DYNAMIC, 1.0]

The headers row defines column names. Each data row is a list of values matching those headers. Missing trailing columns default to nothing.

Alternatively, you can use a dict format where each row is a dictionary:

points:
  data:
    - {name: anchor, pos_cad: [0, 0, 0], type: STATIC}
    - {name: mass, pos_cad: [0, 0, -50], type: DYNAMIC, extra_mass: 1.0}

Minimal example

Here is a complete YAML file for a simple two-point tether:

# simple_tether.yaml

points:
  headers: [idx, pos_cad, type, wing_idx, transform_idx, extra_mass]
  data:
    - [1, [0, 0, 0], STATIC, nothing, 1, 0.0]
    - [2, [0, 0, -50], DYNAMIC, nothing, 1, 1.0]

segments:
  headers: [idx, point_i, point_j, l0, diameter_mm,
            unit_stiffness, unit_damping, compression_frac]
  data:
    - [1, 1, 2, 50.0, 5.0, 100000, 50.0, 0.001]

transforms:
  headers: [idx, elevation, azimuth, heading,
            base_pos, base_point_idx, rot_point_idx]
  data:
    - [1, -80, 0, 0, [0, 0, 50], 1, 2]

Load and simulate:

using SymbolicAWEModels
using KiteUtils: init!, next_step!, update_sys_state!

set = Settings("system.yaml")
set.v_wind = 0.0

sys = load_sys_struct_from_yaml("simple_tether.yaml";
    system_name="simple_tether", set=set)
sam = SymbolicAWEModel(set, sys)
init!(sam)

for _ in 1:100
    next_step!(sam)
end
Transform angles

Transform elevation and azimuth values in YAML are specified in degrees (converted automatically), unlike the Julia constructor which takes radians.

Variables and materials

The variables block gives names to values that are reused across the file. A name written in any column of any block is replaced by its value:

variables:
  bridle_comp: 0.01
  bridle_diameter: 2.5

segments:
  headers: [name, point_i, point_j, l0, diameter_mm,
            unit_stiffness, unit_damping, compression_frac]
  data:
    - [bridle_left, 1, 2, 5.0, bridle_diameter, 100000, 50.0, bridle_comp]
    - [bridle_right, 1, 3, 5.0, bridle_diameter, 100000, 50.0, bridle_comp]

A variable may hold a number, a string or a list (kcu_pos: [0.0, 0.0, 12.0]), and may be written in terms of another variable. Column names in headers are never substituted, but a variable may not share its name with a component — that is an error, since references to the component would resolve to the variable.

Multi-variables and materials

A variable holding a mapping fills several columns at once: it stands for the columns it names, so the row is written with one entry for the whole group.

variables:
  dyneema:
    youngs_modulus: 55.0e9
    damping_per_stiffness: 0.00077
    density: 724.0

segments:
  headers: [name, point_i, point_j, l0, diameter_mm,
            youngs_modulus, damping_per_stiffness, density, compression_frac]
  data:
    # 'dyneema' fills youngs_modulus, damping_per_stiffness and density
    - [bridle, 1, 2, 5.0, 5.0, dyneema, 0.01]
    - [thin_bridle, 1, 3, 5.0, 1.0, dyneema, 0.01]
    # Written out (no multi-variable)
    - [strut, 2, 3, 5.0, 1.0, 6.4e9, 0.002, 724.0, 0.01]

The fields must match the columns starting at that position, in any order — a mismatch is an error naming both sides. This replaces the old materials table: each material defines only the fields it needs, with no shared headers row to keep in sync, and no nothing padding for the columns it fills.

In a dict row the fields are merged instead, and the row wins — override a field by naming it:

segments:
  data:
    - {name: bridle, point_i: 1, point_j: 2, l0: 5.0, diameter_mm: 5.0,
       material: dyneema, damping_per_stiffness: 0.001}

A segment's density [kg/m³] is used for its mass, so different tethers can use different materials. Without one, the global set.rho_tether applies.

Material properties versus element properties

unit_stiffness [N] and unit_damping [Ns] describe one element: both scale with its cross section, so a material shared by segments of different diameter must not fix them. Use the diameter-independent form instead:

MaterialElementRelation
youngs_modulus [Pa]unit_stiffness [N]unit_stiffness = youngs_modulus * pi * (diameter_mm/2000)^2
damping_per_stiffness [s]unit_damping [Ns]unit_damping = damping_per_stiffness * unit_stiffness

Either form may be given per row; giving both forms of the same quantity is an error. What is left out comes from the settings (e_tether, rel_damping, d_tether, rho_tether).

Removed in v0.14

The materials, elements and segment_properties tables were removed. A material is now a multi-variable, and its youngs_modulus, damping_per_stiffness and density are ordinary columns.

Component reference

Points

points:
  headers: [name, pos_cad, type, wing_idx, transform_idx, body, tube,
            vel_w, extra_mass, body_frame_damping, world_frame_damping,
            area, drag_coeff, fix_sphere, fix_static]
FieldTypeDefaultDescription
nameString/IntrequiredPoint identifier (idx also accepted)
pos_cad[x,y,z]requiredAuthored position [m], before the transforms move it; place! turns it into the initial pose pos_ENU
typeStringrequiredSTATIC, DYNAMIC, or BODY_STATIC
wing_idxInt/nothingnoneWing this point belongs to; omit it, or 0, for a point that belongs to none
transform_idxInt/nothingnothingTransform for initial positioning
bodyRef/nothingnothingBODY_STATIC: body the point rides
tubeRef/nothingnothingBODY_STATIC: Timoshenko tube the point rides
vel_w[x,y,z]zerosInitial world-frame velocity [m/s]
extra_massFloat0.0Additional mass [kg]
body_frame_dampingFloat0.0Damping in body frame [Ns/m]
world_frame_dampingFloat0.0Damping in world frame [Ns/m]
areaFloat0.0Cross-sectional area for drag [m^2]
drag_coeffFloat0.0Drag coefficient
fix_sphereBoolfalseConstrain the point to a sphere
fix_staticBoolfalseDynamically freeze the point position

A BODY_STATIC point rides a rigid body (body:, or wing: for a wing body) or a Timoshenko tube (tube:); its body-frame offset is derived from its initial pose.

Segments

segments:
  headers: [idx, point_i, point_j, l0, diameter_mm,
            unit_stiffness, unit_damping, compression_frac]
  data:
    - [1, 1, 2, 5.0, 5.0, 100000, 50.0, 0.01]

The material columns can also come from a multi-variable, see Variables and materials.

FieldTypeDescription
idxIntSegment identifier
point_i, point_jIntEndpoint point indices
l0FloatUnstretched length [m] (0 = calculate from points)
diameter_mmFloatDiameter [mm]
unit_stiffnessFloatPer-unit-length stiffness [N]
unit_dampingFloat/nothingPer-unit-length damping [Ns], or nothing for the settings default
youngs_modulusFloat/nothingDiameter-independent alternative to unit_stiffness [Pa]
damping_per_stiffnessFloat/nothingDiameter-independent alternative to unit_damping [s]
compression_fracFloatCompressive/tensile stiffness ratio (0-1)
compression_damping_fracFloatFraction of unit_damping still acting under compression (0-1, default 1)
densityFloat/nothingMaterial density [kg/m³]; falls back to set.rho_tether

Pulleys

pulleys:
  headers: [idx, segment_i, segment_j, type, efficiency]
  data:
    - [1, 3, 4, DYNAMIC, 0.95]
FieldTypeDefaultDescription
idxInt—Pulley identifier
segment_i, segment_jInt—The two segments sharing the pulley point
typeEnum—DYNAMIC
efficiencyFloat0.95Fraction of line tension the sheave passes on (1.0 = ideal)
dampingFloat0.0Artificial damping on rope travel [Ns/m], for debugging
brakeBoolfalseFreeze the rope split where it is, for debugging
friction_epsilonFloat0.1Friction smoothing width [m/s]

Tethers

Route 1 (explicit segments):

tethers:
  headers: [idx, segment_idxs]
  data:
    - [1, [1, 2, 3]]

Route 2 (auto-generated segments):

tethers:
  headers: [name, start_point, end_point, n_segments]
  data:
    - [main, kite, ground, 5]

The generated points are DYNAMIC and the generated segments take the tether's material columns, which are the Segments ones (diameter_mm, unit_stiffness, unit_damping, youngs_modulus, damping_per_stiffness, density, compression_frac, compression_damping_frac) and may equally come from a multi-variable. A tether without a winch keeps its length fixed, which is how a plain line is split into several segments.

FieldTypeDescription
init_stretched_lengthFloat/nothingPlaced (stretched) standoff [m]; place! moves the free end to span it. nothing = keep the point geometry
init_tether_forceFloat/nothingTarget initial spring force [N], default 0
init_stretch_fracFloat/nothingInitial unstretched/stretched ratio; 1.0 is untensioned, > 1 slack. Excludes init_tether_force

Winches

winches:
  headers: [idx, tether_idxs, winch_point]
  data:
    - [1, [1], ground]

Stations

A Station is a wing section with a twist degree of freedom. point_idxs (or points) lists its structural points; type is DYNAMIC (twist solves its equilibrium), STATIC (twist is a prescribed control input) or KINEMATIC (a flap hinge whose deflection follows two bodies).

stations:
  headers: [name, point_idxs, type, moment_frac, damping, stiffness]
  data:
    - [left,   [le_left, te_left],     DYNAMIC, 0.25, 100.0, 0.0]
    - [center, [le_center, te_center], DYNAMIC, 0.25, 100.0, 0.0]

Wings

wings:
  data:
    - name: main_wing
      dynamics_type: RIGID_DYNAMICS   # or PARTICLE_DYNAMICS
      aero_mode: linearized           # direct | continuous | pressure | plate | none
      stations: [left, center, right]
      origin_idx: kcu
      z_ref_points: [kcu, le_center]
      y_ref_points: [le_right, le_left]
      aero_z_offset: 0.0

Mass properties (extra_mass, com, unit_inertia) are optional columns; com and unit_inertia are computed from the wing's .obj mesh when omitted, if one is supplied. They describe the wing body alone, as below.

Mass of a rigid body

A RIGID_DYNAMICS wing or a plain Body has an extra_mass: its own mass, which nothing overwrites. The points it carries — its BODY_STATIC riders, and a rigid wing's nodes — keep their own masses too, and the body adds each of them at the point's attachment as its total_mass: its extra_mass plus half of every segment it holds. So the body's

  • total_mass is extra_mass plus the total_mass of every point it carries, and its weight acts at the COM;
  • COM (com_offset_b) is the mass-weighted mean of its own COM and those points;
  • inertia is its own about that COM plus each point's, by the parallel-axis theorem.
body.total_mass
├── body.extra_mass            the body's own mass
└── point.total_mass           for every point the body carries
    ├── point.extra_mass
    └── segment_mass / 2       for every segment attached to the point

Mass given both on the body and on its points counts both, once each: a KCU point of 10 kg riding a 15 kg wing makes a 25 kg body whose COM sits 40% of the way to the KCU. A wing with no extra_mass of its own and no point masses spreads set.mass over its points. A particle wing's total_mass adds up its free points and section bodies, which carry its mass.

The segment halves use each segment's l0 at the start of a run (update_mass_properties!, run by init!); they are not updated while a winch changes a tether's l0 during a run.

Bodies and tubes

A Body is a plain rigid body — no aerodynamics, no structural points of its own. Bodies are linked by Tubes: an inflated tube of one diameter [m] and pressure [Pa], whose law gives its rigidities and whose model is the element it is simulated as — timoshenko, a 2-node TimoshenkoTube beam element, or elastic, a lumped 6-DOF ElasticTube spring. A chain of Timoshenko tubes forms a beam, and the placed bodies fix each tube's rest length. BODY_STATIC points ride a body (body_idx) or a Timoshenko tube (tube).

bodies:
  headers: [name, extra_mass, inertia_principal, pos, type]
  data:
    - [nodeA, 1.0, [0.01, 0.01, 0.01], [0.0, 0.0, 0.0], STATIC]
    - [nodeB, 1.0, [0.01, 0.01, 0.01], [1.0, 0.0, 0.0], DYNAMIC]

tubes:
  headers: [name, bodies, diameter, pressure, law, model, shear_coeff, damping]
  data:
    - [beam, [nodeA, nodeB], 0.12, 30000.0, breukels2011, timoshenko, 0.8333,
       0.05]

points:
  headers: [name, pos_cad, type, body_idx]
  data:
    - [tip_anchor, [1.0, 0.0, 0.0], BODY_STATIC, nodeB]

A timoshenko tube derives each of EA, GA, GJ, EIy and EIz from its law unless its row gives it; an elastic tube takes stiffness_axial, stiffness_shear, stiffness_torsion and stiffness_bending. Both take damping [s], and anchor_a/anchor_b place the ends in each body's frame. Scalar rigidities are linear laws; nonlinear (callable) ones are supplied programmatically, not from YAML.

Transforms

transforms:
  headers: [idx, elevation, azimuth, heading,
            base_pos, base_point_idx, rot_point_idx]
  data:
    - [1, -80, 0, 0, [0, 0, 50], 1, 2]

Loading workflow

The full loading workflow for a model with aerodynamics:

using SymbolicAWEModels, VortexStepMethod

set_data_path("data/2plate_kite")
set = Settings("system.yaml")
vsm_set = VortexStepMethod.VSMSettings(
    project_file("vsm_settings"); data_prefix=false)

struc_yaml = joinpath(get_data_path(),
    "rigid_structural_geometry.yaml")
sys = load_sys_struct_from_yaml(struc_yaml;
    system_name="2plate_kite",
    set=set,
    vsm_set=vsm_set)

sam = SymbolicAWEModel(set, sys)
init!(sam)

2-plate kite structure

After compilation, a cache file (model_*.bin) is saved. Subsequent loads skip the expensive symbolic compilation and deserialize the cached model instead. Force a rebuild with init!(sam; remake=true).

YAML vs Julia

AspectYAMLJulia constructors
Best forComplex models, data from CAD/measurementsSimple models, programmatic generation
ReadabilityEasy to scan geometry at a glanceBetter for computed geometry (loops, formulas)
Material refsBuilt-in: reference by nameManual: pass stiffness/damping directly
Version controlClean diffs for parameter changesCode diffs mix logic and parameters

Both paths produce the same SystemStructure type and are equally capable. They can be freely mixed — for example, load a YAML model and then modify component fields in Julia before simulation.