Modifying trajectories

Modifying existing trajectories can be useful for a variety of reasons. Sometimes, you may want to change the values of the states, controls, or other components of the trajectory. Other times, you may want to add or remove components from the trajectory.

using NamedTrajectories

Create a random trajectory with 5 time steps, a state variable x of dimension 3, and a control variable u of dimension 2

traj = rand(NamedTrajectory, 5)
traj.names
(:x, :u)

Add a new state variable y to the trajectory. Notice this is in-place.

y_data = rand(4, 5)
add_component!(traj, :y, y_data)
traj.names
(:x, :u, :y)

Remove the state variable y from the trajectory. This is not in place.

restored_traj = remove_component(traj, :y)
restored_traj.names
(:x, :u)

Adding suffixes

Another common operation is to add or remove a suffix from the components of a trajectory. This can be useful when you want to create a modified version of a trajectory that is related to the original trajectory in some way, or when you want to create a new trajectory that is a combination of two or more existing trajectories.

For now, these tools are used to create a new trajectory.

Add a suffix "_new" to the state variable x

modified_traj = add_suffix(traj, "_modified")
modified_traj.names
(:x_modified, :y_modified, :u_modified)

The modified trajectory contains the same data

modified_traj.x_modified == traj.x
true

Merging trajectories

You can also merge two or more trajectories into a single trajectory. This can be useful when you want to combine data. Mergining trajectories is like taking a direct sum of the underlying data.

Merge the original trajectory with the modified trajectory

merged_traj = merge(traj, modified_traj)
merged_traj.names |> println
(:x, :y, :x_modified, :y_modified, :u, :u_modified)

You can also extract a specific suffix from the components of a trajectory

extracted_traj = get_suffix(merged_traj, "_modified")
extracted_traj.names
(:x_modified, :y_modified, :u_modified)

If you want the original names, you can remove the suffix

original_traj = get_suffix(merged_traj, "_modified", remove=true)
original_traj.names
(:x, :y, :u)

Merging with conflicts

If there are any conflicting symbols, you can specify how to resolve the conflict.

conflicting_traj = rand(NamedTrajectory, 5)
traj.names, conflicting_traj.names
((:x, :u, :y), (:x, :u))

In this case, keep the u data from the first trajectory and the x data from the second trajectory

merged_traj = merge(traj, conflicting_traj; merge_names=(u=1, x=2,))
println(merged_traj.u == traj.u, ", ", merged_traj.u == conflicting_traj.u)
println(merged_traj.x == traj.x, ", ", merged_traj.x == conflicting_traj.x)
true, false
false, true

Merged names

merged_traj.names
(:y, :x, :u)

Advanced usage

Sometimes it may be desirable to have direct access to the underlying data matrix/vector associated with the trajectory. In other circumstances it is more useful to employ the built-in per-component and per-knot-point indexing functionality. We detail the relationship between these different methods of access here.

traj = rand(NamedTrajectory, 5)
NamedTrajectory{Float64}([0.9359022897327065 -0.5399244690047508 … -1.1591191467563131 -0.3106585502479884; -1.1840123466914483 -0.16272015853828586 … 0.19913320090544606 -0.6328664451640903; … ; 2.718726554002487 -0.19670450190977112 … -0.03809709296662128 -1.4489900182927609; -0.20957969138866905 0.8048114725668519 … 0.4671984787994504 0.9983815453045227], [0.9359022897327065, -1.1840123466914483, -1.026938739984246, 2.718726554002487, -0.20957969138866905, -0.5399244690047508, -0.16272015853828586, -1.1631593765536652, -0.19670450190977112, 0.8048114725668519  …  -1.1591191467563131, 0.19913320090544606, -0.010822335394171611, -0.03809709296662128, 0.4671984787994504, -0.3106585502479884, -0.6328664451640903, 0.7934122569648145, -1.4489900182927609, 0.9983815453045227], 5, 1.0, 5, (x = 3, u = 2, states = 3, controls = 2), NamedTuple(), NamedTuple(), NamedTuple(), NamedTuple(), (x = 1:3, u = 4:5, states = [1, 2, 3], controls = [4, 5]), NamedTuple(), 0, NamedTuple(), NamedTuple(), (:x, :u), (:x,), (:u,))

The "backing store" of a NamedTrajectory is its datavec field, a Vector{<:Real}:

traj.datavec
25-element Vector{Float64}:
  0.9359022897327065
 -1.1840123466914483
 -1.026938739984246
  2.718726554002487
 -0.20957969138866905
 -0.5399244690047508
 -0.16272015853828586
 -1.1631593765536652
 -0.19670450190977112
  0.8048114725668519
  ⋮
  0.19913320090544606
 -0.010822335394171611
 -0.03809709296662128
  0.4671984787994504
 -0.3106585502479884
 -0.6328664451640903
  0.7934122569648145
 -1.4489900182927609
  0.9983815453045227

