Parse, convert, and generate UCUM (Unified Code for Units of Measure) unit codes in Julia.
UCUM is a context-free grammar for unambiguous, machine-readable unit strings
(e.g. mmol/L, kg.m2.s-2.C-1, Cel) widely used in clinical and scientific
data exchange. UCUM.jl is built for the common case of ingesting unit codes
from data files at runtime and normalizing the associated values to SI.
The package has a standalone, dependency-free core (its own UcumUnit type,
UCUM grammar parser, and the UCUM⇄SI basis change) and optional package
extensions that bridge to
DynamicQuantities.jl and
Unitful.jl.
The full unit registry is generated on your machine when the package loads,
from the official data/ucum-essence.xml (UCUM v2.2): 312 unit atoms (305
derived plus the 7 base units), 24 prefixes (incl. binary), and all
special/affine units. It is built per process and never written to disk — see
data/UCUM-LICENSE.md for why that matters.
Note: This package is still in its early stages. Feedback from users is welcome to further improve the functionality and interface of UCUM.jl.
using UCUM
u = ucumparse("mmol/L") # parse a UCUM code → UcumUnit
# value normalized to SI base units → ≈ 37.0 (mol/m^3)
sivalue(37.0 * u)
sidims(u) # SI dimensional signature
ucumparse("kg.m2.s-2.C-1") # explicit factored form ≡ volt
sivalue(25.0 * ucumparse("Cel")) # affine units → 298.15 (K)
sivalue(30.0 * ucumparse("dB")) # prefixed special units → 1000.0
# true — validate before converting
iscommensurable(ucumparse("mm[Hg]"), ucumparse("Pa"))
ucumconvert(ucumparse("Pa"), 1.0 * ucumparse("bar")) # → 100000.0 Pa
ucumstrip(ucumparse("Cel"), 310.15 * ucumparse("K")) # → 37.0
# SI dims → "kg.m2.s-2.C-1"
ucumcode(sidims(ucumparse("V")))
# → "kg.m2/(s2.C)"
ucumcode(sidims(ucumparse("V")); strategy = :readable)For literal codes there is a string macro, resolved at compile time. A plain
code is a UcumUnit; a leading number (whitespace optional) makes it a
UcumQuantity — unless the whole string is itself valid UCUM, which always
wins (10*3 is the thousand atom, 2.m a factor term, so those stay units;
5mg is invalid as pure UCUM and cleanly becomes 5.0 mg):
ucum"mmol/L" # UcumUnit
ucum"5mg" # UcumQuantity(5.0, mg)
ucum"5 mg" # same
ucum"10*3" # the thousand *unit* — valid UCUM always stays a unitRuntime data (codes from JSON) still goes through ucumparse — macros are for
literals only.
Every exported name is prefixed ucum/Ucum (or si/Si for the SI views),
apart from Base-style is* predicates on UCUM's own types (isdimensionless,
isspecial, isarbitrary, iscommensurable).
That is deliberate: UCUM.jl is a front-end to Unitful and DynamicQuantities,
so it must be loadable alongside them. It therefore claims none of the
shared vocabulary — uparse, ustrip, uconvert, dimension, unit,
Quantity and friends keep belonging to whichever units library you loaded, and
using UCUM, Unitful needs no qualification or disambiguation.
A UcumQuantity carries a value and a unit. Comparison is semantic —
quantities are equal when they denote the same physical value — while other
arithmetic (+, -, <) is deliberately not provided: this package is a
UCUM front-end, so do the maths in Unitful/DynamicQuantities (or on plain
numbers after sivalue).
1000 * ucumparse("g") == 1 * ucumparse("kg") # true
0 * ucumparse("Cel") == 273.15 * ucumparse("K") # true
# ≈ for float tolerance
1.0 * ucumparse("mmol/L") ≈ 1.0 * ucumparse("mol/m3")
1 * ucumparse("m") == 1 * ucumparse("s") # false (never an error)Printing always shows a faithful unit: a parsed unit displays the code it came from, and a computed one displays its scale explicitly:
ucumparse("km") # km
1.0 * ucumparse("bar") # 1.0 bar
ucumparse("mm") * ucumparse("mm") # 1.0e-6·m2ucumcode answers two different questions, and which one you get depends on
what you hand it:
# "kV" — the code for that unit
ucumcode(ucumparse("kV"))
# "kg.m2.s-2.C-1" — its SI dimensional template
ucumcode(sidims(ucumparse("kV")))A unit's own code round-trips: ucumparse(ucumcode(u)) == u, so prefixes,
named units, special units (Cel, dB) and arbitrary units ([IU]) all
survive. A parsed unit returns its original spelling, which makes the result
faithful rather than canonical — ucumparse("J") and ucumparse("N.m") are
equal units but keep their own codes. A computed unit is synthesized from its
dimensions, with the scale as a leading UCUM factor:
ucumcode(ucumparse("mm") * ucumparse("mm")) # "10*-6.m2"The dimensional template, reached through sidims, is scale-free by
construction — it is the right partner for a value that has already been
normalized with sivalue. Both serialization pairings are therefore correct,
and they say different things:
q = 2.5 * ucumparse("km")
(q.value, ucumcode(q)) # (2.5, "km") — as reported
(sivalue(q), ucumcode(sidims(q))) # (2500.0, "m") — normalized to SI baseLoading either library activates the corresponding bridge, in both
directions. No qualification or disambiguation is needed — the namespaces do
not overlap. The bridge covers the full data lifecycle: ingest a UCUM code
into the backend, compute there (arithmetic belongs to the backend),
render the result in a target UCUM unit, and serialize it back to a
(value, code) pair.
DynamicQuantities stores dimensions as runtime values, which makes it the preferred, type-stable backend for units determined at runtime (the JSON/FHIR-ingestion case).
A UCUM quantity crosses over through DynamicQuantities' own constructor, and arrives in SI base units:
using UCUM, DynamicQuantities
q = ucum"21mJ/m2" # UcumQuantity — 21.0 mJ/m2
DynamicQuantities.Quantity(q) # 0.021 kg s⁻² (SI base)
convert(DynamicQuantities.Quantity, q) # the same, via convert
ucum_dq"21mJ/m2" # the same, in one step
# 0.001 kg s⁻² — one of that unit
DynamicQuantities.Quantity(ucumparse("mJ/m2"))
# ≈ 37.0 mol m⁻³ — runtime code
DynamicQuantities.Quantity(37.0 * ucumparse("mmol/L"))
# 298.15 K — affine unit normalized
DynamicQuantities.Quantity(ucum"25Cel")The full lifecycle — ingest, compute, render, serialize:
using UCUM, DynamicQuantities
# 1. Ingest — literals via the backend macro; runtime data via ucumparse
# ≈ 5.0e-6 kg (SI base)
dose = ucum_dq"5mg"
weight = DynamicQuantities.Quantity(70.0 * ucumparse("kg")) # runtime path
# 2. Compute — arithmetic belongs to the backend
# ≈ 7.14e-8 (dimensionless)
per_kg = dose / weight
# 3. Render in a target UCUM unit
ucumstrip(ucumparse("ug/kg"), per_kg) # ≈ 71.43
# 4. Serialize back to (value, code) — e.g. FHIR {"value": v, "unit": code}
p = DynamicQuantities.Quantity(101325.0; mass = 1, length = -1, time = -2)
# (101325.0, "kg.m-1.s-2")
(sivalue(p), ucumcode(p))
# ≈ 760.0 — conventional unit
ucumstrip(ucumparse("mm[Hg]"), p)
# Special units round-trip through the core conversion machinery
ucumconvert(ucumparse("Cel"),
DynamicQuantities.Quantity(310.15; temperature = 1)) # 37.0 Cel
# Reverse constructor and validation
# 101325.0 kg.m-1.s-2 as a UcumQuantity (SI base)
UcumQuantity(p)
iscommensurable(ucumparse("Pa"), p) # true
ucumcode(DynamicQuantities.uparse("kg*m^2/s^2")) # "kg.m2.s-2"Unitful encodes units in the type domain, so quantities built from
runtime-parsed codes are type-unstable on the Unitful side (the reverse
direction is type-stable — UcumQuantity is concrete).
Crossing over works the same way, through Unitful's constructor, and likewise
lands in SI base units — use Unitful's own uconvert to re-express the result
in whatever unit you want to see:
using UCUM, Unitful
q = ucum"21mJ/m2" # UcumQuantity — 21.0 mJ/m2
c = Unitful.Quantity(q) # 0.021 kg s⁻² (SI base)
convert(Unitful.Quantity, q) # the same, via convert
ucum_uf"21mJ/m2" # the same, in one step
# 21.0 mJ m⁻² — back to the original scale
Unitful.uconvert(Unitful.u"mJ/m^2", c)
# 0.001 kg s⁻² — one of that unit
Unitful.Quantity(ucumparse("mJ/m2"))
# 298.15 K — affine unit normalized
Unitful.Quantity(ucum"25Cel")The SI extraction in the other direction is uconvert-based too, so non-base
and affine Unitful forms come out right:
using UCUM, Unitful
# ≈ 37.0 mol m⁻³ (literal ingest)
c = ucum_uf"37mmol/L"
p = 2.0 * Unitful.u"atm" # non-base Unitful unit
t = 25.0 * Unitful.u"°C" # affine Unitful unit
# ≈ 1520.0 (not 2.0 — via uconvert)
ucumstrip(ucumparse("mm[Hg]"), p)
ucumconvert(ucumparse("Cel"), t) # 25.0 Cel (°C → K → Cel)
(sivalue(p), ucumcode(p)) # (202650.0, "kg.m-1.s-2")
# Unitful represents dimensionless as a bare number;
# UcumQuantity(::Real) re-enters
x = Unitful.Quantity(30.0 * ucumparse("dB")) # 1000.0 — bare Float64
# 30.0 — the log unit round-trips
ucumstrip(ucumparse("dB"), UcumQuantity(x))
ucumcode(Unitful.u"V") # "kg.m2.s-2.C-1"With both loaded, uparse, ustrip, dimension and the rest of the shared
vocabulary unambiguously mean the other library's. Out of scope on purpose:
arbitrary units ([IU] has no SI value — both directions throw
UCUM.UcumDimensionError), Unitful.Level/Gain, and QuantityArray. The
same bridge pattern (constructors plus methods on UCUM's own verbs) is open to
any third-party units library without touching UCUM — only the literal macros
are reserved for the two built-in backends.
Look up human-readable metadata, or query the unit catalog:
ucuminfo("Cel") # Cel degree Celsius (°C) — temperature
ucuminfo("Pa").name # "pascal"
ucuminfo("um").printsymbol # "μm" (prefix + atom, entities decoded)
# UcumUnitInfo for [in_i'Hg] and m[Hg]
ucumcatalog("mercury")
ucumcatalog(property = "pressure") # Pa, bar, atm, …
ucumcatalog("deg", property = "temperature") # filters compose
UCUM.ucumproperties() # all defined property names-
Special/affine units (
Cel,[degF],[pH],B,Np, …) convert correctly but, per UCUM §22.1, cannot take part in algebra — not evenCel1. A metric prefix is allowed on a metric special unit and scales the measurement value (§21-22), so30 dBis10^(30/10) = 1000and25000 mCelis25 °C. -
Arbitrary units (
[IU],[arb'U], …) have no SI value, sosivalueand the library bridges reject them. They do convert to themselves, including across prefixes:ucumconvert(ucumparse("[IU]"), 5.0 * ucumparse("m[IU]"))→0.005 [IU]. Different arbitrary units stay mutually inconvertible — which is whyiscommensurableis the right convertibility check, notsidimsequality (both[IU]and[arb'U]have all-zero SI dimensions). -
Plane angle is dimensionless in SI, so
rad,degandsrare dimensionless here and convert to1(ucumconvert(ucumparse("1"), 1.0 * ucumparse("deg"))→ π/180). Useucumdimsfor the UCUM-faithful view that keeps angle (and uses charge instead of current). -
Registry scales are computed by evaluating the UCUM definitions in
Float64, so a few carry last-digit drift —Lis defined asdm3, which evaluates to1.0000000000000002e-3rather than1e-3. Normalized values are therefore accurate to floating-point tolerance, not bitwise:sivalue(37.0 * ucumparse("mmol/L"))prints36.99999999999999. Compare with≈, not==. -
Invalid input is always reported through
UCUM.UcumParseError/UCUM.UcumSpecialUnitError, soucumtryparseandisvalid(UcumUnit, s)never throw — safe for untrusted data. -
Hot paths:
ucumparsecosts registry lookups per call, so hoist it out of loops and cache parsed units (e.g. aDict{String,UcumUnit}) when ingesting many records with data-driven codes. For literal codes prefer theucum"..."macros — they resolve at compile time, which also makesucum_uf"..."type-stable despite Unitful's type-domain units. -
UcumQuantity{T<:Real}is parametric in its value. Construction and pass-through (q.value,ucumstrip(q),==) preserveT; conversions (sivalue,ucumconvert,ucumstrip(target, q)) returnpromote_type(T, Float64), because the registry's conversion factors areFloat64. SoInt/Float32promote toFloat64on conversion,BigFloatgains no real precision — and wrapper types that promote overFloat64keep their type: aMeasurements.Measurementcarries its uncertainty through affine and logarithmic special units, and dual numbers stay differentiable.using UCUM, Measurements t = (37.0 ± 0.2) * ucumparse("Cel") # clinical value with uncertainty sivalue(t) # 310.15 ± 0.2 (K) ucumstrip(ucumparse("[degF]"), t) # 98.6 ± 0.36 (slope 9/5)
Test dependencies live in test/Project.toml as a Pkg
workspace
member, so --project=test is a ready-made environment (this is why the package
requires Julia 1.12+).
julia --project=. -e 'using Pkg; Pkg.test()' # everything
julia --project=test test/test_parser.jl # one area on its ownThe suite is split by area — test_parser.jl, test_registry.jl,
test_basis.jl, test_special.jl, test_conformance.jl, … — and
test/runtests.jl does nothing but include them, each wrapped in a
SafeTestsets module so no state
leaks between areas. Every file is independently runnable, in a shell as above
or from a --project=test REPL with include("test/test_parser.jl"). Shared
helpers live in test/testutils.jl.
UCUM.jl is distributed under two separate sets of terms — see
NOTICE.md for the summary:
- The source code (
src/,ext/,test/) is licensed under the MIT License — seeLICENSE. - The bundled data table
data/ucum-essence.xmlis not MIT-licensed. It is a separate copyrighted work of Regenstrief Institute, Inc., redistributed verbatim and unmodified under the UCUM Copyright Notice and License, Version 1.1 (https://ucum.org/license) — seedata/UCUM-LICENSE.md.
The UCUM License is not OSI-approved and is revocable, and it imposes
conditions on everyone who uses or redistributes the data. The package as
distributed is therefore not wholly OSI open source, even though its source
code is MIT. If that matters for your use (for example, redistribution through
a Linux distribution or conda-forge), read
data/UCUM-LICENSE.md first.
If you redistribute this package, you must keep the UCUM data file and its
attribution (data/UCUM-LICENSE.md) together and unmodified.
UCUM is a standard and copyrighted work of Regenstrief Institute, Inc. UCUM.jl is an independent implementation and is not endorsed by or affiliated with Regenstrief Institute, Inc.