Using Virtual State Blocks
What is a Virtual State Block?
A VirtualStateBlock (VSB) is a class that converts a
state estimate and covariance from one representation to another. These
alternative state representations can then be used to simplify some operations
that a user would normally need to do ‘manually’, such as producing solutions
for output, forming error state updates, or doing lever arm and frame
corrections between different points on a vehicle.
In NavToolkit, a StateBlock is a class that is used
to represent some value being estimated, but it does not directly hold the
actual estimate \(\mathbf{\hat{x}}\) and covariance \(\mathbf{P}\).
Rather, these values are managed internally by a fusion engine like
StandardFusionEngine and the estimate and
covariance corresponding to a StateBlock are
obtained by the user by (for example) querying the engine’s
get_state_block_estimate()
function using the StateBlock’s
label.
A VirtualStateBlock enables the engine to represent
existing states in a different way, by providing a mapping function
\(\mathbf{f(\hat{x})}\) (
VirtualStateBlock::convert_estimate) and its
Jacobian \(\mathbf{J}\) (
VirtualStateBlock::jacobian), the partial
derivative of \(\mathbf{f(\hat{x})}\) evaluated at
\(\mathbf{\hat{x}}\), \(\frac{\partial f(\mathbf{\hat{x}})}{\delta
\mathbf{\hat{x}}}|_{\mathbf{\hat{x}}}\). Using these functions, the fusion
engine computes the current value of the
VirtualStateBlock's states on demand, based on the
value of other states it’s estimating.
Comparing and Contrasting State Blocks and Virtual State Blocks
Neither StateBlocks nor
VirtualStateBlocks actually contain a state
estimate (\(\mathbf{\hat{x}}\)) or covariance (\(\mathbf{P}\)) – these
come from the engine’s
get_state_block_estimate() and
get_state_block_covariance()
respectively – but both implicitly define what ‘their’ state vector must
look like via their respective functions’ outputs. For example, the
Phi produced by
generate_dynamics() implies the length, order,
and units of the associated state vector by virtue of the length, order, and
units of its diagonal elements. A similar argument applies to
VirtualStateBlocks
jacobian(), though two state
vectors are involved in this case: the state vector to be transformed, and the
result.
Both StateBlock and
VirtualStateBlock are not usually interacted with
directly after creation and passing to an engine, but are instead referenced
using a string label.
While a StateBlock is referenced using the single
label attached to it, a
VirtualStateBlock has two labels:
current(for the source representation) referring to theStateBlock(or anotherVirtualStateBlock) that provides the input \(\mathbf{\hat{x}}\) and \(\mathbf{\hat{P}}\) to be converted.targetto refer to the converted values. When a filter implementation fully supportsVirtualStateBlock, a user is able to retrieve theVirtualStateBlocks state and covariance representations \(\mathbf{\hat{x}_{vsb}}\) and \(\mathbf{P_{vsb}}\) by querying the filter using thetargetlabel.
The ‘core’ of each is a function that transforms the state vector, but the
transformations are different. A StateBlock
transforms the state vector over time; its
g function takes an estimate
\(x_t\) at a given time and returns \(g(x_t) \rightarrow x_{t+1}\), the
expected estimate at a future time, keeping the semantic meaning of the states
intact, including units and frames. For the
VirtualStateBlock, the
convert_estimate() function
changes units, frame, or other semantic meaning of the states, but keeps the
time of validity \(t\) intact.
How to Configure a Virtual State Block
To properly use a VirtualStateBlock:
Figure out what units/frames your desired
VirtualStateBlockstates need to be in.Identify an estimate and covariance source to form the basis of the
VirtualStateBlock. This can be from an existingStateBlockor anotherVirtualStateBlock.Determine the
currentlabel to use. This will either be aStateBlock’slabelor anotherVirtualStateBlock’stargetand derived from the selection made in step 2.Determine if an existing
VirtualStateBlockcan perform the conversion; if not, implement one.Create the
VirtualStateBlockand add it to the filter.Get the
VirtualStateBlockvalues as you would with a normalStateBlock, using thetargetlabel to retrieve them from the engine.
We have a number of off-the-shelf VirtualStateBlock
s that handle common conversions relating to inertial error states, lever arm
corrections and unit conversions, some of which are described in the
examples below. You can also
implement your own VSB.
Aside: Chaining
As the only prerequisite to form a VirtualStateBlock
is that an existing basis state and covariance must be available, it is possible
to ‘chain’ VirtualStateBlock conversions together.
This allows a user to break down a more complicated conversion into
smaller, more tractable constituents which has the additional benefit of making
more VSB representations available. No special actions are needed to perform
chaining outside of the steps outlined above; one just needs to ensure that VSB
labels ‘link’ together and the output states of one match the expected input
states of the other:
auto vsb1 = VirtualStateBlock("a", "b", ...);
// After added to filter can call filter.get_state_block_estimate("c"),
// which will return the result of vsb2.convert_estimate(vsb1.convert_estimate(filter.get_state_block_estimate("a")))
// (convert_estimate aspn_xtensor::TypeTimestamp arguments removed for clarity)
auto vsb2 = VirtualStateBlock("b", "c", ...);
Also, it is acceptable to build multiple
VirtualStateBlocks off of the same source block,
but having multiple ‘paths’ to a VirtualStateBlock
is currently not supported:
auto vsb1 = VirtualStateBlock("pinson", "pva_at_imu", ...);
// OK
auto vsb2 = VirtualStateBlock("pva_at_imu", "just_position_states", ...);
// Cannot do this if vsb2 exists, because more than one path would
// provide the label just_position_states.
auto vsb3 = VirtualStateBlock("pinson", "just_position_states"...);
In other words, if one were to draw a graph of all
StateBlock
labels,
VirtualStateBlock
targets and
VirtualStateBlock
currents
that are linked via chained VSBs, the graph may branch, but may not form a
closed loop.
Fig. 1 Visualizing a chain of virtual state blocks as a graph. vsb3 is not allowed because vsb2 already exists.
Implementing a Custom Virtual State Block
A subclass of VirtualStateBlock must define mapping
function \(\mathbf{f(\hat{x})}\) and the Jacobian \(\mathbf{J}\) by
overriding the virtual methods
convert_estimate() and
jacobian(), respectively. These will
be used by the fusion engine to generate the VirtualStateBlock’s
state estimate \(\mathbf{\hat{x}_{vsb}}\) and covariance \(\mathbf{P_{vsb}}\).
Given an original estimate \(\mathbf{\hat{x}}\) and covariance
\(\mathbf{P}\), which will be the fusion engine’s estimate and covariance
for the current label, the
VirtualStateBlock representations
\(\mathbf{\hat{x}_{vsb}}\) and \(\mathbf{P_{vsb}}\) are calculated as:
Implementing the convert_estimate()
and jacobian() functions is similar
to implementing a
MeasurementProcessor::generate_model
function. There, you define a function h
and its Jacobian H that
map a state estimate and covariance to a measurement vector
z; with
VirtualStateBlocks you define a function and
Jacobian that maps a state estimate and covariance to an alternative state
representation.
The convenience class FirstOrderVirtualStateBlock
can be used instead of VirtualStateBlock and
allows the user to supply only the mapping function
\(\mathbf{f(\hat{x})}\). It uses numerical methods to calculate the Jacobian.
Some Examples
Scalar Unit Conversion
Assume we have a single state that is estimating speed over ground in units of
meters-per-second (m/s), and a sensor that measures the same quantity but in
feet-per-second (ft/s). We would normally just enable an update with this
measurement by creating a new MeasurementProcessor and defining:
If the real StateBlock has a
label "speed_meters", we
can implement a new VirtualStateBlock as:
#include <memory>
#include <string>
#include <navtoolkit.hpp>
// Define a new VirtualStateBlock
class MetersToFeetVirtualStateBlock : public navtk::filtering::VirtualStateBlock {
public:
MetersToFeetVirtualStateBlock(std::string current, std::string target)
: VirtualStateBlock(std::move(current), std::move(target)) {}
navtk::Matrix jacobian(const navtk::Vector&, const aspn_xtensor::TypeTimestamp&) override {
return navtk::Matrix{{3.28084}};
}
navtk::Vector convert_estimate(const navtk::Vector& x,
const aspn_xtensor::TypeTimestamp&) override {
return 3.28084 * x;
}
navtk::not_null<std::shared_ptr<VirtualStateBlock>> clone() override {
return std::make_shared<MetersToFeetVirtualStateBlock>(*this);
}
};
int main() {
// Then create elsewhere
auto vsb = MetersToFeetVirtualStateBlock("speed_meters", "speed_feet");
}
Or, alternatively using the FirstOrderVirtualStateBlock:
std::function<navtk::Vector(const navtk::Vector&)> fx = [](const navtk::Vector& x) {
return 3.28084 * x;
};
std::function<navtk::Matrix(const navtk::Vector&)> jx = [&](const navtk::Vector& x) {
return navtk::Matrix{{3.28084 * x[0]}};
};
auto vsb = std::make_shared<navtk::filtering::FirstOrderVirtualStateBlock>(
"speed_meters", "speed_feet", fx, jx);
and then provide it to the filter:
filter.add_virtual_state_block(vsb);
This now enables us to reference either the StateBlock
using the "speed_meters" label,
or the VirtualStateBlock with the
target
"speed_feet". Using the latter lets us use the off-the-shelf
DirectMeasurementProcessor as the state and
measurement units are the same:
auto proc =
navtk::filtering::DirectMeasurementProcessor("proc", "speed_feet", navtk::Matrix{{1.0}});
In this case, the use of a VirtualStateBlock
is likely overkill, but a more complicated relationship between the state and
the measurement can benefit greatly from being broken into simple, composable
transformations.
Vector Unit Conversion with FirstOrderVirtualStateBlock
We start by assuming that we have a StateBlock
modeling 3 position states: latitude, longitude and altitude in radians,
radians and meters. We wish to convert these to Earth-Centered-Earth-Fixed
(ECEF) coordinates. We identify an existing navutils function,
llh_to_ecef(), that can do this conversion. Luckily,
it also accepts and returns Vector, which is the exact format
we need (otherwise, we’d have to wrap the navutils function in a lambda or
similar that accepts/returns Vector). We will avoid calculating
the Jacobian by hand as it is fairly involved and just use the
FirstOrderVirtualStateBlock:
auto vsb = navtk::filtering::FirstOrderVirtualStateBlock(
"llh_pos", "ecef_pos", navtk::navutils::llh_to_ecef);
Error-State to Whole-State
A more interesting example occurs when we have an error state filter. In this case we are estimating the errors in some reference process, such as the position, velocity, attitude (PVA) and sensor errors in a ‘raw’ inertial integration. However, the error states themselves are not really the end goal of the process–we are usually interested in the ‘best’ PVA available, which is the reference inertial PVA solution with the estimated errors removed.
Taking our Pinson15NedBlock as an example state
representation and our BufferedImu class as the
source of the reference PVA, calculating the ‘best’ PVA at the IMU requires the
following operations:
Get the reference PVA at the filter time
Convert the NED position errors in meters to LLH errors in radians, radians and meters, and add these to the reference position
Add the velocity error states to the reference velocity directly, as the units/frames already match
Form a skew-symmetric matrix from the NED tilt error states and use these to correct the reference NED-to-IMU DCM.
This combination of error state and reference PVA is going to be a pretty
common operation–it’ll be the first step in generating output, will form the
basis of many update functions, will be calculated in order to perform inertial
feedback, etc. Because of this we provide the PinsonErrorToStandard
VirtualStateBlock. However, evaluating this conversion relies on an outside
source of data (the reference PVA), so we need to package up that source in
a format the PinsonErrorToStandard can use:
// Assume we have a starting MeasurementPositionVelocityAttitude 'pva' previously calculated
// Create the filter and add the basic StateBlock
auto engine = navtk::filtering::StandardFusionEngine(pva.get_time_of_validity());
auto imu_model = navtk::filtering::sagem_primus200_model();
auto pinson_block = std::make_shared<navtk::filtering::Pinson15NedBlock>("pinson", imu_model);
engine.add_state_block(pinson_block);
// Create the BufferedImu that we will use to process IMU data and provide
// a reference PVA
auto ins = navtk::inertial::BufferedImu(pva);
// The reference PVA source is provided to the VSB as a generic function, so
// the user is not beholden to the BufferedImu class. As such, we capture
// the reference to the BufferedImu and convert its output to proper format
auto nav_fun = [&](const aspn_xtensor::TypeTimestamp& time) {
return navtk::utils::to_navsolution(*ins.calc_pva(time));
};
// We create the VSB using the label to the Pinson15NedBlock, the label we want
// to associate with the VSB output, and the function that provides the reference PVA
auto vsb1 = std::make_shared<navtk::filtering::PinsonErrorToStandard>(
"pinson", "standard_at_imu", nav_fun);
// Then we add it to the filter
engine.add_virtual_state_block(vsb1);
This example illustrates why convert_estimate()
and related functions also accept a TypeTimestamp input;
some conversions may rely on being able to sync up the state estimates and covariance
at a given time with other time-varying sources of information.
Pitfalls
VSBs are handy, but do have limitations. Some of them follow.
A
VirtualStateBlockis not a full-up mimic of aStateBlock; a lot of filter-side functionality that works on StateBlocks will not support VirtualStateBlocks. Generally only estimate/covariance getter functions will work:All VSBs are tied to a real
StateBlock; removing saidStateBlockfrom the filter will invalidate anyVirtualStateBlockusing it as a base.Conversions that may have discontinuities must be implemented carefully to avoid unexpected behavior. If the derivative does not exist the Jacobian will be incorrect; ideally the implementation would be written in such a way as to produce a Jacobian that’s ‘close enough’. Numerically-derived Jacobians are especially prone to this pitfall; special handling of step sizes may be required. Be sure to document any failure cases that cannot be worked around.
Using a
VirtualStateBlockis often slower and more computationally expensive than other methods. This is most pronounced when performing updates against aVirtualStateBlock, and is amplified when crossing programming language boundaries. If performance becomes an issue it may be better to push the conversion back into theMeasurementProcessor::generate_modelfunction.We often refer to being able to use a
DirectMeasurementProcessorwithVirtualStateBlock, but this is not always suitable. For instance, one would not do a direct update of roll, pitch, yaw Euler angles without additional logic (if one of the states is \(0\) and the update is \(2\pi\), these are equivalent and the state estimate shouldn’t be changed by the update, but usingDirectMeasurementProcessorwould cause the state to jump somewhere between \(0\) and \(2\pi\), depending on the covariances associated with state and measurement). Use of aVirtualStateBlockwill not make issues such as these disappear.As the
VirtualStateBlockoperates at the base level on rawVectorandMatrixinputs, clear documentation of state units, frames and ordering onVirtualStateBlockinputs and outputs is absolutely critical. This also applies to what aVirtualStateBlockdoes with ‘extra’ input states. For instance, most of our VSBs are written with aPinson15NedBlockin mind as the ‘base state’, and thisStateBlockhas 15 states associated with it. The first nine are PVA related, and the last 6 are sensor errors. Only the first 9 are typically targets of any conversion, so what does one do with the 6 ‘trailing’ states? Whether they are kept or dropped during the conversion should be made obvious to potential users.