The data field holds a reshaped "view" of the "backing store", in a form that is somewhat easier to work with:

traj.data
5×5 reshape(view(::Vector{Float64}, :), 5, 5) with eltype Float64:
  0.935902  -0.539924   2.38666   -1.15912    -0.310659
 -1.18401   -0.16272   -0.903514   0.199133   -0.632866
 -1.02694   -1.16316   -1.51828   -0.0108223   0.793412
  2.71873   -0.196705   0.209261  -0.0380971  -1.44899
 -0.20958    0.804811   1.24953    0.467198    0.998382

Indexing

The data matrix is of dimension (traj.dim, traj.T), where length(traj.names) == traj.dim

The nth component's indices are given by traj.components[traj.names[n]]

println(traj.names)
println(traj.components)
(:x, :u)
(x = 1:3, u = 4:5, states = [1, 2, 3], controls = [4, 5])

For instance, the indices of a given component at a given knot point are given as follows:

idx = 1 # x
t = 3
slice = traj.datavec[((t - 1) * traj.T) .+ traj.components[traj.names[idx]]]
println(slice == traj[t].x == traj.x[:, t])
true

More generally, the indices of a given component across all knot points are given as follows:

idx = 1 # x
println([((k - 1) * traj.dim) .+ getproperty(traj.components, traj.names[idx]) for k in 1:traj.T])
idx = 2 # u
println([((k - 1) * traj.dim) .+ getproperty(traj.components, traj.names[idx]) for k in 1:traj.T])
UnitRange{Int64}[1:3, 6:8, 11:13, 16:18, 21:23]
UnitRange{Int64}[4:5, 9:10, 14:15, 19:20, 24:25]

Writability

Views and Backing Stores

In Julia, a "view" (SubArray) is intrinsically linked to some "parent" Array. Any in-place modification of one is reflected by the other.

The following are "safe" operations on a NamedTrajectory (in-place modification of the datavec):

traj = rand(NamedTrajectory, 5)
traj.datavec
println(traj.datavec)
traj.datavec[1] *= 0.
println(traj.datavec)
traj.datavec[:] = rand(length(traj.datavec))
println(traj.datavec)
[0.628940108246519, -0.031341930879655186, 0.989787415201165, 0.28233427859903576, 1.2383577241696155, 0.6359093422622137, -0.39423444668611574, 0.6992645408921024, 1.2734647684483136, 0.09348261632879054, -0.22037478815348888, -2.3813480111331007, 0.5853657549298499, -0.596035909698749, -1.2364530307539028, -0.5494283119649687, 1.1132919731490338, 1.1762355259297517, -1.4522658829225128, 1.5170257233931586, 1.238292045087252, 0.7922706644886653, 1.4808450387743357, -0.12240178678168617, 0.566298020289145]
[0.0, -0.031341930879655186, 0.989787415201165, 0.28233427859903576, 1.2383577241696155, 0.6359093422622137, -0.39423444668611574, 0.6992645408921024, 1.2734647684483136, 0.09348261632879054, -0.22037478815348888, -2.3813480111331007, 0.5853657549298499, -0.596035909698749, -1.2364530307539028, -0.5494283119649687, 1.1132919731490338, 1.1762355259297517, -1.4522658829225128, 1.5170257233931586, 1.238292045087252, 0.7922706644886653, 1.4808450387743357, -0.12240178678168617, 0.566298020289145]
[0.5209115030400462, 0.22310177426225775, 0.265092478663288, 0.2825444178709914, 0.4874140594511386, 0.565021915908337, 0.9723727195596421, 0.17022125308779923, 0.7479929341508788, 0.4658970829925625, 0.6677174310310272, 0.20459734760536508, 0.23681074964255022, 0.7758405703144919, 0.24806718252176474, 0.8908916349001365, 0.9626496152987761, 0.9974426702807784, 0.58263666438866, 0.6981482885465873, 0.4424657885733654, 0.19688726524012434, 0.4728059600961071, 0.5027184842341622, 0.2773234263207335]

The following is an example of an "unsafe" operation (non-in-place modification of the datavec):

traj = rand(NamedTrajectory, 5)
println(traj.datavec == traj.data[:])
traj.datavec = rand(length(traj.datavec))
println(traj.datavec == traj.data[:]) # the `data` field now points to a "backing store" that is no longer accessible via `traj.datavec`; this will lead to undefined behavior:
true
false

The following is likewise an "unsafe" operation (non-in-place modification of data, replacing a "view" of datavec with a "raw" matrix):

traj = rand(NamedTrajectory, 5)
println(traj.datavec == traj.data[:])
traj.data = rand(size(traj.data)...)
println(traj.datavec == traj.data[:]) # the `data` field no longer points to any "backing store", i.e. is independent of `traj.datavec`; this will lead to undefined behavior:
true
false

In general, reassigning the values of any of the fields of a trajectory may lead to undefined behavior:

