Skip to content

Latest commit

 

History

History
368 lines (296 loc) · 15.5 KB

File metadata and controls

368 lines (296 loc) · 15.5 KB

UCUM.jl

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.

Quick start

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 unit

Runtime 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.

Quantities, equality and display

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·m2

Generating codes

ucumcode 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 base

Backend extensions

Loading 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

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

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.

Metadata & catalog

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

Notes

  • Special/affine units (Cel, [degF], [pH], B, Np, …) convert correctly but, per UCUM §22.1, cannot take part in algebra — not even Cel1. A metric prefix is allowed on a metric special unit and scales the measurement value (§21-22), so 30 dB is 10^(30/10) = 1000 and 25000 mCel is 25 °C.

  • Arbitrary units ([IU], [arb'U], …) have no SI value, so sivalue and 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 why iscommensurable is the right convertibility check, not sidims equality (both [IU] and [arb'U] have all-zero SI dimensions).

  • Plane angle is dimensionless in SI, so rad, deg and sr are dimensionless here and convert to 1 (ucumconvert(ucumparse("1"), 1.0 * ucumparse("deg")) → π/180). Use ucumdims for 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 — L is defined as dm3, which evaluates to 1.0000000000000002e-3 rather than 1e-3. Normalized values are therefore accurate to floating-point tolerance, not bitwise: sivalue(37.0 * ucumparse("mmol/L")) prints 36.99999999999999. Compare with , not ==.

  • Invalid input is always reported through UCUM.UcumParseError / UCUM.UcumSpecialUnitError, so ucumtryparse and isvalid(UcumUnit, s) never throw — safe for untrusted data.

  • Hot paths: ucumparse costs registry lookups per call, so hoist it out of loops and cache parsed units (e.g. a Dict{String,UcumUnit}) when ingesting many records with data-driven codes. For literal codes prefer the ucum"..." macros — they resolve at compile time, which also makes ucum_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), ==) preserve T; conversions (sivalue, ucumconvert, ucumstrip(target, q)) return promote_type(T, Float64), because the registry's conversion factors are Float64. So Int/Float32 promote to Float64 on conversion, BigFloat gains no real precision — and wrapper types that promote over Float64 keep their type: a Measurements.Measurement carries 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)

Running the tests

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 own

The 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.

License

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 — see LICENSE.
  • The bundled data table data/ucum-essence.xml is 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) — see data/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.