Macaw.Data.Unit (macaw_data v0.5.3)

Copy Markdown View Source

Units named by their UCUM codes (spec/03 §1–§7, D-138, D-155).

A unit is named by its code in the Unified Code for Units of Measure — UCUM, https://ucum.org/ucum, Regenstrief Institute — the case-sensitive, unambiguous codes OpenTelemetry's semantic conventions use: s, ms, By, kBy, MiBy, %, Cel, kW.h, {request}/s, [lb_av], mm[Hg]. One code per unit is the point: a source that says "s" and one that says "sec" must not draw two labels, so a code is checked, never guessed at. Every atom and prefix here is UCUM's, with UCUM's definition, checked against the UCUM essence table (ucum-essence.xml, version 2.2, 2024-06-17).

This module reads the subset of UCUM's grammar (§2.1 of the specification, Syntax rules) that measurement data uses — products ., quotients / (left-associative, §2.1.3), signed integer exponents, integer factors and {annotation} blocks (§2.1.5) — over a fixed table of prefixes (da h k M G T d c m u n p Ki Mi Gi Ti) and atoms: time, information, ratios, temperature, SI and customary length, area, volume and mass, speed, power, energy, pressure, angle, charge, current, potential, illuminance, the mole and the logarithmic bel. A symbol is matched whole before it is split into a prefix and an atom (§2.1.4), so min is the minute, and a prefix is refused on an atom UCUM does not call metric (kmin, k[in_i]). Parentheses and UCUM's other atoms are not read; the table grows by proposal.

A code has a dimension and an exact factor to its base (the second, the byte, the meter, the gram, the kelvin, the radian, the coulomb): a rational, times a power of π for a code holding the degree. So values scale between two codes of one dimension — 1,500 ms is 1.5 s — and convert exactly, value × scale + offset (conversion/2). Cel and [degF] are UCUM's affine special units, scales with an offset: they have no factor and convert by their offsets. B and B[SPL] are its logarithmic ones; each has a level dimension of its own here, so a decibel scales to a bel and never to a linear unit. A currency never converts: an exchange rate is data.

A person types symbols, not codes: parse_alias/1 reads them, and refuses an ambiguous one (KB, gal) naming the codes it could mean.

Examples

iex> Macaw.Data.Unit.valid?("kW.h")
true

iex> Macaw.Data.Unit.parse("sec")
{:error, {:unknown_atom, "sec"}}

iex> Macaw.Data.Unit.symbol("By/s")
"B/s"

iex> Macaw.Data.Unit.convert(1500, "ms", "s")
1.5

iex> Macaw.Data.Unit.parse_alias("mph")
{:ok, "[mi_i]/h"}

iex> Macaw.Data.Unit.from_telemetry(:millisecond)
{:ok, "ms"}

Summary

Types

Why parse_alias/1 refuses a symbol.

A UCUM code, such as ms or {request}/s.

One component of a code: a prefixed atom, an integer factor or an annotation standing alone, with its exponent — negated when the component divides.

Why conversion/2 refuses a pair of codes.

A base dimension and its exponent; %{} is dimensionless.

An exact factor: a rational, or {numerator, denominator, k} — the rational times π to the non-zero power k, for a code holding the degree.

How a code's values relate to its base: by a factor, an offset or a logarithm.

An exact rational factor, {numerator, denominator}.

Why a string is not a code, or a symbol not an alias.

Functions

The exact conversion from one code to another (spec/03 §6, D-155): {:ok, {scale, offset}}, so that a value in from is value × scale + offset in to — scale an exact factor ({n, d}, or {n, d, k} for n/d·πᵏ), offset an exact signed rational {n, d} — or why the two do not convert.

A value in from written in to (spec/03 §6): value × scale + offset of conversion/2, as a float — the scale and offset exact, and the arithmetic rounded once for an integer value. Raises ArgumentError naming the reason for a pair conversion/2 refuses.

The ISO 4217 code a code is in when it is a currency alone — {iso4217:USD} is "USD" — else nil: a rate ({iso4217:USD}/h), any other unit, or a string that is not a code (spec/03 §4).

The dimension of a code: each base dimension's exponent, %{} when dimensionless. Raises ArgumentError for a string that is not a code.

The exact factor of a code to its base: {numerator, denominator}, or {numerator, denominator, k} — that rational times πᵏ — for a code holding the degree, or nil for a code holding an affine unit (Cel, [degF]). Raises ArgumentError for a string that is not a code.

A refusal of parse/1, parse_alias/1 or conversion/2 as a line a person reads.

The code of a Telemetry.Metrics unit — its unit atom, or the to of a {from, to} conversion — with no dependency on telemetry_metrics (spec/03 §7). :native has no fixed size until a definition converts it, and an atom not in the table is refused rather than guessed at.

How a code's values relate to its base (spec/03 §2): :affine for a code holding Cel or [degF], :logarithmic for one holding B or B[SPL], else :linear. Raises ArgumentError for a string that is not a code.