fieldnames(NamedTrajectory)
(:data, :datavec, :T, :timestep, :dim, :dims, :bounds, :initial, :final, :goal, :components, :global_data, :global_dim, :global_dims, :global_components, :names, :state_names, :control_names)

TODO:

  • Prevent this issue by catching attempts to set sensitive fields in Base.setproperty!(::NamedTrajectory, ::Symbol, ::Any) (datavec and data are the primary concern in this regard; however, issuing a warning of some kind may be appropriate).
    • Particularly because it is confusing that traj.datavec = zeros(length(datavec)) is "discouraged", while traj.x = zeros(traj.dims.x, traj.T) and traj[1].x = zeros(traj.dims.x) are both valid.

Components and Knot Points

traj = rand(NamedTrajectory, 5)
NamedTrajectory{Float64}([-0.6631827125662183 0.10855255236960826 … 0.07970403386380705 0.23990738030325373; -1.070371103449843 1.2847629645406133 … -0.3921891713796184 -0.39002222653457597; … ; -0.38453893031311187 -0.833648874771814 … 0.4838118988709584 -1.254549797135627; 0.695061988412953 -1.0324272799635863 … -0.2086995965924075 1.3601284153330822], [-0.6631827125662183, -1.070371103449843, -0.2352158270540751, -0.38453893031311187, 0.695061988412953, 0.10855255236960826, 1.2847629645406133, -2.0100491743004434, -0.833648874771814, -1.0324272799635863  …  0.07970403386380705, -0.3921891713796184, 0.2248313501938784, 0.4838118988709584, -0.2086995965924075, 0.23990738030325373, -0.39002222653457597, 1.068043229310047, -1.254549797135627, 1.3601284153330822], 5, 1.0, 5, (x = 3, u = 2, states = 3, controls = 2), NamedTuple(), NamedTuple(), NamedTuple(), NamedTuple(), (x = 1:3, u = 4:5, states = [1, 2, 3], controls = [4, 5]), NamedTuple(), 0, NamedTuple(), NamedTuple(), (:x, :u), (:x,), (:u,))

Trajectory components are accessible (as a "view") via getproperty:

traj.x
3×5 view(reshape(view(::Vector{Float64}, :), 5, 5), 1:3, :) with eltype Float64:
 -0.663183   0.108553   3.70222    0.079704   0.239907
 -1.07037    1.28476   -0.821651  -0.392189  -0.390022
 -0.235216  -2.01005   -1.91799    0.224831   1.06804

Components are also writable via setproperty!:

traj.x = rand(traj.dims.x, traj.T)
traj.x
3×5 view(reshape(view(::Vector{Float64}, :), 5, 5), 1:3, :) with eltype Float64:
 0.17964   0.354886  0.254342  0.12451   0.936795
 0.737976  0.302626  0.620118  0.615342  0.642698
 0.202852  0.84534   0.867612  0.300094  0.549747

or may be modified directly:

traj.x[1] *= 0.
traj.x
3×5 view(reshape(view(::Vector{Float64}, :), 5, 5), 1:3, :) with eltype Float64:
 0.0       0.354886  0.254342  0.12451   0.936795
 0.737976  0.302626  0.620118  0.615342  0.642698
 0.202852  0.84534   0.867612  0.300094  0.549747

Knot points are likewise accessible via getindex:

traj[1]
KnotPoint(1, [0.0, 0.7379758994365869, 0.2028516366562857, -0.38453893031311187, 0.695061988412953], 1.0, (x = 1:3, u = 4:5, states = [1, 2, 3], controls = [4, 5]), (:x, :u), (:u,))

A KnotPoint behaves much like a NamedTrajectory, with respect to getting, setting, and/or modifying its components:

traj[1].u
2-element view(reshape(view(::Vector{Float64}, :), 5, 5), 4:5, 1) with eltype Float64:
 -0.38453893031311187
  0.695061988412953
traj[1].u = rand(traj.dims.u)
traj[1].u
2-element view(reshape(view(::Vector{Float64}, :), 5, 5), 4:5, 1) with eltype Float64:
 0.09808692777592143
 0.42002377759669807
traj[1].u[1] *= 0
traj[1].u
2-element view(reshape(view(::Vector{Float64}, :), 5, 5), 4:5, 1) with eltype Float64:
 0.0
 0.42002377759669807

The parent trajectory will reflect any modifications made in this fashion:

traj.datavec
25-element Vector{Float64}:
  0.0
  0.7379758994365869
  0.2028516366562857
  0.0
  0.42002377759669807
  0.3548861838459252
  0.3026257631646354
  0.8453395502268236
 -0.833648874771814
 -1.0324272799635863
  ⋮
  0.6153416592198294
  0.3000937226746553
  0.4838118988709584
 -0.2086995965924075
  0.936795286010289
  0.6426983907069637
  0.5497472118535665
 -1.254549797135627
  1.3601284153330822

This page was generated using Literate.jl.