galata. GitHub ↗

ADR-0003: Strict SI internally, conversion only at the boundary

Context

Aerospace is a mixed-unit discipline by tradition and will remain one. Altitudes are quoted in feet, speeds in knots, temperatures in Celsius, angles in degrees, and a control engineer who is shown 3048 m instead of 10,000 ft has been given a worse answer even though the number is right.

Meanwhile every unit conversion inside a numerical routine is a latent defect. A derivative computed per radian and consumed per degree is wrong by a factor of 57.3 and looks entirely plausible on a Bode plot. Mars Climate Orbiter is the canonical example and it was not a stupid mistake; it was a boundary that nobody had written down.

Decision

The numerical core is strictly SI. Metres, seconds, kilograms, newtons, newton-metres, radians, radians per second, kelvin, pascals, kilograms per cubic metre. Nothing else crosses a function signature inside src/core, src/model, src/numerics, src/trim, src/linearize, src/synth, src/analyze, src/sim or src/ident.

Every public struct field carries its unit in a comment, on the same line, in the form // m/s. A field without one does not pass review.

Conversions exist only in two places: the user interface layer, and file format adapters. Both call the same single set of constexpr functions in galata/units.hpp, and there is no other conversion code anywhere. It sits outside include/galata/core/ on purpose: that directory is scanned by the gate below, and keeping the definition site out of it means the gate needs no exception for the one file that is allowed to contain conversion factors.

The conversion factors are exact by definition, and each carries the definition that makes it exact:

From To Factor Basis
international foot metre 0.3048 exact, by international agreement (1959)
nautical mile metre 1852 exact, by definition (CGPM/BIPM)
knot metre per second 1852 / 3600 exact, from the nautical mile
degree radian pi / 180 exact
degree Celsius kelvin + 273.15 exact, by definition of the kelvin
pound-force newton 4.4482216152605 exact, from the pound and standard gravity
slug kilogram 14.5939029372064 exact, from the pound-force and foot

Non-exact conversions are not permitted: if a factor cannot be written exactly, the underlying unit is not one this project converts.

This is gated, not merely stated. scripts/check-si-boundary.sh fails CI if a file under the numerical core calls a conversion function, or contains a numeric literal matching a known conversion factor. It reports by name the core directories that do not exist yet, so its coverage is visible in the CI log instead of being assumed rather than fixed in this document. When this record was written only src/core existed; today the gate scans forty files and reports src/synth and src/ident as the only skips. The literal check is the one that catches the real failure mode, which is not calling feet_to_metres in the solver — nobody does that — but writing alt * 0.3048 inline because it seemed obvious at the time.

Alternatives considered

A compile-time dimensional-analysis type system (mp-units, au, or a hand-rolled Quantity<Length>). This is the strictly better engineering answer: it makes the entire class of error a compile error rather than a review question, and it costs nothing at runtime.

It is rejected for the 1.x series on interface cost, not on merit. galata's numerical core is built on Eigen, and its central objects are Eigen::VectorXd state vectors and Eigen::MatrixXd Jacobians whose entries have different dimensions from each other — the A matrix of a linearised aircraft mixes 1/s, rad/s, m/s and dimensionless entries in the same matrix. A dimensioned scalar type does not compose with a matrix library whose element type must be uniform, and the wrapper needed to make it compose would be a larger and less inspectable artefact than the rule it replaces. The C plugin ABI compounds this: a C ABI cannot carry a C++ type system, so the boundary would be undimensioned anyway and the guarantee would stop exactly where plugins begin.

The rule plus the CI gate covers the same ground for the cases that actually occur, at a fraction of the interface cost. If Eigen ever gains first-class support for dimensioned scalars this should be reconsidered.

Allow degrees in the aerodynamic model because published coefficient tables are almost always tabulated against degrees. Rejected, and this is the specific case the rule exists for: the table adapter converts breakpoints to radians on load, once, and records that it did so. A derivative that is per-degree inside the solver is exactly the 57.3× error described above.

Carry a unit tag alongside each value at runtime. Rejected: it moves a class of compile-time-preventable error to run time and adds a branch to every arithmetic operation, which the determinism policy would then have to reason about.

Consequences

Revisit when

Eigen supports dimensioned scalar types without a wrapper, or the project drops the C plugin ABI. Neither is expected within 1.x.