An ISO 4217 code's minor units — the decimals an amount in it is written to — or nil for a code with none (XAU). Raises ArgumentError for a code not in the table.

The components of a code, or why it is not one (spec/03 §1).

The code a person means by a symbol they typed (spec/03 §3, D-155): °C, lb, mph, kWh, KiB. The alias table is read first, so a person's B is the byte and not the bel; a symbol the table does not hold is read by parse/1, a code being its own alias. An ambiguous symbol is refused with the codes it could mean, never guessed. Surrounding whitespace is ignored.

The display symbol of a code (spec/03 §5): B for By, µs for us, °C for Cel, kW·h for kW.h, m² for m2; the factor 1 and annotations are dropped, so 1 and {request} are "".

Whether a string is a code parse/1 reads.

Types

alias_reason()

@type alias_reason() ::
  {:ambiguous, String.t(), [{code(), String.t()}, ...]}
  | {:unknown_alias, String.t()}

Why parse_alias/1 refuses a symbol.

code()

@type code() :: String.t()

A UCUM code, such as ms or {request}/s.

component()

@type component() :: %{
  prefix: String.t() | nil,
  atom: String.t() | nil,
  factor: pos_integer() | nil,
  annotation: String.t() | nil,
  exponent: integer()
}

One component of a code: a prefixed atom, an integer factor or an annotation standing alone, with its exponent — negated when the component divides.

conversion_reason()

@type conversion_reason() ::
  {:dimension | :logarithmic | :currency, code(), code()}
  | {:affine_in_term, code()}

Why conversion/2 refuses a pair of codes.

dimension()

@type dimension() :: %{
  optional(
    :time
    | :information
    | :length
    | :mass
    | :temperature
    | :angle
    | :charge
    | :luminous_intensity
    | :level
    | :sound_pressure_level
    | {:currency, String.t()}
  ) => integer()
}

A base dimension and its exponent; %{} is dimensionless.

exact()

@type exact() :: ratio() | {pos_integer(), pos_integer(), integer()}

An exact factor: a rational, or {numerator, denominator, k} — the rational times π to the non-zero power k, for a code holding the degree.

kind()

@type kind() :: :linear | :affine | :logarithmic

How a code's values relate to its base: by a factor, an offset or a logarithm.

ratio()

@type ratio() :: {pos_integer(), pos_integer()}

An exact rational factor, {numerator, denominator}.

reason()

@type reason() ::
  :empty
  | {:syntax, String.t()}
  | {:unknown_atom, String.t()}
  | {:not_metric, String.t(), String.t()}
  | {:unknown_currency, String.t()}

Why a string is not a code, or a symbol not an alias.

Functions

conversion(from, to)

@spec conversion(code(), code()) ::
  {:ok, {exact(), {integer(), pos_integer()}}}
  | {:error, reason() | conversion_reason()}

The exact conversion from one code to another (spec/03 §6, D-155): {:ok, {scale, offset}}, so that a value in from is value × scale + offset in to — scale an exact factor ({n, d}, or {n, d, k} for n/d·πᵏ), offset an exact signed rational {n, d} — or why the two do not convert.

Two codes of one dimension convert by their factors. The affine units Cel and [degF] convert with their offsets, standing alone. A logarithmic unit converts only to its own level. A currency never converts. A refusal is {:dimension, from, to}, {:logarithmic, from, to}, {:currency, from, to}, {:affine_in_term, code}, or the reason parse/1 refuses a code.

Examples

iex> Macaw.Data.Unit.conversion("Cel", "[degF]")
{:ok, {{9, 5}, {32, 1}}}

iex> Macaw.Data.Unit.conversion("[lb_av]", "kg")
{:ok, {{45359237, 100000000}, {0, 1}}}

iex> Macaw.Data.Unit.conversion("dB[SPL]", "Pa")
{:error, {:logarithmic, "dB[SPL]", "Pa"}}

convert(value, from, to)

@spec convert(number(), code(), code()) :: float()

A value in from written in to (spec/03 §6): value × scale + offset of conversion/2, as a float — the scale and offset exact, and the arithmetic rounded once for an integer value. Raises ArgumentError naming the reason for a pair conversion/2 refuses.

Examples

iex> Macaw.Data.Unit.convert(2_500_000, "By", "MBy")
2.5

iex> Macaw.Data.Unit.convert(100, "Cel", "[degF]")
212.0

iex> Macaw.Data.Unit.convert(30, "dB", "B")
3.0

currency(code)

@spec currency(term()) :: String.t() | nil

The ISO 4217 code a code is in when it is a currency alone — {iso4217:USD} is "USD" — else nil: a rate ({iso4217:USD}/h), any other unit, or a string that is not a code (spec/03 §4).

Examples

iex> Macaw.Data.Unit.currency("{iso4217:EUR}")
"EUR"

