measurand

Measured values with uncertainty, and GUM-conformant error propagation.

A Measurand is a value with an associated uncertainty. Arithmetic on measurands propagates the uncertainty correctly, so a calculation chain can tell the truth about how well its result is known.

Uncertainty is propagated by first-order (GUM) error propagation over an automatic-differentiation graph. Because each measurand remembers which independent sources it depends on, shared sources correlate correctly: x - x is exactly 0 ± 0, not 0 ± √2·σ.

Installation

gem install measurand

Or add to your Gemfile:

gem 'measurand'

Usage

require 'measurand'

Construction

Measurand.new(5.129213, 0.01)              # value ± uncertainty
Measurand.new(5.129213)                    # exact; uncertainty 0
Measurand.relative(5.129213, 0.02)         # 2% relative — how instruments are spec'd

Measurand.cast([5.129, 5.132, 5.128], 0.001)        # shared absolute uncertainty
Measurand.cast_relative([5.129, 5.132], 0.0002)     # shared relative uncertainty

Measurand.from_samples([5.129, 5.132, 5.128])       # mean ± standard error of the mean

Parsing

Measurand.parse('5.129213 ± 0.01')
Measurand.parse('5.129213 +/- 0.01')       # ASCII, since ± is awkward to type
Measurand.parse('5.129213(10)')            # parenthetic — CODATA/NIST notation
Measurand.parse('5.129213 ± 1.9%')         # relative
Measurand.parse('5.129213')                # exact

parse raises ArgumentError on anything it does not recognise, rather than returning nil.

Arithmetic

a + b
a - b
a * b
a / b
a ** b
-a
a.abs

Measurand.new(3.2, 0.1) * 2                # => 6.4 ± 0.2 — scalars are exact
2 * Measurand.new(3.2, 0.1)                # => 6.4 ± 0.2 — either side

Independent uncertainties combine in quadrature. Shared sources are handled by the derivative graph, not assumed independent:

x = Measurand.new(5.0, 0.1)
x - x                                      # => 0.0 ± 0.0
x * 2 - x                                  # => 5.0 ± 0.1  (exactly x)

This is the difference from the old plusminus gem, which added errors linearly: 3.2±0.1 + 4.5±0.1 is 7.7 ± 0.1414, not 7.7 ± 0.2.

Comparison

a == b                 # exact: value AND uncertainty match
a.eql?(b)
a <=> b                # by value; Comparable (so <, >, sort order by value)
a.consistent_with?(b)  # same measurement within combined uncertainty?
a.overlaps?(b)         # do the ± intervals intersect?

Note that == is stricter than <=>: two measurands with equal values but different uncertainties compare 0 under <=> yet are not ==. The scientific question — "are these the same within error?" — is consistent_with?, which subtracts (so correlation is handled) and asks whether zero lies within the combined uncertainty.

Presentation

Digits follow the uncertainty — showing more precision than the measurement supports would be a lie. The uncertainty is rounded by the Particle Data Group convention, and the value is rounded to match:

m = Measurand.new(5.129213, 0.01)
m.to_s                 # => "5.129 ± 0.010"
m.to_s(ascii: true)    # => "5.129 +/- 0.010"  — for ASCII-only sinks
m.to_parenthetic       # => "5.129(10)"
m.inspect              # => "Measurand(5.129213, 0.01)"  — full precision

to_s emits the ± character by default; pass ascii: true when writing to a stream that isn't UTF-8. parse reads both forms, so either round-trips.

Transcendentals

Kept in a separate module rather than monkeypatching Math:

Measurand::Math.sqrt(m)
Measurand::Math.exp(m)
Measurand::Math.log(m)
Measurand::Math.log10(m)
Measurand::Math.sin(m)
Measurand::Math.cos(m)
Measurand::Math.tan(m)

Numeric sugar — opt-in

Required separately, so nothing is monkeypatched unless you ask:

require 'Measurand/Numeric'

5.129213.pm(0.01)                          # => Measurand(5.129213, 0.01)
5.129213.±(0.01)                           # alias; not infix — Ruby won't allow it
[5.129, 5.132].pm(0.001)                   # Enumerable#pm — array of measurands

Design notes

  • Forward-mode automatic differentiation. Each measurand carries a map of partial derivatives with respect to the independent sources it depends on. The uncertainty is derived from those partials and the sources' own uncertainties. Shared sources cancel correctly.
  • == is exact, consistent_with? is the science. Equality compares both value and uncertainty; consistency is the interesting question and gets its own name.
  • The value is not coerced to Float at construction. A reading from a 6-digit instrument keeps its type; coercion happens at the point of arithmetic.
  • No Math monkeypatching. Measurand::Math is a separate module.

Limitations

Uncertainty is symmetric. A measurand carries a single standard uncertainty, so it is always value ± σ — never value +a −b. This is the first-order (GUM) model, and it matches the reference tools (uncertainties, errors), which are symmetric by design.

Two consequences worth knowing:

  • Quoted asymmetric errors are not represented. Literature values written 5.3 +0.4 −0.2 — from non-Gaussian likelihoods, low-count Poisson statistics, or parameters near a physical boundary — cannot be stored as such.
  • Nonlinear propagation is linearized, and therefore symmetrized. Even from symmetric inputs, a nonlinear function produces an asymmetric interval that first-order propagation flattens back to a single σ. For example, Measurand::Math.exp(Measurand.new(0.0, 1.0)) reports 1.0 ± 1.0, but the true ±1σ interval is [exp(−1), exp(+1)] = [0.368, 2.718], i.e. +1.72 / −0.63. Near strong nonlinearity or large relative uncertainty, read the single σ as a linear approximation, not an exact interval.

Asymmetric and second-order propagation are deferred: combining asymmetric errors correctly is genuinely unsettled (see Barlow, Asymmetric Errors), and doing it properly means Monte-Carlo sampling or second-order moments rather than the quadrature used here.

  • Python uncertainties — the reference implementation of this approach.
  • R errors — GUM-conformant, and, correctly, leaves units to a separate package.

License

MIT