From 1b96c815a07fb6a6f4a3b82324485095f00bd0e9 Mon Sep 17 00:00:00 2001 From: Chip Hogg Date: Sun, 23 Aug 2026 14:58:05 -0400 Subject: [PATCH] Add Eigen kinematics example This shows off a nice simplification of some Eigen code. We get good mileage out of automatic unit conversions, and even the gravity constant. Along the way, we tweak the naming and ordering in the index file, to be more consistent with the titles that show up in the sidebar. --- docs/examples/adc-millivolts.md | 2 +- docs/examples/eigen-kinematics.md | 203 ++++++++++++++++++++++++++++++ docs/examples/index.md | 21 ++-- examples/BUILD.bazel | 16 +++ examples/README.md | 4 +- examples/defs.bzl | 9 +- examples/eigen_kinematics/au.cc | 91 ++++++++++++++ examples/eigen_kinematics/raw.cc | 68 ++++++++++ 8 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 docs/examples/eigen-kinematics.md create mode 100644 examples/eigen_kinematics/au.cc create mode 100644 examples/eigen_kinematics/raw.cc diff --git a/docs/examples/adc-millivolts.md b/docs/examples/adc-millivolts.md index fa214896..e6a4d6f3 100644 --- a/docs/examples/adc-millivolts.md +++ b/docs/examples/adc-millivolts.md @@ -1,4 +1,4 @@ -# Analog-to-digital converter counts to millivolts +# Analog-to-digital converter: counts to millivolts A 12-bit analog-to-digital converter (ADC) measures voltages against a 3300 millivolt (mV) reference, and reports them as integer counts from 0 to 4095. One count --- one least significant diff --git a/docs/examples/eigen-kinematics.md b/docs/examples/eigen-kinematics.md new file mode 100644 index 00000000..6c70d71b --- /dev/null +++ b/docs/examples/eigen-kinematics.md @@ -0,0 +1,203 @@ +# Eigen: 3D vector kinematics + +A body has a position, a velocity, and an acceleration, each a 3D vector. Where is it one timestep +later, and how far from the origin is it? + + + +=== "⚠️ Before: raw C++" + + ⚠️ **Before** --- every vector is a bare `Vector3d`, and the units live only in the names. + { .ab-banner .ab-before } + + ??? note "Includes and usings" + + ```cpp + --8<-- "examples/eigen_kinematics/raw.cc:frontmatter" + ``` + + ```cpp + --8<-- "examples/eigen_kinematics/raw.cc:example" + ``` + +=== "✅ After: with Au" + + ✅ **After** --- each vector is a `Quantity` whose rep is `Vector3d`, so the units are checked. + { .ab-banner .ab-after } + + ??? note "Includes and usings" + + ```cpp + --8<-- "examples/eigen_kinematics/au.cc:frontmatter" + ``` + + ```cpp + --8<-- "examples/eigen_kinematics/au.cc:example" + ``` + +!!! note + The two tabs are aligned for comparison: blank lines where one version needs fewer + statements, and extra spaces so that corresponding expressions sit in the same column. + Neither is a spelling we'd recommend writing --- they're here so that flipping between + the tabs shows only the real differences. + +Both programs print the same two lines[^1]: + +``` + 5 0 119.694 m +119.798 m +``` + +[^1]: All of our examples get compiled and run in CI, and we check that they produce the same + output. + +## What's happening + +The physics is the same on both sides: $x + v \, \Delta t + \frac{1}{2} a \, \Delta t^2$. What +changes is who is responsible for the units. The raw code has three different unit conversions: + +- `km/h` to `m/s` for the velocity, +- `ms` to `s` for the timestep, and +- `g_0` to `m/s^2` for the acceleration. + +The first two are at least visible in the source. The third one isn't, because the raw version +doesn't convert anything. Instead, it uses `-9.80665` directly as a magic number, leaving users to +guess the intent. + +All of those conversions vanish from the source code in the Au version, because the library +automatically generates the correct conversion factors --- _at compile time_. + +The names simplify, too: unit-suffixed names such as `x_m`, which force the human to keep track of +the units, get replaced by the simpler `x`. In fact, for velocity, we get _even more_ +simplification: both `v_mps` and `v_kph` get replaced by a single `v`. Its units happen to be `km +/ h`, but we don't need to worry about that; we know the library will produce any necessary +conversions. These simpler names really pay off in the `advanced_position()` function body: when +the suffixes vanish, the underlying physics shows through more clearly. + +The output lines simplify for the same reason. The raw version types the unit label by hand --- +`<< " m"`, twice, with nothing checking that it still matches what the number means. The Au version +streams the quantities themselves, and the label comes from the type: `norm(x_new)` is a length, so +it prints `m`, and `transpose(x_new)` is a whole vector, so it prints Eigen's formatting of the +elements followed by the one unit they all share. + +### Unit symbols and constants + +This example leans on [Unit symbols], such as `m` and `km`, and [Constants], such as +`STANDARD_GRAVITY`. They both have the same effect here: when you _multiply_ or _divide_ by them, +they _change the units_, but **not** the _underlying stored value_. If the input is already +a `Quantity`, you get another `Quantity`; and if it's not, then it _becomes_ one. + +Unit symbols are a handy, _concise_ way to annotate your variables with their units. Writing `12.34 +* m / s` has exactly the same effect as `(meters / second)(12.34)`; it's just a little shorter. + +Again, keep in mind that unit symbols and constants do _not_ change the underlying value. So, if +you're following our Eigen [safety guide], these do _not_ count as "operations" that create risk for +dangling references. That's why the variable assignments here are perfectly safe, even without +`eval()`. + +??? note "More nuance on lifetime risk" + To be clear: we mean that multiplying by symbols or constants doesn't _add_ lifetime risk. We + _don't_ mean unit symbols and constants _preclude_ lifetime risk. If there is _pre-existing_ + lifetime risk, these won't magically remove it. + + Consider this example. Suppose we have two utility functions that return `Eigen::Vector3d` + instances: + + ```cpp + Eigen::Vector3d v1(); + Eigen::Vector3d v2(); + ``` + + The following example is guaranteed to dangle: + + ```cpp + auto q = (v1() + v2()) * m; + ``` + + The sum holds references to its operands, which in this case are the temporary objects `v1()` + and `v2()`, neither of which survives past the semicolon at the end of the line. `m` doesn't + make this safe, but it's also not the root of the problem: the following simpler example is also + guaranteed to dangle! + + ```cpp + auto q = v1() + v2(); + ``` + + We hope this discussion clarifies how unit symbols and constants relate to lifetime risk. For + a fuller treatment of this topic, we recommend that all users read and understand the Eigen + [safety guide] before using Au with Eigen. + +### Alias names + +In this example, we went with `Position`, `Velocity`, and `Acceleration`. This is a fine approach, +but not the only possible one. If you want to use mixed units in your interfaces, you could also +define a custom "rep-named alias" for `Eigen::Vector3d`: + +```cpp +template +using QuantityV3 = Quantity; +``` + +Then you could write `QuantityV3` for a position, `QuantityV3` (after +defining a suitable `KilometersPerHour` alias), and so on. + +This question is mostly a matter of taste; Au is safe either way. + +### Eigen safety + +Eigen's famously fast performance comes in part from *lazy evaluation*. The equally famous _cost_ +of this speed is an elevated risk of object lifetime bugs. We have a whole Eigen [safety guide] +devoted to this topic in general. We'll hit the highlights relevant to this example here. + +First, it's important to appreciate that Au has "risk parity" with Eigen. This means that when you +add Au to Eigen, you still have all the same risks, but you _don't_ get _new_ ones. The [safety +guide] explains the details, but the upshot is that you should still be looking for the same warning +signs as raw Eigen, and you'll still use the same strategies to mitigate the issues (even if some of +the particulars might change, such as `eval()` being a free function instead of a member function). + +In this example, there are three places where expression templates occur, and thus three places that +may carry object lifetime risk. + +- The arguments `v` and `a` that we pass to `advanced_position()` both need unit conversions. + - These are safe because they're assigned to a _concrete type_: `Velocity` and `Acceleration`, + respectively. + +- The _return value_ of `advanced_position()`, `x + v * dt + 0.5 * a * dt * dt`, is also an + expression template. + - This is safe because the return value is a concrete type: `Position`. Evaluation happens when + we convert to the concrete type. + +- The `transpose(x_new)` that we stream on the last line is an expression template too: Eigen's + "view" functions are operations, just like arithmetic. + - This one is _not_ assigned to a concrete type. It's safe for the other reason: we consume it + inside the same full expression, so `x_new` cannot have died or changed in the meantime. + +So, using concrete types for the input parameters and the return value automatically guarantees +lifetime safety at those boundaries --- and an expression you compute and consume on the spot is +safe, because nothing gets deferred past its inputs. + +## Summary + +Au's Eigen support makes it easier to get your units right _robustly_, and often makes your code +easier to read. Lifetime safety is the one thing it leaves exactly as it found it, for better and +for worse, so make sure you're familiar with the Eigen [safety guide] before you start using Au with +Eigen. + +## Related reading + +- [Eigen how-to guide](../howto/interop/eigen.md), for creating and using Eigen-backed quantities. +- [Eigen safety][safety guide], on expression templates and object lifetime. +- [Eigen compatibility reference](../reference/eigen.md), for the full list of free functions. +- [Unit symbols], including the prefix-applier form used for `km`. +- [Constants], such as the `STANDARD_GRAVITY` used here. +- [Element access](../reference/quantity.md#element-access), for reading and writing one component + of a vector quantity. + +[safety guide]: ../discussion/concepts/eigen_safety.md +[Unit symbols]: ../reference/unit.md#symbols +[Constants]: ../reference/constant.md diff --git a/docs/examples/index.md b/docs/examples/index.md index 68e55c3e..029e2ba2 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -8,18 +8,21 @@ lines in place --- the two versions are kept line-aligned on purpose. ## The examples -- **[Analog-to-digital converter counts to millivolts](./adc-millivolts.md).** Au's secret strength - for embedded applications is not just the built-in units; it's the ability to define _custom_ - units _specifically tailored to your hardware_. This gets you unit safety from the moment the - value leaves the board. +- **[Analog-to-digital converter: counts to millivolts](./adc-millivolts.md).** Au's secret + strength for embedded applications is not just the built-in units; it's the ability to define + _custom_ units _specifically tailored to your hardware_. This gets you unit safety from the + moment the value leaves the board. + +- **[Angular velocity in RPM](./angular-velocity.md).** Converting a wheel's road speed into + revolutions per minute (RPM), without fussing with manual conversion factors like `2π` or `60`. - **[Atomic units](./atomic-units.md).** Building an entire system of units on top of Au --- exact - within itself, and as accurate as physics allows at the boundary. Unlike most others, this one has - no plain-C++ counterpart: this example shows how to extend Au for a specific domain. + within itself, and as accurate as physics allows at the boundary. Unlike most others, this one + has no plain-C++ counterpart: this example shows how to extend Au for a specific domain. -- **[Linear speed to revolutions per minute](./angular-velocity.md).** Converting a wheel's road - speed into revolutions per minute (RPM), without fussing with manual conversion factors like `2π` - or `60`. +- **[Eigen: 3D vector kinematics](./eigen-kinematics.md).** Advancing a position, velocity, and + acceleration through a timestep, with `Eigen::Vector3d` as the underlying storage type of each + `Quantity`. ## How these examples are written {#front-matter} diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index 6be1834f..9972f34d 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -23,6 +23,7 @@ filegroup( ":adc_millivolts_doc_sources", ":angular_velocity_doc_sources", ":atomic_units_doc_sources", + ":eigen_kinematics_doc_sources", ], # Only the docs build consumes these. Nothing here is part of the library's API. visibility = ["//:__pkg__"], @@ -46,6 +47,21 @@ ab_example( expected_output = "409.256 rev / min\n", ) +ab_example( + name = "eigen_kinematics", + au_deps = [ + "//au", + "//au:io", + "//au/compatibility:eigen", + "@eigen", + ], + expected_output = "\n".join([ + " 5 0 119.694 m", + "119.798 m", + ]) + "\n", + raw_deps = ["@eigen"], +) + single_example( name = "atomic_units", hdrs = ["atomic_units/atomic_units.hh"], diff --git a/examples/README.md b/examples/README.md index 9b75c2a1..eed8f314 100644 --- a/examples/README.md +++ b/examples/README.md @@ -53,7 +53,9 @@ version counted raw source lines and called a visibly broken page aligned. region by hand, in house style. 3. Register it in `BUILD.bazel` with `ab_example(...)`, giving the exact expected stdout. The Au - side needs `//au:io` in its deps if it prints (see below). + side needs `//au:io` in its deps if it prints (see below). The raw side usually has no deps at + all --- that is the point of it --- but `raw_deps` is there for a non-units library both sides + share, as `eigen_kinematics` shares Eigen. 4. Add `":_doc_sources"` to the `doc_sources` filegroup at the top of `BUILD.bazel`. Both macros define that filegroup for you; listing it there is the one explicit edit per example, and diff --git a/examples/defs.bzl b/examples/defs.bzl index d5c60b75..b219d652 100644 --- a/examples/defs.bzl +++ b/examples/defs.bzl @@ -61,22 +61,27 @@ project will end up with, so it is the shape the example should build. load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library") load("@rules_shell//shell:sh_test.bzl", "sh_test") -def ab_example(name, expected_output, au_deps, raw_srcs = None, au_srcs = None): +def ab_example(name, expected_output, au_deps, raw_deps = None, raw_srcs = None, au_srcs = None): """Defines a raw-vs-Au example pair, plus the tests that keep the pair trustworthy. Args: name: Name of the example. Sources are read from this subdirectory. expected_output: The exact stdout both programs must produce. - au_deps: Deps for the Au version (the raw version must have none by construction). + au_deps: Deps for the Au version. + raw_deps: Deps for the raw version. Usually empty: the point of the raw version is that it + uses no units library. A non-units dependency that both sides share is fine, though -- + `eigen_kinematics` needs Eigen in both, since Eigen is the *rep*, not the units. raw_srcs: Sources for the raw version. Defaults to `/raw.cc`. au_srcs: Sources for the Au version. Defaults to `/au.cc`. """ raw_srcs = raw_srcs or ["{}/raw.cc".format(name)] + raw_deps = raw_deps or [] au_srcs = au_srcs or ["{}/au.cc".format(name)] cc_binary( name = "{}_raw".format(name), srcs = raw_srcs, + deps = raw_deps, ) cc_binary( diff --git a/examples/eigen_kinematics/au.cc b/examples/eigen_kinematics/au.cc new file mode 100644 index 00000000..2ec8cded --- /dev/null +++ b/examples/eigen_kinematics/au.cc @@ -0,0 +1,91 @@ +// Copyright 2026 Aurora Operations, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// NOTE TO EDITORS: this file is line-aligned with `raw.cc`. See the note in that file. +// +// There is deliberately no `eval()` in this file, even though it computes with Eigen expression +// templates throughout. Every place a result is *stored* names a concrete type -- the return type +// `Position`, and the declaration of `x_new` -- and constructing those is what evaluates the +// expression. That is exactly how the raw tab avoids the same trap. `eval()` earns its keep when +// you would otherwise write `auto`; adding it here would suggest it is always required, which is +// a different and wrong lesson. See `docs/discussion/concepts/eigen_safety.md`. + +// --8<-- [start:frontmatter] +#include "au/au.hh" + +#include + +#include "Eigen/Core" +#include "au/compatibility/eigen.hh" +#include "au/io.hh" +#include "au/units/hours.hh" +#include "au/constants/standard_gravity.hh" +#include "au/units/meters.hh" +#include "au/units/seconds.hh" + +// This is a `.cc` file, so we import the names we use, one at a time. See the "Namespaces and +// includes" discussion page for why we do this rather than `using namespace au;`. +using au::kilo; +using au::Meters; +using au::milli; +using au::STANDARD_GRAVITY; +using au::norm; +using au::Quantity; +using au::QuantityD; +using au::Seconds; +using au::transpose; +using au::UnitPower; +using au::UnitQuotient; +using au::symbols::h; +using au::symbols::m; +using au::symbols::s; + +// Symbols for the prefixed units we use. A prefix applier turns an existing symbol into one for +// the prefixed unit, which is the most readable of the three ways to spell this. +constexpr auto km = kilo(m); +constexpr auto ms = milli(s); + +// Aliases for the vector quantity types, so the signature below reads well. A type alias +// introduces one name we chose, so it is fine at namespace scope even in a header. +using Position = Quantity; +using Velocity = Quantity, Eigen::Vector3d>; +using Acceleration = Quantity>, Eigen::Vector3d>; +// --8<-- [end:frontmatter] + +// clang-format off +// --8<-- [start:example] +// The types state the units. Nothing to remember; nothing to convert. +Position advanced_position(const Position &x, + const Velocity &v, + const Acceleration &a, + QuantityD dt) { + return x + v * dt + 0.5 * a * dt * dt; +} + +int main() { + const auto x = Eigen::Vector3d{0.0, 0.0, 120.0} * m; + + // Any units of the right dimension will do: the conversion is generated at compile time. + const auto v = Eigen::Vector3d{72.0, 0.0, 0.0} * km / h; + + const auto a = Eigen::Vector3d{0.0, 0.0, -1.0} * STANDARD_GRAVITY; + + + const Position x_new = advanced_position(x, v, a, 250.0 * ms); + + std::cout << transpose(x_new) << '\n'; + std::cout << norm(x_new) << '\n'; +} +// --8<-- [end:example] +// clang-format on diff --git a/examples/eigen_kinematics/raw.cc b/examples/eigen_kinematics/raw.cc new file mode 100644 index 00000000..18b690fe --- /dev/null +++ b/examples/eigen_kinematics/raw.cc @@ -0,0 +1,68 @@ +// Copyright 2026 Aurora Operations, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// NOTE TO EDITORS: this file is line-aligned with `au.cc`, so that readers can flip between the two +// on the doc website and compare corresponding lines in place. The region between the +// `[start:example]` and `[end:example]` markers must keep the same number of lines in both files, +// with corresponding constructs on corresponding lines. `//examples:eigen_kinematics_test` +// enforces the line count; keeping the lines *meaningfully* aligned is on you. +// +// That region is fenced off from clang-format, which would otherwise reflow the parameter lists and +// silently destroy the alignment. Format it by hand, in house style. The fences sit outside the +// snippet markers, so they never show up on the website. +// +// Every declaration here names `Eigen::Vector3d` rather than using `const auto`, which would blink +// more neatly against the Au tab's `const auto`. It is deliberate: `v_mps`'s initializer contains +// a multiply, so `auto` there deduces an Eigen expression template rather than a vector. With +// `v_kph` named on its own line that expression would at least not dangle, but storing a lazy +// expression is still a habit the safety guide warns against -- and using `auto` on the other +// declarations but not that one would read as arbitrary. So all three name the type, which is +// what a careful Eigen user writes anyway. +// +// `v_kph` exists to show what the raw version actually costs: the same velocity, stored twice, in +// two units, told apart only by a name suffix. Do not "simplify" it back into one statement -- +// the Au tab's blank line opposite it is the point. + +// --8<-- [start:frontmatter] +#include + +#include "Eigen/Core" +// --8<-- [end:frontmatter] + +// clang-format off +// --8<-- [start:example] +// Position must be meters, velocity m/s, acceleration m/s^2, and the timestep seconds. +Eigen::Vector3d advanced_position(const Eigen::Vector3d &x_m, + const Eigen::Vector3d &v_mps, + const Eigen::Vector3d &a_mps2, + double dt_s) { + return x_m + v_mps * dt_s + 0.5 * a_mps2 * dt_s * dt_s; +} + +int main() { + const Eigen::Vector3d x_m {0.0, 0.0, 120.0}; + + // The velocity arrives as 72 km/h downrange, so keep a second copy of it, scaled into m/s. + const Eigen::Vector3d v_kph {72.0, 0.0, 0.0}; + const Eigen::Vector3d v_mps = v_kph * (1000.0 / 3600.0); + const Eigen::Vector3d a_mps2 {0.0, 0.0, -9.80665}; + + // The timestep is 250 ms; convert that by hand too. + const Eigen::Vector3d x_new_m = advanced_position(x_m, v_mps, a_mps2, 250.0 / 1000.0); + + std::cout << x_new_m.transpose() << " m" << '\n'; // Unit label typed by hand. + std::cout << x_new_m.norm() << " m" << '\n'; // ...and again, nothing checks it. +} +// --8<-- [end:example] +// clang-format on