iex> Macaw.Data.Unit.currency("{iso4217:EUR}/kW/h")
nil

dimension(code)

@spec dimension(code()) :: dimension()

The dimension of a code: each base dimension's exponent, %{} when dimensionless. Raises ArgumentError for a string that is not a code.

Examples

iex> Macaw.Data.Unit.dimension("[psi]")
%{mass: 1, length: -1, time: -2}

iex> Macaw.Data.Unit.dimension("dB[SPL]")
%{sound_pressure_level: 1}

factor(code)

@spec factor(code()) :: exact() | nil

The exact factor of a code to its base: {numerator, denominator}, or {numerator, denominator, k} — that rational times πᵏ — for a code holding the degree, or nil for a code holding an affine unit (Cel, [degF]). Raises ArgumentError for a string that is not a code.

Examples

iex> Macaw.Data.Unit.factor("[ft_i]")
{381, 1250}

iex> Macaw.Data.Unit.factor("deg")
{1, 180, 1}

format_error(arg1)

@spec format_error(reason() | alias_reason() | conversion_reason()) :: String.t()

A refusal of parse/1, parse_alias/1 or conversion/2 as a line a person reads.

Examples

iex> Macaw.Data.Unit.format_error({:ambiguous, "gal", [{"[gal_us]", "US gallon"}, {"[gal_br]", "imperial gallon"}]})
~s|"gal" is ambiguous: [gal_us] (US gallon) or [gal_br] (imperial gallon)|

from_telemetry(unit)

@spec from_telemetry(atom() | {atom(), atom()}) ::
  {:ok, code()} | {:error, :native | {:unknown, term()}}

The code of a Telemetry.Metrics unit — its unit atom, or the to of a {from, to} conversion — with no dependency on telemetry_metrics (spec/03 §7). :native has no fixed size until a definition converts it, and an atom not in the table is refused rather than guessed at.

Examples

iex> Macaw.Data.Unit.from_telemetry({:native, :microsecond})
{:ok, "us"}

iex> Macaw.Data.Unit.from_telemetry(:native)
{:error, :native}

kind(code)

@spec kind(code()) :: kind()

How a code's values relate to its base (spec/03 §2): :affine for a code holding Cel or [degF], :logarithmic for one holding B or B[SPL], else :linear. Raises ArgumentError for a string that is not a code.

Examples

iex> Macaw.Data.Unit.kind("dB")
:logarithmic

minor_units(iso)

@spec minor_units(String.t()) :: non_neg_integer() | nil

An ISO 4217 code's minor units — the decimals an amount in it is written to — or nil for a code with none (XAU). Raises ArgumentError for a code not in the table.

Examples

iex> Enum.map(~w(USD JPY BHD XAU), &Macaw.Data.Unit.minor_units/1)
[2, 0, 3, nil]

parse(code)

@spec parse(String.t()) :: {:ok, [component(), ...]} | {:error, reason()}

The components of a code, or why it is not one (spec/03 §1).

Examples

iex> Macaw.Data.Unit.parse("km/h")
{:ok,
 [
   %{prefix: "k", atom: "m", factor: nil, annotation: nil, exponent: 1},
   %{prefix: nil, atom: "h", factor: nil, annotation: nil, exponent: -1}
 ]}

iex> Macaw.Data.Unit.parse("kmin")
{:error, {:not_metric, "k", "min"}}

parse_alias(symbol)

@spec parse_alias(String.t()) :: {:ok, code()} | {:error, alias_reason()}

The code a person means by a symbol they typed (spec/03 §3, D-155): °C, lb, mph, kWh, KiB. The alias table is read first, so a person's B is the byte and not the bel; a symbol the table does not hold is read by parse/1, a code being its own alias. An ambiguous symbol is refused with the codes it could mean, never guessed. Surrounding whitespace is ignored.

Examples

iex> Macaw.Data.Unit.parse_alias("°F")
{:ok, "[degF]"}

iex> Macaw.Data.Unit.parse_alias("KB")
{:error, {:ambiguous, "KB", [{"kBy", "1,000 bytes"}, {"KiBy", "1,024 bytes"}]}}

iex> Macaw.Data.Unit.parse_alias("kPa")
{:ok, "kPa"}

symbol(code, opts \\ [])

@spec symbol(code(), keyword()) :: String.t()

The display symbol of a code (spec/03 §5): B for By, µs for us, °C for Cel, kW·h for kW.h, m² for m2; the factor 1 and annotations are dropped, so 1 and {request} are "".

Options

  • :annotations - write an annotation standing alone as its text ({request}/s → request/s), default false

Examples

iex> Macaw.Data.Unit.symbol("us")
"µs"

iex> Macaw.Data.Unit.symbol("{request}/s", annotations: true)
"request/s"

valid?(code)

@spec valid?(term()) :: boolean()

Whether a string is a code parse/1 reads.