BOVNAR.bvnr

Bovnar — Unit & Currency Reference

Bovnar (BVNR) v1.1 documentation · Also available as Markdown and PDF.

Spec version: 1.1 Status: Normative — the unit and currency registry the parser validates against Scope: Physical units, currency codes, prefix rules, disambiguation, C/Python APIs, and validation.


Table of Contents

  1. Overview
  2. Syntax — Annotation as a Type Parameter
  3. Physical Base Units
  4. Prefixes
  5. Unit Notation Grammar
  6. Exponents
  7. The no_unit Keyword
  8. Constraints and Limits
  9. Currency Codes
  10. Symbol Disambiguation
  11. C Data Model
  12. C API Functions
  13. Integration with the Parser Event Stream
  14. Validation Errors
  15. Annotated Examples

1. Overview

The Bovnar quantity annotation system is an optional, per-value annotation that attaches a physical unit or currency denomination to any numeric field. It is part of the type annotation (<family:width,_base,unit>) and applies to the uint, sint, float, float_fix, and float_dec type families.

Two distinct namespaces share the annotation slot:

Both namespaces are syntactically unified: the same grammar, the same ~ prefix separator, the same compound-unit operators (·, *, /), and the same value_unit_t data model apply to both. They are separated purely by a token-classification rule described in §9.1 and §10.

Annotations are descriptive, not prescriptive: the validator checks form and type, and never rejects a document because its units do not add up. The library does provide dimensional analysis and unit conversion as an explicit, opt-in service — bvn_units_compatible, bvn_unit_convert_factor, bvn_unit_convert_value and the exact-rational bvn_unit_convert_rational (§12.4), plus the reader's want_unit hook, which converts at read time (§1.10 of the read/write API). None of that runs unless you ask for it. Exchange-rate arithmetic is the one thing the library genuinely does not do: currencies carry no conversion table, and a cross-currency conversion is always refused rather than guessed (§9.6).

1.1 Design Principles


2. Syntax — Annotation as a Type Parameter

The unit or currency code occupies the third positional parameter class of a type annotation, after the optional bit-width and optional base. Parameter classes are identified by their content, not by position:

type-spec       = param-type [ ":" type-param-list ]
type-param-list = type-param { "," type-param }
type-param      = width-param   (* plain decimal integer, e.g. 32    *)
                | base-param    (* "_" + decimal integer,  e.g. _16  *)
                | unit-param    (* everything else,        e.g. m/s  *)

A unit may also be written in a foreign notation. A name: namespace hands the parameter to a unit profile, which translates it into the same value_unit_t this document describes — so <float_dec:64,ucum:mm[Hg]> and <float_dec:64,mmHg> are the same unit to every part of the library. Seven namespaces are defined — ucum, unece, qudt, qudt-qk, udunits, om and cf; everything in this reference applies to the result unchanged. The notation is under implementation — it is not part of a published specification, and a document must opt in with a #!bovnar 1.2 directive that this build does not itself advertise. A native unit is unaffected in every version. See Unit Profiles.

2.1 Parameter Ordering Flexibility

.val = <uint:32,_10,no_unit> 42;
#      <uint:_10,no_unit,32>        — identical (parameter order is free)
#      <uint:no_unit,_10,32>        — identical

2.2 Inline Unit Suffix

A unit may be written directly after a scalar value literal, between the value and the terminating ;:

.distance  = 1500 m;            # inline physical unit
.speed     = 9.81 m/s;          # compound inline unit
.price     = 19.99 $USD;        # inline currency (mandatory $ sigil)
.gold_rate = 2351.40 $USD/oz_t; # inline compound currency/unit
.ratio     = 3.14 no_unit;      # explicit dimensionless

The inline unit uses the same character set and same semantic parser (bvn_parse_unit) as the type-annotation unit parameter. It is terminated by ASCII whitespace, # (comment), or ;.

A native unit contains no whitespace, in either position. Inline, whitespace ends the token, so 1.0 k g is a value with a stray token after it (error_unexpected_input_byte). In a type annotation, whitespace is legal only beside a separator — the family :, a , between parameters, the closing > — and inside a native unit parameter it is error_type_param_whitespace (spec §5.3). <float:64,k g> used to be accepted as k~g, which made it the one place in the format where a wrong unit was produced silently instead of refused.

A parameter carrying a profile namespace is the exception: there whitespace is kept verbatim and the vocabulary decides what it means, because UDUNITS multiplies with a space and udunits:kg m-2 s-1 is the commonest spelling of a flux in CF metadata. That is a property of the foreign notation, not of the native one — see doc/11 §13.2.

Constraints

Situation Result
No annotation unit; inline unit present Inline unit becomes the effective unit
Annotation has no unit; inline unit present Inline unit becomes the effective unit
Annotation unit present; no inline unit Annotation unit is the effective unit
Annotation unit equals inline unit Valid; the common unit is used
Annotation unit differs from inline unit error_unit_mismatch
Inline unit inside an array element error_unexpected_input_byte

When both are present, equality is checked after parsing via bvn_unit_equal, a structural comparison of the parsed value_unit_t values: the two units must have the same number of components and the same set of components (matching base, exponent, and prefix). The comparison is order-insensitive — unit multiplication is commutative, so components are matched as multisets and reordered spellings such as N·m and m·N (or m·s⁻¹ and m/s) compare as equal. (It is not a raw memcmp, which would wrongly reject reordered components.)

.v = <float:64,m/s> 9.81 m·s⁻¹;   # OK: both parse to m/s
.v = <float:64,m> 1.0 s;           # ERROR: error_unit_mismatch

2.3 Applicable Type Families

Type family Unit / currency parameter
uint Supported
sint Supported
float Supported (binary floating-point; discouraged for monetary amounts — see §9.7)
float_fix Supported (wrong for monetary values — see §9.7)
float_dec Supported; recommended for monetary amounts
utf8 Parameterless: a unit (or any other parameter) is error_illegal_value_type

3. Physical Base Units

Bovnar supports 264 physical base units. Currency codes are a separate namespace and are covered in §9.

Reading this section: The Symbol column gives the canonical serialized form. Long forms are accepted on input but never produced on output. Enum value is the value_base_unit_t constant used in the C API.

3.1 SI Base Units

Symbol Long forms Name Enum value Notes
s sec, second, seconds second bu_second SI base unit of time
m meter, metre, meters, metres meter bu_meter SI base unit of length
g gram, grams gram bu_gram SI base unit of mass is kg; g carries the prefix
A amp, amps, ampere, amperes ampere bu_ampere SI base unit of electric current
K kelvin, kelvins kelvin bu_kelvin SI base unit of thermodynamic temperature
mol mole, moles mole bu_mol SI base unit of amount of substance
cd candela, candelas candela bu_candela SI base unit of luminous intensity

Note on the kilogram: Bovnar uses g (gram) as the base unit symbol so that the k~ (kilo) prefix can be attached explicitly: k~g = kilogram. This is consistent with how the SI formally defines the kilogram as a prefixed gram.

3.2 Named SI-Derived Units

Symbol Long forms Name Enum value SI Definition
Hz hertz hertz bu_hertz s⁻¹
N newton, newtons newton bu_newton kg·m·s⁻²
Pa pascal, pascals pascal bu_pascal kg·m⁻¹·s⁻²
J joule, joules joule bu_joule kg·m²·s⁻²
W watt, watts watt bu_watt kg·m²·s⁻³
V volt, volts volt bu_volt kg·m²·A⁻¹·s⁻³
ohm, ohms, Ohm ohm bu_ohm kg·m²·A⁻²·s⁻³ — U+2126 OHM SIGN, UTF-8: 0xE2 0x84 0xA6; U+03A9 (Greek capital omega) also accepted on input, canonical output is always U+2126
F farad, farads farad bu_farad kg⁻¹·m⁻²·A²·s⁴
C coulomb, coulombs coulomb bu_coulomb A·s
S siemens, mho, mhos, siemens bu_siemens kg⁻¹·m⁻²·A²·s³
Wb weber, webers weber bu_weber kg·m²·A⁻¹·s⁻²
T tesla, teslas tesla bu_tesla kg·A⁻¹·s⁻²
H henry, henrys, henries henry bu_henry kg·m²·A⁻²·s⁻²
°C degC, degrC, degreeC, degreesC, celsius degree Celsius bu_celsius K = °C + 273.15 (affine); BIPM Table 4 entry 14
lm lumen, lumens lumen bu_lumen cd·sr
lx lux lux bu_lux cd·sr·m⁻²
Bq becquerel, becquerels becquerel bu_becquerel s⁻¹
Gy gray, grays gray bu_gray m²·s⁻²
Sv sievert, sieverts sievert bu_sievert m²·s⁻²
kat katal, katals katal bu_katal mol·s⁻¹
rad radian, radians radian bu_radian dimensionless (plane angle; m/m)
sr steradian, steradians steradian bu_steradian dimensionless (solid angle; m²/m²)

Photometry: the steradian is carried, not dropped. lm, lx and ph are defined through the steradian, so they carry its quantity kind (§3.12 and §11 of Unit Ambiguities); cd and sb do not. The SI dimension vector cannot tell them apart — every photometric unit reduces to candela in base dimensions — so without the kind the library both refused lm ↔ cd·sr and converted lm ↔ cd at factor 1, which is the same claim with the sr silently dropped. What holds now:

Converts Refused
lmcd·sr, lxlm/m²cd·sr/m², phlx, sbcd/m² lmcd (flux vs intensity), lxcd/m² and phsb (illuminance vs luminance)

3.3 Non-SI Units Accepted for Use with SI

Symbol Long forms Name Enum value Notes
L, l liter, litre, liters, litres liter bu_liter 10⁻³ m³
min minute, minutes minute bu_minute 60 s
h hour, hours hour bu_hour 3600 s
d day, days day bu_day 86400 s
wk week, weeks week bu_week 604800 s
yr year, years year bu_year 31557600 s (Julian year)
mo month, months month (Julian) bu_month 2629800 s (= 365.25 d / 12)
fn fortnight, fortnights fortnight bu_fortnight 1209600 s (= 14 d)
°, deg degr, degree, degrees degree (angle) bu_degree π/180 rad — U+00B0
t tonne tonne bu_tonne 10³ kg
bar bar bu_bar 10⁵ Pa
eV electronvolt electronvolt bu_electronvolt 1.602176634×10⁻¹⁹ J
Da dalton, amu, u dalton bu_dalton 1.66053906892×10⁻²⁷ kg (CODATA 2022)
au astronomical unit bu_astronomical_unit 1.495978707×10¹¹ m
ha hectare hectare bu_hectare 10⁴ m²

3.4 Imperial and US Customary Units

Length

Symbol Long forms Name Enum value Factor
in inch, inches inch bu_inch 0.0254 m (exact)
ft foot, feet foot bu_foot 0.3048 m (exact)
yd yard, yards yard bu_yard 0.9144 m (exact)
mi mile, miles statute mile bu_mile 1609.344 m (exact)
nmi nautical_mile, nautical_miles nautical mile bu_nautical_mile 1852 m (exact)
(U+212B) angstrom, angstroms, Å (U+00C5), Å (U+0041 U+030A) ångström bu_angstrom 10⁻¹⁰ m
ly light_year, light_years light-year bu_light_year 9.4607304725808×10¹⁵ m
pc parsec, parsecs parsec bu_parsec 3.085677581491367×10¹⁶ m
fur furlong, furlongs furlong bu_furlong 201.168 m (exact)
fath fathom, fathoms fathom bu_fathom 1.8288 m (exact)
thou thou, mil, mils thou bu_thou 25.4×10⁻⁶ m (exact)
ch chain, chains chain (Gunter's) bu_chain 20.1168 m (exact)
rd rod, rods rod (pole, perch) bu_rod 5.0292 m (exact)

Thou vs mil: Both thou and mil are accepted for 1/1000 of an inch (25.4 µm). The canonical output form is thou. Note that mil does not mean milliradian; milliradians are written m~rad.

Mass

Symbol Long forms Name Enum value Factor
lb lbs, pound, pounds pound (avoirdupois) bu_pound 0.45359237 kg (exact)
oz ounce, ounces ounce (avoirdupois) bu_ounce 0.028349523125 kg (exact)
gr grain, grains grain bu_grain 6.479891×10⁻⁵ kg (exact)
st stone, stones stone bu_stone 6.35029318 kg (exact)
tn_sh short_ton, short_tons short ton (US ton) bu_short_ton 907.18474 kg (exact)
tn_l long_ton, long_tons long ton (UK ton) bu_long_ton 1016.0469088 kg (exact)
oz_t troy_ounce, troy_ounces troy ounce bu_troy_ounce 0.0311034768 kg (exact)
ct carat, carats metric carat bu_carat 2×10⁻⁴ kg (exact)
slug slugs slug bu_slug 14.593902937206364 kg (= lb·gn/ft)
dr dram, drams dram (avoirdupois) bu_dram 1.7718451953125×10⁻³ kg (exact)
dwt pennyweight, pennyweights pennyweight (troy) bu_pennyweight 1.55517384×10⁻³ kg (exact)
lb_t troy_pound, troy_pounds, apothecary_pound troy pound (= apothecary pound) bu_troy_pound 0.3732417216 kg (exact, = 12 oz_t)
dr_ap apothecary_dram, apothecary_drams dram (apothecary) bu_apothecary_dram 3.8879346×10⁻³ kg (exact, = 3 sc)
cwt_l long_hundredweight, long_hundredweights hundredweight (long/imperial) bu_long_hundredweight 50.80234544 kg (exact, = 112 lb)

The apothecary dram is 2.2× the avoirdupois dr and the two are a classic near miss; the apothecary POUND, by contrast, is the troy pound (twelve apothecary ounces, and an apothecary ounce is a troy ounce), so both spellings are one unit. The SHORT hundredweight is exactly 100 lb, which the prefix mechanism already spells h~lb, so it has no unit of its own.

Temperature

Symbol Long forms Name Enum value Conversion
°C, degC degrC, degreeC, degreesC, celsius degree Celsius bu_celsius K = °C + 273.15 (affine) — also §3.2 (BIPM named derived unit)
°F, degF degrF, degreeF, degreesF, fahrenheit degree Fahrenheit bu_fahrenheit K = (°F + 459.67) × 5/9 (affine)
°Ra, degRa degrRa, degreeRa, degreesRa, rankine degree Rankine bu_rankine K = °Ra × 5/9 (linear)
°De, degDe degrDe, degreeDe, degreesDe, delisle degree Delisle bu_delisle K = 373.15 − °De × 2/3 (affine)
°N, degN degrN, degreeN, degreesN, newton_temperature degree Newton bu_newton_temp K = °N × 100/33 + 273.15 (affine)
°Re, degRe degrRe, degreeRe, degreesRe, reaumur degree Réaumur bu_reaumur K = °Re × 5/4 + 273.15 (affine)
°Ro, degRo degrRo, degreeRo, degreesRo, romer degree Rømer bu_romer K = (°Ro − 7.5) × 40/21 + 273.15 (affine)

Kelvin (K) is the SI base unit (§3.1). Ra not RR is reserved for the röntgen (bu_roentgen).

Temperature Differences

Every unit above is a scale: 25 °C is 298.15 K, and Bovnar converts it that way. A difference of 25 degrees is 25 K, and these are the units that say so.

Symbol Long forms Name Enum value Conversion
ΔK, delta_K deltaK, delta_kelvin, deltakelvin, Δ°C, delta_degC, deltadegC, delta_celsius, deltacelsius kelvin interval bu_delta_kelvin 1 K exactly (linear). Δ°C is this unit
Δ°F, delta_degF deltadegF, delta_fahrenheit, deltafahrenheit, Δ°Ra, delta_degRa, deltadegRa, delta_rankine, deltarankine Fahrenheit interval bu_delta_fahrenheit 5/9 K exactly (linear). Δ°Ra is this unit
Δ°De, delta_degDe deltadegDe, delta_delisle, deltadelisle Delisle interval bu_delta_delisle −2/3 K exactly (linear)
Δ°N, delta_degN deltadegN, delta_newton_temperature Newton interval bu_delta_newton_temp 100/33 K exactly (linear)
Δ°Re, delta_degRe deltadegRe, delta_reaumur, deltareaumur Réaumur interval bu_delta_reaumur 5/4 K exactly (linear)
Δ°Ro, delta_degRo deltadegRo, delta_romer, deltaromer Rømer interval bu_delta_romer 40/21 K exactly (linear)

Six rows, eight spellings. The degree Celsius interval is the kelvin (SI Brochure 9th ed. §2.3.1) and the Rankine degree is the Fahrenheit degree, so Δ°C and Δ°Ra are aliases rather than units of their own — two units that had to compare equal and convert by exactly 1 would be a distinction with no content.

They are ratio scales. .affine = false, .offset = 0.0. That is what lets them compose where the scales cannot: Δ°F/k~m is a lapse rate and Δ°Re^-1 an expansion coefficient, while °F/k~m and °Re^-1 have no SI meaning at all (§9.4 — an affine scale is meaningful only alone at exponent 1).

They carry their own quantity kind, so:

ΔK   → K       error_unit_mismatch      the whole point
°C   → Δ°C     error_unit_mismatch      a reading is not an interval, and only
                                        the author can decide which one it was
Δ°C  → ΔK      factor 1                 the same unit
Δ°F  → ΔK      factor 5/9               exact; lossless only for multiples of 9
Δ°De → ΔK      factor −2/3              Delisle runs backwards
--si on 25 Δ°C                          25 ΔK, not 298.15 K
[<float:64,K> 1.0, <float:64,ΔK> 2.0]   error_array_element_type_mismatch

A bare array is homogeneous in its unit (spec §7.4), so the last line needs no rule of its own.

Inside a compound the distinction does not arise, and is not made. W/(m²·ΔK) and W/(m²·K) are the same unit, as are ΔK/k~m and K/k~m, and ΔK^-1 and K^-1. The reason is the one above: an affine scale cannot appear in a compound at all, so a K there was already an interval and there is nothing to separate. The quantity kind is therefore significant only for a lone unit at exponent 1 — precisely where the affine offset was the hazard. Every U-value written before these units existed keeps its meaning.

Δ is U+0394 (0xCE 0x94); every unit has ASCII spellings, formed as delta_ plus the scale's own short form. There is deliberately no bare delta_C or delta_F: those read as a delta coulomb and a delta farad, and the scales themselves do not alias bare C or F either. delta_K is unambiguous because K is the kelvin and nothing else.

3.5 Pressure Units

Symbol Long forms Name Enum value Factor
atm atmosphere, atmospheres standard atmosphere bu_atmosphere 101325 Pa (exact)
at atmosphere_technical atmosphere technical bu_atmosphere_technical 98066.5 Pa (= 1 kgf/cm²)
mmHg millimetre of mercury bu_mmhg 133.322387415 Pa
Torr torr torr bu_torr 101325/760 Pa
psi pound-force per square inch bu_psi 6894.757293168362 Pa
inHg inch_hg, inch_mercury inch of mercury bu_inch_hg 3386.388640341 Pa (= 25.4 mmHg exactly)
mH2O metre_water, meter_water metre of water column bu_meter_water 9806.65 Pa (exact, conventional)

mH2O takes prefixes, so the spellings people actually write fall out of it: c~mH2O (or the compact cmH2O) is the centimetre of water a ventilator is set in, and m~mH2O the millimetre. The value is the conventional column — water at 1000 kg/m³ under standard gravity — the same kind of convention that fixes mmHg at exactly 133.322387415 Pa. A column measured at a stated temperature (UDUNITS' water_4C, QUDT's IN_H2O) is a different unit and is refused, not rounded onto this one.

3.6 Energy Units

Symbol Long forms Name Enum value Factor
cal calorie, calories thermochemical calorie bu_calorie 4.184 J (exact)
Btu btu International Table BTU bu_btu 1055.05585262 J
erg ergs erg bu_erg 10⁻⁷ J (exact)
thm therm, therms US therm bu_therm 1.05480400×10⁸ J (exact)
ft_lb foot_pound, foot_pounds foot-pound bu_foot_pound 1.3558179483314003 J (= lbf·ft)
cal_IT calorie_IT International Table calorie bu_calorie_it 4.1868 J (exact)
Btu_th BTU_th, btu_th thermochemical BTU bu_btu_th 23722880951/22500000 J ≈ 1054.35026449 J

Two calories and two BTUs, and the pairs cross over. cal is the THERMOCHEMICAL calorie and Btu the INTERNATIONAL TABLE BTU, which is not an inconsistency but the two vocabularies' own defaults: UCUM's unqualified cal is thermochemical and its unqualified [Btu] is too, while UDUNITS' unqualified calorie is the IT one. Each pair is 0.067 % apart — dimensionally identical and numerically wrong if confused — so both members of both pairs are carried and every profile code lands on the one its own vocabulary says it means. Both BTUs are one pound of water raised one degree Fahrenheit, i.e. the corresponding calorie × 453.59237 g/lb × 5/9; that terminates for the IT calorie and does not for the thermochemical one, which is why Btu_th states an exact rational rather than a decimal.

BTU alias note: BTU (all uppercase, three characters) is a valid alias for bu_btu. Because currencies require the $ sigil, the bare token BTU is a physical-unit lookup and resolves to bu_btu; Btu and btu are also accepted. See §10.2 for the complete look-alike table.

3.7 Power Units

Symbol Long forms Name Enum value Factor
hp horsepower mechanical horsepower bu_horsepower 745.6998715822702 W (= 550 ft_lb/s)
PS CV, metric_horsepower metric horsepower bu_metric_horsepower 735.49875 W (exact)

3.8 Force Units

Symbol Long forms Name Enum value Factor
lbf pound_force pound-force bu_pound_force 4.4482216152605 N
dyn dyne, dynes dyne bu_dyne 10⁻⁵ N (exact)
kip kips kip (kilopound-force) bu_kip 4448.2216152605 N
kgf kilogram_force kilogram-force bu_kilogram_force 9.80665 N (exact)

3.9 Speed and Rotational Frequency Units

Symbol Long forms Name Enum value Factor
kn knot, knots knot bu_knot 1852/3600 m/s
rpm revolutions per minute bu_rpm 1/60 s⁻¹

kn has dimension m·s⁻¹. rpm has dimension s⁻¹ (rotational frequency, not linear speed); it is grouped here by convention.

3.10 Volume Units

US Liquid Volume

Symbol Long forms Name Enum value Factor
gal gallon, gallons US liquid gallon bu_gallon 3.785411784×10⁻³ m³ (exact)
qt quart, quarts US liquid quart bu_quart 9.46352946×10⁻⁴ m³
pt pint, pints US liquid pint bu_pint 4.73176473×10⁻⁴ m³
cup cups US cup bu_cup 2.365882365×10⁻⁴ m³
gi gill, gills US gill bu_gill 1.18294118250×10⁻⁴ m³
fl_oz fluid_ounce, fluid_ounces US fluid ounce bu_fluid_ounce 2.95735295625×10⁻⁵ m³
tbsp tablespoon, tablespoons US tablespoon bu_tablespoon 1.478676478125×10⁻⁵ m³ (exact)
tsp teaspoon, teaspoons US teaspoon bu_teaspoon 4.92892159375×10⁻⁶ m³
bbl barrel, barrels petroleum barrel bu_barrel 0.158987294928 m³

cup disambiguation: The canonical symbol for the US cup volume unit is cup (all lowercase). The Cuban Peso (ISO 4217 code 192) is written with the mandatory currency sigil as $CUP; the bare uppercase token CUP is error_unit_illegal. The two cannot be confused. See §10.3 for full details.

UK Imperial Volume

Symbol Long forms Name Enum value Factor
gal_uk gallon_uk, gallons_uk imperial gallon bu_gallon_uk 4.54609×10⁻³ m³ (exact)
qt_uk quart_uk, quarts_uk imperial quart bu_quart_uk 1136.5225×10⁻⁶ m³
pt_uk pint_uk, pints_uk imperial pint bu_pint_uk 568.26125×10⁻⁶ m³
gi_uk gill_uk, gills_uk imperial gill bu_gill_uk 1.420653125×10⁻⁴ m³ (exact)
fl_oz_uk fluid_ounce_uk, fluid_ounces_uk imperial fluid ounce bu_fluid_ounce_uk 28.4130625×10⁻⁶ m³

3.11 Area Units

Symbol Long forms Name Enum value Factor
ac acre, acres acre bu_acre 4046.8564224 m² (exact)
barn barns barn bu_barn 10⁻²⁸ m² (exact)

3.12 Angle Units

Symbol Long forms Name Enum value Factor
arcmin arcminute, arcminutes arcminute bu_arcminute π/10800 rad
arcsec arcsecond, arcseconds arcsecond bu_arcsecond π/648000 rad
grad gradian, gradians, gon gradian bu_grad π/200 rad
rev turn, revolution, revolutions, turns revolution bu_revolution 2π rad

Angle is one shared quantity kind, so °rad works and only the factor is irrational. What the kind stops is an angle drifting into a plain count: rev/min is an angular rate and rpm a cycle rate, and they differ by exactly 2π. sr carries the kind at weight 2, because a steradian is rad² — and so do the photometric units built on it (lm = cd·sr, lx = lm/m², ph = lm/cm²; see the note under §3.2).

3.13 CGS Units

Symbol Long forms Name Enum value SI equivalent
P poise, poises poise (dynamic viscosity) bu_poise 0.1 Pa·s
St stokes, stoke stokes (kinematic viscosity) bu_stokes 10⁻⁴ m²·s⁻¹
G gauss gauss (magnetic flux density) bu_gauss 10⁻⁴ T
Mx maxwell, maxwells maxwell (magnetic flux) bu_maxwell 10⁻⁸ Wb
Oe oersted, oersteds oersted (magnetic field strength) bu_oersted 1000/(4π) A/m
sb stilb, stilbs stilb (luminance) bu_stilb 10⁴ cd/m²
ph phot, phots phot (illuminance) bu_phot 10⁴ lx
Gal galileo, galileos galileo (acceleration) bu_galileo 10⁻² m/s²

sb and ph are not the same quantity, though the SI dimension vector says they are: both reduce to cd·m⁻² and both are 10⁴ of their SI counterpart. The stilb is a luminance (cd/cm²), the phot an illuminance (lm/cm²), and a lumen is a candela-steradian. Bovnar carries that steradian as a quantity kind, so ph converts with lx and sb with cd/m², and neither converts with the other — see §3.12 and the note under §3.2.

3.14 Radiation Units

Symbol Long forms Name Enum value SI equivalent
Ci curie, curies curie (radioactivity) bu_curie 3.7×10¹⁰ Bq
R roentgen, roentgens röntgen (radiation exposure) bu_roentgen 2.58×10⁻⁴ C/kg
rem rems rem (dose equivalent) bu_rem 10⁻² Sv

3.15 Logarithmic Units

Symbol Long forms Name Enum value Notes
Np neper, nepers neper bu_neper dimensionless logarithmic ratio
dB decibel, decibels decibel bu_decibel dimensionless logarithmic ratio

Neper and decibel do not convert into each other. bvn_units_compatible reports false for the pair, and every conversion entry point refuses it.

A logarithmic level is not a linear quantity: 20 dB is a ratio of 100, not twice the ratio 10 dB names. Relating two logarithmic scales is a change of base, which the multiply-by-a-factor model every conversion entry point is built on cannot express — so bovnar gives Np, dB and pH a quantity kind each and refuses every pair, rather than applying a factor that is only ever right by coincidence.

ISO 80000-3 does state 1 Np = 8.685889… dB, for a level referred consistently to the same kind of quantity. Two things stop that from being a conversion this library can perform. It is a relation between levels, so it does not compose with the exponents and prefixes a value_unit_t carries; and dB is written in practice against both the power convention (10·log₁₀) and the field convention (20·log₁₀), with nothing in the annotation recording which — so the number a reader would need is not determined by the unit alone.

The same reasoning keeps a level out of a plain number: dB → dimensionless is refused too.

3.16 Electrical Power Units

Symbol Long forms Name Enum value Notes
var vars var (volt-ampere reactive) bu_var reactive power; same SI dimension as W
VA volt_ampere, volt_amperes volt-ampere bu_volt_ampere apparent power; same SI dimension as W

Watt, var, and VA: All three carry the same SI dimensional signature (kg·m²·s⁻³). bvn_units_compatible returns true when comparing them. They are kept as distinct base units because they represent distinct AC power interpretations.

3.17 Digital Units

Symbol Long forms Name Enum value
b bit, bits bit bu_bit
B byte, bytes, Byte, Bytes byte bu_byte

Bit and byte do not convert into each other. They are separate quantity kinds, so bB is refused rather than divided by eight. A byte is eight bits on the wire, but a value annotated in bytes and one annotated in bits are usually not the same measurement — a link rate quoted in M~b/s and a file size in M~B — and silently trading one for the other is the class of error this format exists to prevent. Convert explicitly in the application if that is genuinely what you mean.

Prefixes still work within each: Ki~BB is 1024, M~bk~b is 1000. Both units take IEC binary prefixes as well as SI ones, and SI prefixes on them are restricted to kilo and above (§6.3).

3.18 Textile Linear Density

Symbol Long forms Name Enum value Factor
tex tex bu_tex 1×10⁻⁶ kg/m (ISO 1144)
den denier, deniers denier bu_denier 1/9000000 kg/m

3.19 US Apothecary / Dry Volume

Symbol Long forms Name Enum value Factor
fl_dr fluid_dram, fluid_drams US fluid dram bu_fluid_dram 3.6966911953125×10⁻⁶ m³
minim minims US minim bu_minim 6.16115199218750×10⁻⁸ m³
pk peck, pecks US dry peck bu_peck 8.80976754172×10⁻³ m³
bsh bushel, bushels US bushel bu_bushel 3.523907016688×10⁻² m³

minim (not min, which is the minute) avoids ambiguity.

3.20 Old German Units

Old German units fall into metric-compatible units (still in use in DACH regions) and historical pre-metric Prussian units. The prefix pr is reserved for Prussian symbols. No German unit accepts any non-trivial SI or IEC prefix; bvn_prefix_unit_valid rejects any non-si_none/iec_none prefix for every unit from bu_pfund through bu_scheffel.

Metric-Compatible German Units — Mass

Symbol Long forms Name Enum value Factor
Pfd pfund, pfunds Pfund bu_pfund 0.5 kg (exact)
Ztr zentner Zentner bu_zentner 50 kg (exact)
dz doppelzentner Doppelzentner bu_doppelzentner 100 kg (exact)
lot lots Lot bu_lot 15.625×10⁻³ kg (exact)

Historical German Units — Length (Prussian)

Symbol Long forms Name Enum value Factor
prln prussian_line, linie Prussian line bu_prussian_line 313853/144000000 m ≈ 2.1795347×10⁻³ m
prz prussian_zoll, zoll Prussian Zoll bu_prussian_zoll 313853/12000000 m ≈ 2.6154417×10⁻² m
prf prussian_fuss, preussischer_fuss Prussian Fuß bu_prussian_fuss 3.13853×10⁻¹ m
elle prussian_elle, preussische_elle Prussian Elle bu_prussian_elle 6.66937625×10⁻¹ m (exact; 25½ Zoll)
rute prussian_rute, preussische_rute Prussian Rute bu_prussian_rute 3.766236 m (exact)
klafter prussian_klafter Klafter bu_klafter 1.883118 m (exact)
dt_mi deutsche_meile, german_mile Geographische Meile bu_german_mile 7420.44 m

Historical German Units — Area (Prussian)

Symbol Long forms Name Enum value Factor
morgen prussian_morgen Morgen (Prussian) bu_morgen 2553.21604938528 m² (exact)

Historical German Units — Volume (Prussian)

Symbol Long forms Name Enum value Factor
schffl scheffel, prussian_scheffel Scheffel (Prussian) bu_scheffel 54.961×10⁻³ m³

Every Prussian unit here reproduces its historical definition from the 1816 Fuß (0.313853 m): Zoll = Fuß/12, Linie = Zoll/12, Rute = 12 Fuß, Klafter = 6 Fuß, Elle = 25½ Zoll, Morgen = 180 square Ruten. test_unit_factors_derived.py checks each of them against that definition.

The German units carry ids 100133–100145, inside the native unit block like every other physical unit — see §12.1 for the block layout. They used to sit at 348–360, appended past the whole currency range, because the id space was one flat counter and the currencies had been dropped into the middle of it. The space is blocked now, so a unit's id no longer says anything about when it was added.

3.21 Additional Length Units

Symbol Long forms Name Enum value Factor
ftUS survey_foot US survey foot bu_survey_foot 1200/3937 m ≈ 0.304800609… m
lea league, leagues statute league bu_league 4828.032 m (= 3 statute miles)
cbl cable, cables cable length bu_cable 185.2 m
hand hands hand bu_hand 0.1016 m (= 4 in, exact)

ftUS (US survey foot) differs from ft (international foot, 0.3048 m exactly) by about 2 ppm. Used in US geodetic surveying.

3.22 Additional Mass Units

Symbol Long forms Name Enum value Factor
qntl quintal, quintals quintal bu_quintal 100 kg (exact)
sc scruple, scruples apothecary scruple bu_scruple 1.2959782×10⁻³ kg (= 20 grains)

3.23 Acceleration

Symbol Long forms Name Enum value Factor
gn standard_gravity standard gravity bu_standard_gravity 9.80665 m·s⁻² (exact, CGPM 1901)

3.24 Signal Rate

Symbol Long forms Name Enum value Notes
Bd baud, bauds baud bu_baud 1 symbol/s = 1 s⁻¹ (ITU-T V.662)

3.25 Ratio and Proportion Units

Dimensionless scaling factors. A value carrying one of these units reduces to the numeric value multiplied by the factor in the canonical (dimensionless) base representation — e.g. 5 %0.05, 250 ppm0.00025. Like the other dimensionless ratios they carry an empty dimension vector, but unlike rad/sr they do not accept SI or IEC prefixes (a prefixed % is meaningless).

Symbol Long forms Name Enum value Factor
% percent per cent bu_percent 10⁻²
per_mille per mille bu_per_mille 10⁻³
per_myriad per myriad bu_per_myriad 10⁻⁴
pcm per_cent_mille per cent mille bu_per_cent_mille 10⁻⁵
ppm parts per million bu_ppm 10⁻⁶
ppb parts per billion bu_ppb 10⁻⁹
pptr parts_per_trillion, pptv parts per trillion bu_ppt 10⁻¹²
ppq parts_per_quadrillion, ppqv parts per quadrillion bu_ppq 10⁻¹⁵

Why pptr and not ppt. Two independent reasons, and either alone would settle it. ppt already resolved — as the compact form of p~pt, the picopint — so claiming it as an alias would have taken a spelling away from the unit that held it, which §4.3's guarantee forbids and gen_units.py refuses at build time. And ppt is ambiguous in the field: parts per thousand in some industries, parts per trillion in atmospheric chemistry, a factor of 10⁹ apart. UCUM splits the same ambiguity the same way — [ppth] for per thousand, [pptr] for per trillion — so the symbol here is borrowed rather than invented. The compact token ppt is refused outright (§4.3); write pptr, , or p~pt if the picopint really is what you meant.

pptv and ppqv, the "by volume" spellings, are accepted as aliases because UDUNITS-2 defines them as the same 10⁻¹² and 10⁻¹⁵. Both profiles that name these units — UCUM's [pptr] and UDUNITS' ppt/pptv/ppq/ppqv — were refused for want of a native target until these rows existed.

3.26 Named Speed Units

Speeds people write as one token. mi/h and k~m/h express the same quantities as compounds and remain valid; these are separate base units, not shorthands the parser expands, so an annotation of mph does not reconcile with an inline mi/h (the comparison is structural — see §2.2). Neither accepts a prefix: they already carry one, or have no meaningful prefixed form. kn (the knot, §3.9) belongs to the same family.

Symbol Long forms Name Enum value Factor
mph mile per hour bu_mile_per_hour 0.44704 m·s⁻¹ (exact)
kph kmh kilometre per hour bu_kilometer_per_hour 5/18 m·s⁻¹ (exact; the decimal does not terminate)

3.27 Acidity

The pH scale: dimensionless by construction, being a negative decimal logarithm of hydrogen-ion activity. It carries a factor of 1 and accepts no prefix — a milli-pH is not a quantity. It is modelled for the same reason dB and Np are: a logarithmic scale is still a statement about what the number means, and 7.2 alone is not.

Because the scale is logarithmic, the value is a label, not something to sum or average — the same caveat that applies to dB and Np.

Symbol Long forms Name Enum value Factor
pH pH (acidity) bu_ph_scale 1 (dimensionless)

pH vs p~H: the two differ by one character and by seven orders of dimension. pH is the acidity scale; p~H is the picohenry. Case matters too: ph is the phot (§3.13). This is exactly why acidity had to become a unit — without it, pH resolves as a compact prefixed henry.

3.28 Water Hardness

Six scales for one physical quantity: the concentration of dissolved alkaline-earth ions (Ca²⁺, Mg²⁺). Each is defined as a mass of a reference compound per litre, but the scales use different compounds — °dH counts CaO, most of the others CaCO₃, °rH counts Ca — so mass concentration is not their common ground. Amount concentration is: every scale carries mol·m⁻³ (which is exactly mmol·L⁻¹), converts into every other, and into m~mol/L.

None of them takes a prefix. Millimoles per litre needs no unit of its own — it is the compound m~mol/L (or mmol/L).

Symbol Long form Name Enum value Defined as Factor (mol·m⁻³ = mmol·L⁻¹)
°dH german_hardness German degree bu_german_hardness 10 mg CaO / L 0.178326
°e, °Clark english_hardness, clark_degree English (Clark) degree bu_english_hardness 1 grain CaCO₃ / imperial gallon 0.142415
°fH french_hardness French degree bu_french_hardness 10 mg CaCO₃ / L 0.099914
°rH russian_hardness Russian degree bu_russian_hardness 1 mg Ca / L 0.024951
°aH american_hardness American degree bu_american_hardness 1 mg CaCO₃ / L 0.009991
gpg grains_per_gallon grains per US gallon bu_grains_per_gallon 1 grain CaCO₃ / US gallon 0.171034
val vals equivalent (water analysis) bu_val ½ mol — see below 0.5 (as mol, not a concentration)

Reading across, with 1 mmol·L⁻¹ as the reference:

1 mmol/L equals °dH °e °fH °rH °aH gpg mval/L
5.6077 7.0217 10.0086 40.078 100.086 5.8468 2.000

gpg is a unit, gr/gal is a compound, and they are not the same thing. gpg is an amount concentration and converts with the other hardness scales; gr/gal (grain per US gallon) is a mass concentration and deliberately does not. Both spellings stay valid.

val is the equivalent as water analysis uses it. The ions counted are divalent, so one equivalent is half a mole and m~val/L = 0.5 mmol/L, which is what the hardness tables state. An equivalent of a monovalent species is one mole — Bovnar cannot know the species from the unit, so for that case write the amount directly (m~mol/L). There is deliberately no generic eq / equivalent alias.

Exactness. Every degree is derived from a molar mass (IUPAC 2021: Ca 40.078, C 12.011, O 15.999 g·mol⁻¹), so the factors are measurement-derived rather than exact rationals. They carry .exact = false, which means a read-time lossless conversion (want_unit, §1.10 of the read/write API) refuses them with error_unit_inexact (47) rather than inventing precision — the same answer it gives for a π-based angle. bvn_unit_convert_value and bvn_unit_to_si_factor, which work in double precision, convert normally.

mval/L and meq/L are one unit. eq is an accepted spelling of val, so the German and English conventions both work and both carry the divalent caveat above.

dH is not °dH. Without the degree sign the token is the decihenry, and it stays that way. Likewise water chemistry writes the American scale as "ppm"; Bovnar's ppm is the dimensionless 10⁻⁶ and is not interchangeable with °aH. See 07_bovnar_unit_ambiguities.md.

3.29 Conductivity and Dissolved Solids

Two quantities that water data quotes constantly and that need no unit of their own — the existing tables already say them exactly.

Quantity Write Notes
Electrical conductivity (EC) µS/cm, uS/cm, mS/cm, dS/m, S/m siemens per length; dS/m is the soil-salinity convention and equals mS/cm
… in pre-SI spelling µmho/cm, mmho/cm mho, mhos and (U+2127) are accepted spellings of the siemens
Total dissolved solids (TDS) mg/L, µg/L, g/L, k~g/m³ mass concentration
Resistivity (the reciprocal) MΩ·cm, M~Ω·c~m ultrapure-water convention

One quantity does get a unit of its own, because the hydroponic scale is a rescaling nothing else in the table expresses:

Symbol Long form Name Enum value Method Prefixes
CF conductivity_factor conductivity factor bu_conductivity_factor EC in mS/cm × 10 no

CF carries the dimensions of conductivity, so unlike the turbidity scales below it does convert: 1 CF = 0.1 mS/cm = 100 µS/cm, exactly, and into S/m and dS/m with it. Only the uppercase spelling is the conductivity factor — cF is the centifarad.

"ppm" for TDS. Water data writes TDS in "ppm", meaning milligrams per litre. Bovnar's ppm is the dimensionless 10⁻⁶ — for a dilute aqueous solution at 1 kg/L the two are numerically the same, so writing ppm is defensible here in a way it is not for hardness (§3.28), but the dimensions differ: ppm is a ratio, mg/L is a concentration, and they do not convert into one another. Pick the one you mean.

TDS meter scales. A conductivity meter reading "TDS ppm" applies a conversion factor to EC (the 500/442/700 scales). That factor is a property of the instrument and the water, not of a unit — record the EC in µS/cm and the factor separately, or record the TDS as mg/L.

3.30 Turbidity and Salinity

Six scales defined by a measurement method rather than by a physical quantity. They are dimensionless, and each carries its own quantity kind, so none of them converts to another or to a plain number — the conversion a factor cannot express is refused instead of guessed.

Symbol Long form Name Enum value Method Prefixes
NTU nephelometric_turbidity nephelometric turbidity unit bu_turbidity_ntu white light, 90° detector (EPA 180.1) yes
FNU formazin_nephelometric formazin nephelometric unit bu_turbidity_fnu near-infrared 860 nm, 90° detector (ISO 7027) yes
FTU formazin_turbidity formazin turbidity unit bu_turbidity_ftu formazin-calibrated, geometry unstated (ISO 7027:1984) yes
FAU formazin_attenuation formazin attenuation unit bu_turbidity_fau attenuation at 0°, in the transmitted beam (ISO 7027) yes
JTU jackson_turbidity Jackson turbidity unit bu_turbidity_jtu visual candle turbidimeter (historical) no
PSU practical_salinity practical salinity unit bu_practical_salinity PSS-78 conductivity ratio no

None of the five turbidity scales converts to any other:

NTU FNU FTU FAU JTU
NTU
FNU
FTU
FAU
JTU

One kind per method, and that is the whole content of the table. The formazin-calibrated scales (NTU, FNU, FTU) are numerically equal on a formazin standard — which is exactly the trap. On real water white light and near-infrared respond differently to particle size and colour, so the number alone does not say what was measured; FTU says even less, since its geometry is unstated by definition. FAU is not the same optical quantity at all: it measures how much light the sample removes from the beam, not how much it scatters sideways, and it is the instrument of choice above ~40 FNU where nephelometry saturates. JTU is the visual candle method, whose published equivalence to formazin ("1 JTU ≈ 1 NTU") holds near 40 units and nowhere else, being nonlinear and sample-dependent. Bovnar refuses every one of these conversions rather than implying a factor exists — report the method.

NTU, FNU, FTU and FAU accept prefixes (m~NTU is real in ultrapure-water work). JTU does not: the candle method cannot resolve below roughly 25 JTU, so a milli-JTU is not a measurement.

Watch the case. fau is the femto-astronomical-unit, cF the centifarad — the turbidity and conductivity scales are uppercase only. See 07_bovnar_unit_ambiguities.md.

PSU is not per-mille. Practical salinity is a conductivity ratio, so it is dimensionless by construction and PSU is a label rather than a unit — SI-minded texts write S_P = 35 with no unit at all. It is not a mass fraction: S_P 35 corresponds to about 35.165 g/kg absolute salinity, so equating it with or g/kg is wrong by roughly half a percent. For absolute salinity write the mass fraction directly: g/k~g. The scale is bounded by construction, so it takes no prefix.

3.31 Sentinel Value

bu_none (value 0) is the internal representation of "no base unit", used for the no_unit keyword and as the default when no unit annotation is present.


3.32 Units the Unit Profiles Needed

Every unit in this section was, until it was added, the sole reason a run of UCUM, UDUNITS-2, QUDT, OM or UN/ECE codes had to be refused as error_unit_profile_unsupported: the publisher defines the unit, the value is exactly stateable, and there was no native unit to translate onto. None of them is new physics. All are exact — the ones whose value is not a terminating decimal in SI state a rational in units.bvnr rather than the repr of a double.

US survey lengths

bu_survey_foot (§3.21) has been in the registry all along and nothing was built on it, so every survey length above the foot had to be refused. Each below is an exact rational multiple of the survey foot's 1200/3937 m.

Symbol Long forms Name Enum value Factor
inUS survey_inch US survey inch bu_survey_inch 100/3937 m
ydUS survey_yard US survey yard bu_survey_yard 3600/3937 m
fathUS survey_fathom US survey fathom bu_survey_fathom 7200/3937 m
rdUS survey_rod US survey rod (pole, perch) bu_survey_rod 19800/3937 m
chUS survey_chain US survey chain (Gunter's) bu_survey_chain 79200/3937 m
lkUS survey_link US survey link bu_survey_link 792/3937 m
furUS survey_furlong US survey furlong bu_survey_furlong 792000/3937 m
miUS survey_mile, survey_miles US survey (statute) mile bu_survey_mile 6336000/3937 m
acUS survey_acre, survey_acres US survey acre bu_survey_acre 43 560 ftUS² = 62726400000/15499969 m²

The survey foot is 2 ppm longer than the international foot, and the survey acre 4 ppm larger than the international acre — an area being a length squared. Small enough to ignore and never small enough to be right: on a section of land the acre difference is about ten square metres. The survey foot was withdrawn for new work at the end of 2022, which is a reason to read it carefully rather than to refuse it, since US land records, state-plane coordinates and a century of engineering drawings are written in it.

Typographic lengths

Symbol Long forms Name Enum value Factor
pnt point, points DTP point (1/72 in) bu_point 127/360000 m
pca pica, picas pica (12 points) bu_pica 127/30000 m
lne line, lines line (1/12 in) bu_line 127/60000 m

The symbols are deliberately not pt and ln. pt is the pint and has been since before these existed; a length answering to it would change what an existing spelling means, which §10 forbids and which gen_units.py now refuses at build time. UCUM's own bracketed spellings [pnt], [pca] and [lne] name them unambiguously, so the symbols follow those. The printer's point (0.013837 in) is a different unit and has a row of its own, pnt_pr, at the end of §3.32.

US dry volumes, and the trade measures built on feet

A dry quart is 16 per cent larger than the liquid quart qt. The peck (pk) and bushel (bsh) in §3.19 have always been the dry ones; the gallon, quart and pint were only the liquid ones, so a document that meant dry had no way to say so.

Symbol Long forms Name Enum value Factor
gal_dry dry_gallon, dry_gallons US dry gallon bu_dry_gallon 268.8025 in³ = 4.40488377086×10⁻³ m³
qt_dry dry_quart, dry_quarts US dry quart bu_dry_quart 1.101220942715×10⁻³ m³
pt_dry dry_pint, dry_pints US dry pint bu_dry_pint 5.506104713575×10⁻⁴ m³
fbm board_foot, board_feet board foot (144 in³) bu_board_foot 2.359737216×10⁻³ m³
cord cord, cords cord (128 ft³) bu_cord 3.624556363776 m³
ac_ft acre_foot, acre_feet acre-foot (survey) bu_survey_acre_foot acUS·ftUS = 1233.4892384681489 m³

ac_ft is the acre-foot on the survey acre and foot, which is what UDUNITS, OM and every US water agency mean by it. The international acre-foot is ac·ft = 1233.48183754752 m³ — 5.8 ppm away, and a different unit rather than a rounding.

Permeability, energy, power, ozone and one very short time

Symbol Long forms Name Enum value Factor
darcy darcy, darcys, darcies darcy (permeability) bu_darcy 1/1013250000000 m²
thm_ec therm_EC EC therm (10⁵ Btu) bu_therm_ec 1.05505585262×10⁸ J (exact)
ton_ref refrigeration_ton, ton_of_refrigeration ton of refrigeration bu_refrigeration_ton 12 000 Btu/h = 52752792631/15000000 W
DU dobson, dobson_unit, dobson_units Dobson unit (ozone column) bu_dobson 4.462×10⁻⁴ mol·m⁻² (exact)
shake shake, shakes shake bu_shake 10⁻⁸ s (exact)

darcy takes prefixes, so the millidarcy every reservoir report is written in is m~darcy (or compactly mdarcy). thm_ec is the therm European gas billing uses; native thm is the US therm, 0.24 per cent away, and no SI prefix reaches 10⁵ — which is why it needed a unit of its own rather than a prefixed Btu. shake exists for the same reason at the other end: there is no SI prefix at 10⁻⁸.


Nine more, from two and three vocabularies at once

Each was refused across UCUM, UDUNITS-2, QUDT and OM for one reason only — no native unit of the magnitude. One publisher naming a unit is a request; two or three naming it independently at the same value is evidence, and every factor below is one all of its publishers agree on to the last digit they state.

Symbol Long forms Name Enum value Factor
pnt_pr printers_point printer's point bu_printers_point 0.0003514598 m (exact, = 0.013837 in)
pca_pr printers_pica printer's pica bu_printers_pica 0.0042175176 m (exact, = 12 pnt_pr)
hp_E electric_horsepower electric horsepower bu_horsepower_electric 746 W (exact)
hp_B boiler_horsepower boiler horsepower bu_horsepower_boiler 9809.5 W
abV abvolt abvolt (CGS-EMU) bu_abvolt 10⁻⁸ V (exact)
AT assay_ton assay ton (short) bu_assay_ton 175/6000 kg ≈ 0.029166667 kg
bsh_uk bushel_uk imperial bushel bu_bushel_uk 0.03636872 m³ (exact, = 8 gal_uk)
clo clo bu_clo 0.155 K·m²/W (exact)
debye debye bu_debye 1/299792458000000000000000000000 C·m ≈ 3.3356410×10⁻³⁰ C·m

Each is a near neighbour of a unit already here, which is why it needed its own row rather than an alias. pnt is the DTP point (¹⁄₇₂ in) and pnt_pr the printer's point (0.013837 in), 0.37 % apart; hp is the mechanical horsepower, PS the metric one and hp_E the electric 746 W, while hp_B is thirteen times any of them; bsh is the US dry bushel and bsh_uk the imperial one, 3.2 % larger. Mapping any of these onto its neighbour is the error doc/11 §6.3 warns about — dimensionally perfect, inside anything a later check would notice, and wrong.

abV needed a unit rather than a prefix because there is no decade prefix at 10⁻⁸ and prefixes do not stack. AT and debye are stated as exact rationals: the assay ton is 175/6 g, chosen so that milligrams recovered from one assay ton read as troy ounces per short ton, and the debye has been exactly 10⁻²¹/c C·m since the 2019 SI fixed c. Their publishers state 7- and 6-digit roundings of those; the rational is what they are rounding.

And the thirty-five the same sweep found next

Grouping every profile refusal by (dimension, value) turned up 35 groups two or more vocabularies define. The nine above were the first pass; these are the rest that name a real unit. What is left out is a constant rather than a unit (UCUM's [pi]), a value only one publisher states, or a dimensionless "relative permeability" that is a number.

Where the publishers disagree, the exact definition decides. The CGS-ESU units are built on c, which the 2019 SI fixed, so every one is an exact rational and the publishers' five- to seven-digit decimals are roundings of it — which is why statΩ is 22468879468420441/25000 here and 898 755 400 000, 898 755 200 000 and 898 760 000 000 in UDUNITS, OM and QUDT. The sidereal hour, minute and second are derived from the sidereal day rather than taken from the three slightly inconsistent decimals the publishers state for them, so the four stay consistent with each other. The π-based rows carry .exact = false, as the oersted and the parsec do: a value that is not a rational cannot be converted losslessly, and saying so is what that flag is for.

yr is still the Julian year and mo still a twelfth of it. Naming the others does not make the short spellings ambiguous — it gives the other calendars somewhere to go, which is what lets a UDUNITS or CF document that says year be read at all. It was refused outright before.

Calendar and sidereal time

Symbol Long forms Name Enum Factor
yr_trop tropical_year tropical year bu_year_tropical 31 556 925.9747 s (the tropical year UDUNITS and CF carry)
yr_greg gregorian_year Gregorian year bu_year_gregorian 31 556 952 s (exact, = 365.2425 d)
yr_sid sidereal_year sidereal year bu_year_sidereal 31 558 149.7632 s
yr_com common_year common year bu_year_common 31 536 000 s (exact, = 365 d)
mo_syn synodal_month synodal month bu_month_synodal 2 551 442.976 s (the synodal, lunar month)
mo_trop tropical_month tropical month bu_month_tropical 2 360 584.6848 s
mo_greg gregorian_month mean Gregorian month bu_month_gregorian 2 629 746 s
mo_sid sidereal_month sidereal month bu_month_sidereal 2 360 591.5104 s
d_sid sidereal_day sidereal day bu_day_sidereal 86 164.0905 s
h_sid sidereal_hour sidereal hour bu_hour_sidereal d_sid/24 = 3590.1704375 s
min_sid sidereal_minute sidereal minute bu_minute_sidereal d_sid/1440
s_sid sidereal_second sidereal second bu_second_sidereal d_sid/86400

CGS electrostatic (ESU)

Symbol Long forms Name Enum Factor
statV statvolt statvolt bu_statvolt 149896229/500000 V = 299.792458 V (exact, = c×10⁻⁶)
statA statampere statampere bu_statampere 1/2997924580 A (exact)
statC statcoulomb, franklin statcoulomb (franklin) bu_statcoulomb 1/2997924580 C (exact)
statF statfarad statfarad bu_statfarad 25000/22468879468420441 F (exact)
statΩ statohm statohm bu_statohm 22468879468420441/25000 Ω ≈ 898 755 178 737 Ω (exact)
statH stathenry stathenry bu_stathenry 22468879468420441/25000 H (exact)
statS statsiemens, statmho statsiemens bu_statsiemens 25000/22468879468420441 S (exact)
statWb statweber statweber bu_statweber 149896229/500000 Wb = 299.792458 Wb (exact, = statV·s)
statT stattesla stattesla bu_stattesla 149896229/50 T = 2 997 924.58 T (exact, = statV·s/cm²)

Luminance

Symbol Long forms Name Enum Factor
Lmb lambert lambert bu_lambert 10⁴/π cd·m⁻²
apostilb blondel apostilb (blondel) bu_apostilb 1/π cd·m⁻²
footlambert foot_lambert foot-lambert bu_footlambert 1/π cd·ft⁻²

Water-vapour permeance

Symbol Long forms Name Enum Factor
perm_0C perm (0 °C) bu_perm_0c 5.72135×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact)
perm_23C perm (23 °C) bu_perm_23c 5.74525×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact)
perm_m perm_metric metric perm bu_perm_metric 8.68127×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact)

Heat conventions

Symbol Long forms Name Enum Factor
cal_m mean_calorie mean calorie bu_calorie_mean 4.19002 J (exact)
cal_15 calorie_15C 15 °C calorie bu_calorie_15c 4.1858 J (exact)
cal_20 calorie_20C 20 °C calorie bu_calorie_20c 4.1819 J (exact)
Btu_59 btu_59F 59 °F BTU bu_btu_59f 1054.8 J (exact)
Btu_60 btu_60F 60 °F BTU bu_btu_60f 1054.68 J (exact)
Btu_m mean_btu mean BTU bu_btu_mean 1055.87 J (exact)

And the singles

Symbol Long forms Name Enum Factor
e elementary_charge elementary charge bu_elementary_charge 1.602176634×10⁻¹⁹ C (exact, the 2019 SI definition)
sph spere, spat spere (spat) bu_spere 4π sr
cml circular_mil circular mil bu_circular_mil π/4 thou² ≈ 5.0670748×10⁻¹⁰ m²
unit_pole unitpole unit pole bu_unit_pole 4π×10⁻⁸ Wb
hp_W water_horsepower water horsepower bu_horsepower_water 746.043 W

4. Prefixes

A prefix is attached to a base unit symbol either with the ~ separator — prefix~baseunit, the canonical form — or compactly, without it: kg is k~g, MiB is Mi~B. Both spellings parse to the same value_unit_t; the writer always emits the separated form, so a document that round-trips through the library comes back canonical. See §4.3 for what the compact form does and does not accept.

4.1 SI Prefixes

All 24 current SI prefixes are supported, from quecto (10⁻³⁰) to quetta (10³⁰).

Prefix Symbol Factor Enum value
quetta Q 10³⁰ si_quetta
ronna R 10²⁷ si_ronna
yotta Y 10²⁴ si_yotta
zetta Z 10²¹ si_zetta
exa E 10¹⁸ si_exa
peta P 10¹⁵ si_peta
tera T 10¹² si_tera
giga G 10⁹ si_giga
mega M 10⁶ si_mega
kilo k 10³ si_kilo
hecto h 10² si_hecto
deca da 10¹ si_deca
(none) 10⁰ si_none
deci d 10⁻¹ si_deci
centi c 10⁻² si_centi
milli m 10⁻³ si_milli
micro µ (or u) 10⁻⁶ si_micro
nano n 10⁻⁹ si_nano
pico p 10⁻¹² si_pico
femto f 10⁻¹⁵ si_femto
atto a 10⁻¹⁸ si_atto
zepto z 10⁻²¹ si_zepto
yocto y 10⁻²⁴ si_yocto
ronto r 10⁻²⁷ si_ronto
quecto q 10⁻³⁰ si_quecto

Encoding note: µ is U+00B5 (MICRO SIGN), UTF-8: 0xC2 0xB5. U+03BC (GREEK SMALL LETTER MU) is a distinct code point but is also accepted on input; the canonical output form is always U+00B5. ASCII u is accepted as an input-only alias for µ (e.g. u~m parses as µ~m); the canonical output form is always µ. Note u is also the bare symbol for the dalton, but the two never collide: the base unit is whatever the longest alias suffix says, so u is the dalton and the u in um or u~m is the prefix.

Prefix–Symbol Ambiguities

Several prefix symbols overlap with base unit symbols. A base unit symbol is matched as the longest alias suffix of the component, so a bare unit always wins over a prefixed reading of the same token: min is the minute, never milli-inch, and cd is the candela, never centi-day. Where that is not the reading you want, the ~ separator states it: m~in is milli-inch, m~ introduces the milli prefix, bare m is the meter.

Symbol As prefix As base unit
m milli meter
d deci day
h hecto hour
T tera tesla
f femto (none) — the farad is F, uppercase
a atto (none)
u micro (ASCII alias for µ) dalton
S (none) siemens

d~s = decisecond; d alone = day.

4.2 IEC Binary Prefixes

IEC 80000-13 binary prefixes are used for digital quantities (b and B only). The table below also lists Ri and Qi; the standard stops at Yi (2⁸⁰) and those two are an unratified extension.

Prefix Symbol Factor Enum value
kibi Ki 2¹⁰ iec_kibi
mebi Mi 2²⁰ iec_mebi
gibi Gi 2³⁰ iec_gibi
tebi Ti 2⁴⁰ iec_tebi
pebi Pi 2⁵⁰ iec_pebi
exbi Ei 2⁶⁰ iec_exbi
zebi Zi 2⁷⁰ iec_zebi
yobi Yi 2⁸⁰ iec_yobi
robi Ri 2⁹⁰ iec_robi
quebi Qi 2¹⁰⁰ iec_quebi

Prefix–Unit Validity Constraints

.valid1   = <uint:64,Ki~B>  8;     # OK: IEC prefix on byte
.valid2   = <uint:32,M~b>   100;   # OK: SI mega on bit
.valid3   = <float_dec:64,k~$USD> 250.0;  # OK: kilo on currency
.invalid1 = <uint:64,Ki~m>  1;     # ERROR: IEC prefix on meter
.invalid2 = <uint:32,m~B>   512;   # ERROR: SI milli on byte
.invalid3 = <float_dec:64,Ki~$USD> 1;     # ERROR: IEC prefix on currency

4.3 Compact Prefix Form

A prefixed physical unit may be written without the ~ separator, in the spelling the rest of the world already uses:

.mass      = <float:64,kg>   72.5;      # same unit as k~g
.distance  = <float:64,km>    1.5;      # k~m
.period    = <float:64,ms>   16.7;      # m~s
.frequency = <float:64,MHz> 2400.0;     # M~Hz
.pressure  = <float:64,hPa> 1013.25;    # h~Pa
.energy    = <float:64,MeV>   13.6;     # M~eV
.amount    = <float:64,mmol>   2.5;     # m~mol
.memory    = <uint:64,MiB>   512;       # Mi~B
.force     = <float:64,kg·m/s²> 9.81;   # compounds, exponents and groups too

Both spellings produce the same value_unit_t, so they reconcile against each other (<float:64,k~m> 1.5 km is valid) and against a stored annotation. The canonical output form is unchanged: bvn_unit_to_string emits k~g for either spelling, so a compact unit is an input convenience that never propagates into what the library writes.

The rules are exactly the rules of the separated form, with one addition:

Refused Would have meant Usually means Write instead
usb microstilb the bus u~sb
kt kilotonne also the knot k~t (mass) or kn (speed)
ppt picopint (a volume) parts per trillion, or per thousand pptr (10⁻¹²), (10⁻³), p~pt (the volume)

pH, mph and kph were on this list until the quantities they name became units of their own (§3.26, §3.27). A bare alias outranks any prefixed reading, so they now need no exception — and p~H, m~ph, k~ph still mean what they always did.

The last two rows are the harder cases. kt is a standard abbreviation for two units Bovnar does model — the kilotonne in climate and energy data, the knot in marine and aviation data — and reading a speed as a mass is precisely what this format exists to stop. ppt is worse: the compact rule makes it a volume, while every real use of the token is a dimensionless ratio, and the two ratios it might mean are 10⁹ apart. A token only the author can resolve is one the author has to resolve.

Note what is not done about ppt: the parts-per-trillion unit added in §3.25 does not claim the spelling. A bare alias outranks any prefixed reading (rule 2 above), so claiming it would take ppt away from p~pt and change what an existing document means — which the rule at the top of units.bvnr forbids and gen_units.py refuses at build time. The unit is spelled pptr, following UCUM's own [pptr], and the ambiguous token is refused instead.

The list lives in src/gendata/units.bvnr (.compact_exceptions) and applies to the compact spelling only — p~H is still picohenry, k~t still the kilotonne, p~pt still the picopint.

Because a compact spelling is only ever reached where the separated form would have been a parse error, no document that parsed before this existed can parse differently now.

For a token-by-token list of every spelling that could be read two ways — including case traps, look-alike characters and abbreviations that are deliberately not units — see 07_bovnar_unit_ambiguities.md.

Currencies take the compact prefix too — k$EUR is k~$EUR. The $ sigil already separates the prefix from the code (no prefix symbol and no currency code contains a $), so nothing is left for the ~ to resolve. What the sigil rule still requires is the sigil itself: kUSD is error_unit_illegal, because a bare code is never a currency (§10.4).


5. Unit Notation Grammar

5.1 Simple Units

unit-component = [ prefix [ "~" ] ] base-unit [ unit-exponent ]

The ~ separator is optional (km = k~m, k$USD = k~$USD; §4.3). The currency $ sigil is not.

.temperature = <float:64,K>      300.0;   # kelvin
.distance    = <float:64,k~m>    1.5;     # kilometer
.frequency   = <float:64,M~Hz>   2400;    # megahertz
.storage     = <uint:64,Ki~B>    1024;    # kibibytes
.altitude    = <float:64,km>     10.5;    # kilometer, compact spelling
.fund_nav    = <float_dec:64,k~$USD> 250.0; # $250,000

5.2 Compound Units

compound-unit  = "no_unit"
               | unit-component { unit-sep unit-component }

unit-sep       = "*" | "/" | "·"           (* · = U+00B7 MIDDLE DOT *)

unit-component = [ prefix [ "~" ] ] base-unit [ unit-exponent ]

unit-exponent  = [ exp-sign ] exp-digit { exp-digit }
               | "^" [ "-" | "+" ] ASCII-digit { ASCII-digit }

exp-sign       = "⁺" | "⁻"

exp-digit      = "⁰" | "¹" | "²" | "³" | "⁴" | "⁵"
               | "⁶" | "⁷" | "⁸" | "⁹"

An exponent is one or more digits in either notation — m¹⁰⁰ and m^100 are units, and this production is what §6 states in prose. is a digit here only in a multi-digit exponent: it exists so m¹⁰⁰ can be written, and a leading m⁰ is still no unit (§6). At most three digits are scanned, so m^1000 fails as an unrecognised token rather than as an over-large exponent.

Currency codes participate in compound expressions using the same separators:

.gold_spot    = <float_dec:64,$USD/oz_t>   2351.40;  # $/troy oz
.rent         = <float_dec:64,$EUR/m²>       12.50;  # €/m²
.billing_rate = <float_dec:64,$EUR/h>       150.00;  # €/h
.eur_usd      = <float_dec:64,$USD/$EUR>      1.0842; # exchange rate

The sub-grammar is semantic, not lexical. The outer lexer captures the type-annotation body as a raw byte sequence; bvn_parse_unit parses the unit string portion after the lexer finishes.

5.3 Separators

Character Code point UTF-8 bytes Meaning
* U+002A 0x2A Multiplication
· U+00B7 0xC2 0xB7 Multiplication (preferred visual form)
/ U+002F 0x2F Division

· and * are semantically identical and may be mixed freely.

5.4 Denominator Semantics

The first / sets a latching "in-denominator" flag to true for all subsequent components. Additional / separators do not toggle back to the numerator. k~g·m/s² stores:

[0]: gram,   exp_linear,     si_kilo  ← numerator
[1]: meter,  exp_linear,     si_none  ← numerator
[2]: second, exp_neg_square, si_none  ← denominator (exponent negated)

Both k~g·m/s² and k~g·m·s⁻² parse to identical value_unit_t representations.

Parenthesised grouping

A (…) group is a sub-expression evaluated independently. Like any factor it obeys the latching denominator, so a / before a group negates the group's net component exponents as a whole. This makes the readable denominator form work and compose correctly:

.pressure = <float:64,k~g/(m·s²)> 101325;  # kg·m⁻¹·s⁻² — same as k~g/m·s²
.areal    = <float:64,(k~g/m)·s²> 1.0;     # kg·m⁻¹·s²  — grouping changes the s sign
.rate     = <float:64,m/(s·s)> 1.0;        # m·s⁻²

k~g/(m·s²) and k~g/m·s² therefore parse to the same value_unit_t (the canonical, parenless form is what the writer emits). Rules: an explicit separator is required before a group (m·(s), not m(s)); a group is not followed by its own exponent ((m·s)² is rejected — write m²·s²); parentheses must balance; empty groups (()) are rejected; nesting is bounded at 16. Unmatched or malformed groups raise error_unit_illegal.


6. Exponents

Exponents are integers in the range −100 … +100, with zero reserved: a component raised to zero is malformed, not dimensionless, so m^0 and m⁰ are not units. The bounds are BVN_EXPONENT_MIN and BVN_EXPONENT_MAX in include/bovnar.h.

The named enumerators of unit_exponent_t cover only ±1…±9 and are kept because that is what most units need and what existing source says. They are not the whole domain — any integer in the range is a valid exponent, so a switch over the type needs a default and code must not assume it can enumerate the values.

At most three digits are scanned, which is exactly what 100 needs. That is what makes an over-long exponent fail cleanly: m^1000 leaves a digit where the scan expects the ^, so no exponent is recognised at all and the whole token fails to resolve as a base symbol. A value that scans but lands outside the range — m^200 — is refused on the range check.

6.1 Unicode Superscript Form

Glyph Code point UTF-8 bytes Maps to
U+2070 0xE2 0x81 0xB0 digit 0 (multi-digit only)
¹ U+00B9 0xC2 0xB9 exp_linear
² U+00B2 0xC2 0xB2 exp_square
³ U+00B3 0xC2 0xB3 exp_cubic
U+2074 0xE2 0x81 0xB4 exp_quartic
U+2075 0xE2 0x81 0xB5 exp_quintic
U+2076 0xE2 0x81 0xB6 exp_sextic
U+2077 0xE2 0x81 0xB7 exp_septic
U+2078 0xE2 0x81 0xB8 exp_octic
U+2079 0xE2 0x81 0xB9 exp_nonic
U+207A 0xE2 0x81 0xBA positive sign (no-op)
U+207B 0xE2 0x81 0xBB negate exponent

Digits combine, most-significant first, so m¹⁰⁰ is the metre to the hundredth and s⁻¹² is a reciprocal twelfth power. Note that the glyphs are not one width — ¹ ² ³ are two UTF-8 bytes and ⁰ ⁴ are three — so ⁻¹⁰⁰ is eleven bytes, and a parser must consume one digit at a time rather than assume a stride.

is a digit only within a multi-digit exponent. A lone m⁰ is still not a unit, because zero is reserved.

6.2 ASCII Caret Form

ASCII form Equivalent Unicode Parsed as
m^2 exp_square
s^-2 s⁻² exp_neg_square
m^+2 exp_square
kg^1 kg¹ exp_linear
m^100 m¹⁰⁰ exponent 100
s^-100 s⁻¹⁰⁰ exponent −100

Up to three ASCII digits are permitted after the caret — exactly what BVN_EXPONENT_MAX needs. m^1000 is not an out-of-range exponent but an unrecognised token: the scan stops after three digits, finds no ^, and the whole string fails to resolve.

6.3 Exponent Edge Cases


7. The no_unit Keyword

The literal no_unit declares a value as explicitly dimensionless:

.ratio       = <float:64,no_unit> 0.95;
.count       = <uint:32,no_unit>  1000;

bvn_parse_unit detects no_unit via memcmp and returns BVN_UNIT_NONE (num_components = 0).

Omitting the unit parameter (e.g. <float:64>) yields BVN_UNIT_NO_PREFIX(bu_none) (num_components = 1, base == bu_none). Both forms are semantically equivalent — they compare as compatible via bvn_units_compatible and both serialize to "no_unit" — but they are structurally distinct internal states.


8. Constraints and Limits

Constraint Value Error on violation
Maximum components per compound unit 32 (BVNR_MAX_UNIT_COMPONENTS) error_unit_illegal
Empty component between separators (e.g. m//s) Not allowed error_unit_illegal
Maximum raw unit string length 255 bytes, but which cap fires depends on where the unit is written error_type_too_long in an annotation, error_unit_too_long inline — see below
Null or empty unit string Rejected by bvn_parse_unit ok = false

a/b/c parses as a / (b·c) → components [a, b⁻¹, c⁻¹]. The "no toggle back" rule means / always adds to the denominator; it never re-enters the numerator.

An over-long unit in an annotation is error_type_too_long, not error_unit_too_long. Three 255-byte caps sit in front of the unit parser and the one that fires decides the code. The whole type-annotation body has its own cap, and it counts the family name, the width and the commas as well — so a unit parameter can never be the only thing over the line, and an annotated unit always reaches error_type_too_long first. An inline unit suffix has a buffer to itself and is the path that reaches error_unit_too_long. doc/11 §2.5 tabulates all three caps beside the profile grammar.


9. Currency Codes

Currency amounts are dimensional quantities in financial computing. $19.99 USD carries a denomination dimension just as 9.81 m/s² carries an acceleration dimension. Bovnar extends the unit system with 216 currency and cryptocurrency codes so that monetary data can be annotated and round-tripped with the same precision guarantees as physical measurements.

9.1 The $ Sigil Rule

As of spec 1.0 a currency is recognised only in its $-sigil form ($USD, $BTC, or prefixed k~$EUR / k$EUR). The sigil — and nothing else — dispatches a component to the currency table; see §10.4 for the full rules and rationale.

Classification happens at the lookup stage, per unit component:

  1. A component introduced by $ (after any SI/IEC prefix and its ~) is looked up in the currency table. If the code is not found there, error_unit_illegal is raised — a $ introduces a currency and nothing else.
  2. A component without a $ is looked up only in the physical unit table. A bare code such as USD or CUP is therefore never a currency; if it is not a physical unit it is error_unit_illegal.

Because the sigil is mandatory, a bare uppercase code can never collide with a physical-unit symbol — present or future — so the two namespaces are disjoint by construction. The collision cases this resolves are catalogued for reference in §10.

9.2 ISO 4217 Fiat Currencies and Precious Metals

166 ISO 4217 alphabetic codes are supported, including precious-metal X-codes. They occupy the front of block 90 of the value_base_unit_t id space (§12.1) — ids 900000 … 900165, alphabetical from AED to ZWL — and, unlike physical units, have no named bu_* enumerators: such a currency is resolved from its $-sigil code by bvn_parse_currency_str and carried as the numeric base value. The catalogue in bovnar_currency.c is index-aligned to the block, so a lookup is base - BVN_CURRENCY_FIRST.

The alphabetical order is a convenience, not a contract. Whether a currency is fiat or crypto is a column of the catalogue, read by bvn_unit_is_fiat / bvn_unit_is_crypto, not a sub-range of the ids — so a new currency of either kind appends at the end of currencies.bvnr and renumbers nothing. Before 2.0 it was a sub-range, which is why ZWG and XCG had to be stranded at 378–379 outside the currency range entirely; the block has 10 000 ids and they are ordinary rows now.

Four codes are historical and retained for compatibility: HRK (Croatian Kuna, retired 2023-01-01 when Croatia adopted the Euro), SLL (Sierra Leonean Leone (old), replaced by SLE in 2022), ZWL (Zimbabwean Dollar, superseded by ZWG Zimbabwe Gold in 2024), and BGN (Bulgarian Lev, retired 2026-01-01 when Bulgaria adopted the Euro); ANG (Netherlands Antillean Guilder) likewise coexists with its successor XCG (Caribbean Guilder, which inherits ANG's numeric code 532).

The minor_unit field carries the exponent N such that 1 major unit = 10^N minor units (e.g. 1 USD = 100 cents, N=2). Applications reading integer-annotated values (e.g. <uint:64,$KWD>) should call bvn_currency_minor_unit to determine the correct decimal shift. Minor units are bold below when they differ from 2. Num is the ISO 4217 numeric identifier.

Code Num Min Name
AED 784 2 UAE Dirham
AFN 971 2 Afghan Afghani
ALL 8 2 Albanian Lek
AMD 51 2 Armenian Dram
ANG 532 2 Netherlands Antillean Guilder
AOA 973 2 Angolan Kwanza
ARS 32 2 Argentine Peso
AUD 36 2 Australian Dollar
AWG 533 2 Aruban Florin
AZN 944 2 Azerbaijani Manat
BAM 977 2 Bosnia-Herzegovina Convertible Mark
BBD 52 2 Barbados Dollar
BDT 50 2 Bangladeshi Taka
BGN 975 2 Bulgarian Lev (historical; retired 2026-01-01)
BHD 48 3 Bahraini Dinar
BIF 108 0 Burundian Franc
BMD 60 2 Bermudian Dollar
BND 96 2 Brunei Dollar
BOB 68 2 Boliviano
BRL 986 2 Brazilian Real
BSD 44 2 Bahamian Dollar
BTN 64 2 Bhutanese Ngultrum
BWP 72 2 Botswana Pula
BYN 933 2 Belarusian Ruble
BZD 84 2 Belize Dollar
CAD 124 2 Canadian Dollar
CDF 976 2 Congolese Franc
CHF 756 2 Swiss Franc
CLF 990 4 Unidad de Fomento
CLP 152 0 Chilean Peso
CNY 156 2 Chinese Yuan
COP 170 2 Colombian Peso
CRC 188 2 Costa Rican Colon
CUP 192 2 Cuban Peso
CVE 132 2 Cape Verdean Escudo
CZK 203 2 Czech Koruna
DJF 262 0 Djiboutian Franc
DKK 208 2 Danish Krone
DOP 214 2 Dominican Peso
DZD 12 2 Algerian Dinar
EGP 818 2 Egyptian Pound
ERN 232 2 Eritrean Nakfa
ETB 230 2 Ethiopian Birr
EUR 978 2 Euro
FJD 242 2 Fijian Dollar
FKP 238 2 Falkland Islands Pound
GBP 826 2 Pound Sterling
GEL 981 2 Georgian Lari
GHS 936 2 Ghanaian Cedi
GIP 292 2 Gibraltar Pound
GMD 270 2 Gambian Dalasi
GNF 324 0 Guinean Franc
GTQ 320 2 Guatemalan Quetzal
GYD 328 2 Guyanese Dollar
HKD 344 2 Hong Kong Dollar
HNL 340 2 Honduran Lempira
HRK 191 2 Croatian Kuna (historical; retired 2023-01-01)
HTG 332 2 Haitian Gourde
HUF 348 2 Hungarian Forint
IDR 360 2 Indonesian Rupiah
ILS 376 2 Israeli New Shekel
INR 356 2 Indian Rupee
IQD 368 3 Iraqi Dinar
IRR 364 2 Iranian Rial
ISK 352 0 Icelandic Krona
JMD 388 2 Jamaican Dollar
JOD 400 3 Jordanian Dinar
JPY 392 0 Japanese Yen
KES 404 2 Kenyan Shilling
KGS 417 2 Kyrgyzstani Som
KHR 116 2 Cambodian Riel
KMF 174 0 Comorian Franc
KPW 408 2 North Korean Won
KRW 410 0 South Korean Won
KWD 414 3 Kuwaiti Dinar
KYD 136 2 Cayman Islands Dollar
KZT 398 2 Kazakhstani Tenge
LAK 418 2 Laotian Kip
LBP 422 2 Lebanese Pound
LKR 144 2 Sri Lankan Rupee
LRD 430 2 Liberian Dollar
LSL 426 2 Lesotho Loti
LYD 434 3 Libyan Dinar
MAD 504 2 Moroccan Dirham
MDL 498 2 Moldovan Leu
MGA 969 2 Malagasy Ariary
MKD 807 2 Macedonian Denar
MMK 104 2 Myanmar Kyat
MNT 496 2 Mongolian Togrog
MOP 446 2 Macanese Pataca
MRU 929 2 Mauritanian Ouguiya
MUR 480 2 Mauritian Rupee
MVR 462 2 Maldivian Rufiyaa
MWK 454 2 Malawian Kwacha
MXN 484 2 Mexican Peso
MYR 458 2 Malaysian Ringgit
MZN 943 2 Mozambican Metical
NAD 516 2 Namibian Dollar
NGN 566 2 Nigerian Naira
NIO 558 2 Nicaraguan Cordoba
NOK 578 2 Norwegian Krone
NPR 524 2 Nepalese Rupee
NZD 554 2 New Zealand Dollar
OMR 512 3 Omani Rial
PAB 590 2 Panamanian Balboa
PEN 604 2 Peruvian Sol
PGK 598 2 Papua New Guinean Kina
PHP 608 2 Philippine Peso
PKR 586 2 Pakistani Rupee
PLN 985 2 Polish Zloty
PYG 600 0 Paraguayan Guarani
QAR 634 2 Qatari Riyal
RON 946 2 Romanian Leu
RSD 941 2 Serbian Dinar
RUB 643 2 Russian Ruble
RWF 646 0 Rwandan Franc
SAR 682 2 Saudi Riyal
SBD 90 2 Solomon Islands Dollar
SCR 690 2 Seychellois Rupee
SDG 938 2 Sudanese Pound
SEK 752 2 Swedish Krona
SGD 702 2 Singapore Dollar
SHP 654 2 Saint Helena Pound
SLE 925 2 Sierra Leonean Leone
SLL 694 2 Sierra Leonean Leone (old) (historical; replaced by SLE 2022)
SOS 706 2 Somali Shilling
SSP 728 2 South Sudanese Pound
SRD 968 2 Surinamese Dollar
STN 930 2 Sao Tome and Principe Dobra
SVC 222 2 Salvadoran Colon
SYP 760 2 Syrian Pound
SZL 748 2 Swazi Lilangeni
THB 764 2 Thai Baht
TJS 972 2 Tajikistani Somoni
TMT 934 2 Turkmenistan Manat
TND 788 3 Tunisian Dinar
TOP 776 2 Tongan Pa'anga
TRY 949 2 Turkish Lira
TTD 780 2 Trinidad and Tobago Dollar
TWD 901 2 New Taiwan Dollar
TZS 834 2 Tanzanian Shilling
UAH 980 2 Ukrainian Hryvnia
UGX 800 0 Ugandan Shilling
USD 840 2 US Dollar
UYU 858 2 Uruguayan Peso
UZS 860 2 Uzbekistani Som
VES 928 2 Venezuelan Bolivar Soberano
VND 704 0 Vietnamese Dong
VUV 548 0 Vanuatu Vatu
WST 882 2 Samoan Tala
XAF 950 0 CFA Franc BEAC
XAG 961 0 Silver
XAU 959 0 Gold
XCD 951 2 East Caribbean Dollar
XCG 532 2 Caribbean Guilder
XDR 960 0 Special Drawing Rights
XOF 952 0 CFA Franc BCEAO
XPD 964 0 Palladium
XPF 953 0 CFP Franc
XPT 962 0 Platinum
XTS 963 0 Test currency (ISO 4217 reserved; do not use in production)
YER 886 2 Yemeni Rial
ZAR 710 2 South African Rand
ZMW 967 2 Zambian Kwacha
ZWG 924 2 Zimbabwe Gold
ZWL 932 2 Zimbabwean Dollar (historical; superseded by ZWG 2024)

CLF (Unidad de Fomento) is the only currency with 4 minor units in this catalogue. ISO 4217 also defines UYW with 4, but the catalogue omits the fund and bond codes (BOV CHE CHW COU MXV USN UYI UYW, XBAXBD, XSU, XUA) and the no-currency code XXX: they denote accounting units and placeholders rather than money a value can be denominated in. The four historical codes HRK, SLL, ZWL, and BGN are retained for compatibility but should not be used for new data.

9.3 Cryptocurrencies

50 cryptocurrencies are supported, with 3- or 4-letter uppercase tickers. They occupy ids 900166 … 900215, after the fiat codes in the same block 90, and like them have no named bu_* enumerators — they are resolved by bvn_parse_currency_str and carried as the numeric base value. That they happen to follow the fiat run carries no meaning: bvn_unit_is_crypto reads the catalogue row, not the id (§9.2). The minor_unit field holds the canonical on-chain decimal places. numeric_code = 0 for all cryptocurrencies.

Min = minor_unit = on-chain decimal places. E.g. <uint:64,$BTC> stores satoshis; divide by 10⁸ to obtain whole BTC.

Code Min Subunit Name
BTC 8 satoshi Bitcoin
ETH 18 wei Ethereum
SOL 9 lamport Solana
XRP 6 drop XRP
BNB 18 BNB
ADA 6 lovelace Cardano
LTC 8 Litecoin
DOT 10 planck Polkadot
XMR 12 piconero Monero
ETC 18 Ethereum Classic
BCH 8 Bitcoin Cash
XLM 7 stroop Stellar
FIL 18 Filecoin
ICP 8 Internet Computer
TRX 6 TRON
EOS 4 EOS
VET 18 VeChain
NEO 0 Neo
ZEC 8 Zcash
UNI 18 Uniswap
ARB 18 Arbitrum
SUI 9 Sui
TON 9 Toncoin
INJ 18 Injective
SEI 6 Sei
APT 8 Aptos
TAO 9 Bittensor
WIF 6 dogwifhat
DOGE 8 koinu Dogecoin
LINK 18 Chainlink
USDT 6 Tether
USDC 6 USD Coin
AVAX 18 Avalanche
ATOM 6 Cosmos
POL 18 Polygon
NEAR 24 NEAR Protocol
ALGO 6 Algorand
HBAR 8 Hedera
AAVE 18 Aave
MKR 18 Maker
DAI 18 Dai
STX 6 Stacks
GRT 18 The Graph
LDO 18 Lido DAO
BONK 5 Bonk
PEPE 18 Pepe
SHIB 18 Shiba Inu
JUP 6 Jupiter
PYTH 6 Pyth Network
RUNE 8 THORChain

9.4 Prefix Rules for Currency Units

All SI prefixes are permitted on all currency units. k~$USD denotes "values in thousands of USD" — a common scale annotation in financial reporting. As with a physical unit (§4.3), the ~ is optional: k$USD is the same unit, because the $ sigil already separates the prefix from the code.

.fund_nav   = <float_dec:64,k~$USD>    250.0;   # $250,000
.gdp        = <float_dec:64,M$EUR>  42800.0;    # €42.8 billion — compact prefix
.eth_gwei   = <float_dec:64,G~$ETH>    35.0;    # 35 Gwei gas price

The sigil itself is not optional in either spelling: kUSD is error_unit_illegal, exactly as bare USD is.

IEC binary prefixes (Ki~, Mi~, …) are forbidden on all currency units, compact spelling included (Ki$USD is an error too). bvn_currency_prefix_valid() returns false for any IEC prefix; the parser raises error_unit_illegal.

9.5 Compound Currency Expressions

Currency codes participate in compound unit expressions using the existing separators:

.gold_spot    = <float_dec:64,$USD/oz_t>    2351.40;  # $/troy oz
.wheat        = <float_dec:64,$USD/bsh>        5.82;  # $/bushel
.rent         = <float_dec:64,$EUR/m²>        12.50;  # €/m²
.billing_rate = <float_dec:64,$EUR/h>         150.00; # €/h
.eur_usd      = <float_dec:64,$USD/$EUR>        1.0842; # exchange rate
.eth_btc      = <float_dec:64,$BTC/$ETH>       0.05610; # cross-crypto rate

Currency × currency compounds (USD·EUR) are syntactically valid and produce no error. Their financial interpretation is the application's responsibility.

Exchange Rate Timestamps

Bovnar annotates denomination; it does not store exchange rates or timestamps. The timestamp belongs in a separate field:

.snapshot = {
    .epoch    = <uint:64,s>              1716400000;
    .eur_usd  = <float_dec:64,$USD/$EUR>        1.0842;
};

9.6 Compatibility Rules

bvn_units_compatible() returns false for every unit containing a currency — including a currency against itself. It is dimension-based, and a currency deliberately has no SI conversion row, so bvn_unit_dimension_vector fails and the compatibility test fails with it. bvn_units_compatible($USD, $USD) is false.

Do not use it as the gate before converting money: it refuses the identity. Use the conversion functions directly — bvn_unit_convert_factor, bvn_unit_convert_value, bvn_unit_convert_rational — which carry their own currency path and report success or failure themselves.

What that path accepts is the same currency expression, differing at most in prefixes:

From To Result
$USD $USD 1 (identity)
k~$USD $USD 1000 — a prefix delta is an exact power of ten (or two, for IEC), so it stays lossless
$USD·oz_t⁻¹ oz_t⁻¹·$USD 1 — the match is a multiset, because unit multiplication commutes
$USD $EUR refused — bu_usd ≠ bu_eur, and the library holds no exchange rate
$USD/oz_t $USD/g refused, though only the denominator changes and no rate is involved. A compound carrying a currency is matched as a whole; the physical part is not converted independently. Convert the physical quantity separately if you need this
$USD m, no_unit, % refused

The mechanism is bvn_unit_prefix_only_delta in bovnar_si_units.c, consulted only after bvn_units_compatible has said no — so a unit that does have an SI row keeps taking the ordinary, fully general path.

9.7 Type Pairing Recommendations

Use case Recommended annotation Rationale
Decimal monetary amount <float_dec:64,$USD> Exact decimal; 16 significant digits
High-precision / actuarial <float_dec:128,$USD> 34 significant digits
Integer minor-unit storage <uint:64,$USD> Value in cents; app reads minor_unit()
Negative balances in minor units <sint:64,$USD>
Zero-minor-unit currency <uint:64,$JPY> Integer is the only correct representation
3-minor-unit currency <uint:64,$KWD> Value in fils
Commodity price <float_dec:64,$USD/oz_t> $/troy oz
Exchange rate <float_dec:64,$USD/$EUR> USD per EUR
On-chain satoshi balance <uint:64,$BTC> Integer satoshis
Human-readable BTC amount <float_dec:64,$BTC>

float (binary floating-point) is discouraged for monetary amounts. Binary fractions cannot represent 0.10 USD exactly. Use float_dec for decimal-exact storage.

float_fix is wrong for monetary values. Q-format stores values as integer × 2^(-N) — a binary fractional resolution. No power of 2 equals a power of 10 (except 2⁰ = 10⁰ = 1), so no Q value exactly represents cents.


10. Symbol Disambiguation

This section documents how a physical-unit token and a currency token are kept apart. The mandatory $ currency sigil (§10.4) is the normative rule and resolves every potential collision; the look-alike tables that follow are retained for reference.

10.1 The Namespace Rule as Disambiguator

The mandatory $ sigil (§9.1, normative rule in §10.4) makes the two namespaces disjoint by construction: the sigil — and nothing else — selects the currency table, so a bare token is always a physical-unit lookup and can never be mistaken for a currency.

Written token Looked up in Result
$USD, $BTC, k~$EUR, k$EUR currency table (sigil present) currency, or error_unit_illegal if the code is unknown
m, Hz, cup, BTU, k~g (no $) physical unit table only physical unit, or error_unit_illegal if unknown
USD, CUP, XYZ (no $) physical unit table only error_unit_illegal — not physical units, and a bare code is never a currency

Because the lookup is sigil-driven rather than spelling-driven, case is no longer load-bearing for disambiguation: cup and CUP are both physical-unit lookups (the first matches bu_cup, the second is error_unit_illegal), and the Cuban Peso is written $CUP.

10.2 Exhaustive Conflict Table

Before the sigil, several uppercase tokens looked like they could be either a physical unit or a currency. The sigil removes the ambiguity outright; the table below is retained as a reference for the codes that previously needed disambiguation. In every row, the bare form is a physical-unit lookup and the currency is only ever the $-prefixed form.

Token Bare form (no $) $-sigil form Notes
cup / CUP cup → US cup (bu_cup); CUPerror_unit_illegal $CUP → Cuban Peso (ISO 4217:192) the classic look-alike, now fully separated
BTU BTU → International Table BTU (bu_btu); Btu, btu also accepted (not ISO 4217) uppercase BTU is a physical alias, not a currency
SOL SOLerror_unit_illegal (no physical unit) $SOL → Solana (crypto)
BAR BARerror_unit_illegal; use lowercase bar (not ISO 4217)
ERG ERGerror_unit_illegal; use lowercase erg (not ISO 4217)
CAD, AUD, GBP, XAU error_unit_illegal (no physical unit) $CAD, $AUD, $GBP, $XAU → the respective currencies

Key finding: with the sigil, no token is simultaneously a valid bare physical-unit symbol and a valid currency — currencies live entirely under $, physical units entirely without it.

10.3 The CUP Case in Detail

CUP is the classic example because the ISO 4217 code for the Cuban Peso shares its letters with the English word for the culinary measure. Under the sigil rule the two are unambiguous:

Written in BVNR Resolved as Enum value SI factor
cup US cup bu_cup 2.365882365×10⁻⁴ m³
cups US cup (long form) bu_cup 2.365882365×10⁻⁴ m³
CUP (error) — bare uppercase is not a physical unit error_unit_illegal
$CUP Cuban Peso CUP_ (currency enum, ISO 4217:192) — (monetary, no SI factor)
.recipe_volume = <float_dec:32,cup>  2.0;    # 2 US cups (volume)
.balance       = <float_dec:64,$CUP> 15.00;  # 15 Cuban Pesos (currency)
# .bad         = <float_dec:64,CUP>  15.00;  # error_unit_illegal: bare 'CUP' is not a unit

Calling bvn_unit_to_si_factor on a $CUP unit returns *ok = false because bvn_find_si_conv skips currency enum values. Calling bvn_unit_is_currency on a cup unit returns false. The two are completely disjoint in both parsing and the conversion API — and, because the peso must be written $CUP, a user who writes the bare CUP intending the culinary cup gets an immediate error_unit_illegal (the correct spelling is lowercase cup) rather than a silently-accepted currency.

10.4 The Mandatory Currency Sigil

A currency code carries a mandatory $ sigil as of spec 1.0. This is the resolution of the CUP (Cuban Peso) vs cup (the physical cup) namespace collision: a currency is only recognised in its sigil form, so a bare code can never collide with a physical-unit symbol — present or future.

# Currencies — the '$' sigil is required:
.price   = <float_dec:64,$USD>      19.99;   # US Dollar
.btc     = <uint:64,$BTC>        54782000;   # Bitcoin (satoshis)
.fund    = <float_dec:64,k~$EUR>   250.0;    # kilo-Euro (prefix before the sigil)
.aum     = <float_dec:64,M$EUR>    42.8;     # mega-Euro, compact prefix (= M~$EUR)
.spot    = <float_dec:64,$USD/oz_t> 2351.40; # currency / physical-unit compound

# A bare code is no longer a currency:
.volume  = <float_dec:32,cup>        2.0;    # the physical 'cup', unambiguously
# .bad   = <float_dec:64,USD> 1.0;            # error_unit_illegal: bare 'USD' is not a unit (needs '$')

The sigil attaches directly before the currency code, after any SI/IEC prefix and its optional ~ (k~$EUR or k$EUR, M~$USDT or M$USDT; §4.3). It is accepted in inline units and type annotations alike, and the writer emits it on output so values round-trip. Because this breaks any document that used bare currency codes, it was made before the 1.0 freeze — afterwards it would be an incompatible change (see the Versioning & Stability section of the specification).


11. C Data Model

11.1 Enumerations

prefix_system_t

typedef enum prefix_system_e {
    prefix_si,      /* SI decimal prefixes (or no prefix) */
    prefix_iec      /* IEC binary prefixes                */
} prefix_system_t;

si_prefix_id_t

typedef enum si_prefix_id_e {
    si_none = 0,
    si_quecto, si_ronto, si_yocto, si_zepto, si_atto,
    si_femto,  si_pico,  si_nano,  si_micro, si_milli,
    si_centi,  si_deci,
    si_deca,   si_hecto, si_kilo,  si_mega,  si_giga,
    si_tera,   si_peta,  si_exa,   si_zetta, si_yotta,
    si_ronna,  si_quetta
} si_prefix_id_t;

iec_prefix_id_t

typedef enum iec_prefix_id_e {
    iec_none = 0,
    iec_kibi, iec_mebi, iec_gibi, iec_tebi, iec_pebi,
    iec_exbi, iec_zebi, iec_yobi, iec_robi, iec_quebi
} iec_prefix_id_t;

value_base_unit_t

A base unit's id is blocked, not a running counter: the leading two decimal digits name the vocabulary it comes from and the four digits after them its position within that vocabulary. Each block therefore holds 10 000 ids, and a vocabulary that grows shifts nothing outside itself.

Block Ids Vocabulary Source
10 100000–109999 native bovnar units src/gendata/units.bvnr
20 200000–209999 UCUM opaque units src/gendata/ucum.bvnr
30 300000–309999 UN/ECE opaque units src/gendata/unece.bvnr
40 400000–409999 QUDT opaque units src/gendata/qudt.bvnr
50 500000–509999 QUDT quantity kinds src/gendata/qudt-qk.bvnr
60 600000–609999 UDUNITS-2 opaque units src/gendata/udunits.bvnr
70 700000–709999 OM 2 opaque units src/gendata/om.bvnr
80 800000–809999 CF standard names src/gendata/cf.bvnr
90 900000–909999 currencies src/gendata/currencies.bvnr

bu_none is 0 and belongs to no block; every tag between the native units and the currencies is now taken, so a further vocabulary needs one outside 10–90. Blocks 40–80 currently contribute no ids at all: those vocabularies map every code onto a native unit, and the block tag reserves their room rather than filling it. Within block 10 the native units run contiguously from bu_bit = 100000 to bu_stattesla = 100263, in the order of units.bvnr; BVN_UNIT_NATIVE_FIRST, BVN_UNIT_NATIVE_LAST and BVN_UNIT_NATIVE_COUNT are generated beside the enum, so read the bounds from those rather than from this sentence. Currencies run contiguously from 900000 and — unlike every other block — have no named bu_* enumerators (see §9.2/§9.3): a currency is written by its ISO 4217 code behind a $ sigil, resolved by bvn_parse_currency_str and carried as the numeric base value. bvn_unit_is_currency(base) is a bounds check over block 90.

Only a vocabulary that contributes units of its own takes a block. A unit profile is a spelling for the unit slot, so most of its codes translate to native units and carry native ids; the ones that get a block id of their own are the opaque units — codes with no native equivalent and no dimension, such as UCUM's assay-defined [IU] or UN/ECE's package types — which need an identity precisely because nothing else can stand in for them.

Why blocks. One flat counter makes every vocabulary's ids a function of every other vocabulary's size, and that was not hypothetical. This space previously had physical units at 1–133 and again at 348–396 because the currencies had been dropped in between; two currencies stranded at 378–379, outside their own range, because that range had been frozen; and the profiles' units pinned above all of it. Adding a currency shifted units. The 2.0 renumbering ended that, and made an id self-describing: 200017 is UCUM's, whatever else the build contains.

The price is a sparse space, so do not index an array by a base unit id. The library's own tables are dense, one row per defined unit, indexed by bvni_unit_slot(); BVN_UNIT_SLOT_COUNT is that row count. A bounds check is no longer a membership test either — ask bvn_unit_valid on the unit, or bvn_unit_is_currency on the base.

typedef enum value_base_unit_e {
    bu_none = 0,            /* dimensionless / no unit; in no block */

    /* Digital — block 10 opens here */
    bu_bit = 100000, bu_byte,

    /* SI base */
    bu_second, bu_meter, bu_gram, bu_ampere, bu_kelvin,
    bu_mol, bu_candela,

    /* Named SI-derived */
    bu_hertz, bu_newton, bu_pascal, bu_joule, bu_watt,
    bu_volt,  bu_ohm,   bu_farad,  bu_coulomb, bu_siemens,
    bu_weber, bu_tesla, bu_henry,  bu_lumen,   bu_lux,
    bu_becquerel, bu_gray, bu_sievert, bu_katal,
    bu_liter, bu_minute, bu_hour, bu_day, bu_degree, bu_celsius,
    bu_radian, bu_steradian,
    bu_tonne, bu_bar,
    bu_electronvolt, bu_dalton, bu_astronomical_unit,
    bu_hectare, bu_week, bu_year,

    /* Imperial/US — length */
    bu_inch, bu_foot, bu_yard, bu_mile, bu_nautical_mile,
    bu_angstrom, bu_light_year, bu_parsec, bu_furlong, bu_fathom,

    /* Imperial/US — mass */
    bu_pound, bu_ounce, bu_grain, bu_stone, bu_short_ton,
    bu_long_ton, bu_troy_ounce, bu_carat,

    /* Temperature */
    bu_fahrenheit,

    /* Pressure */
    bu_atmosphere, bu_mmhg, bu_torr, bu_psi,

    /* Energy */
    bu_calorie, bu_btu, bu_erg, bu_therm,

    /* Power */
    bu_horsepower,

    /* Force */
    bu_pound_force, bu_dyne, bu_kip,

    /* Speed */
    bu_knot,

    /* Volume — US */
    bu_gallon, bu_gallon_uk, bu_quart, bu_pint, bu_cup,
    bu_fluid_ounce, bu_tablespoon, bu_teaspoon, bu_barrel,

    /* Area */
    bu_acre, bu_barn,

    /* Angle */
    bu_arcminute, bu_arcsecond, bu_grad,

    /* CGS */
    bu_poise, bu_stokes, bu_gauss, bu_maxwell, bu_oersted,
    bu_stilb, bu_phot, bu_galileo,

    /* Radiation */
    bu_curie, bu_roentgen, bu_rem,

    /* Logarithmic */
    bu_neper, bu_decibel,

    bu_rankine,             /* Temperature — absolute Fahrenheit scale */
    bu_slug,                /* Imperial mass */
    bu_thou,                /* Imperial length */

    /* Volume — UK imperial */
    bu_pint_uk, bu_fluid_ounce_uk, bu_quart_uk,

    /* Electrical power */
    bu_var, bu_volt_ampere,

    /* Force (additional) */
    bu_kilogram_force,

    /* Pressure (additional) */
    bu_inch_hg,

    /* Rotational frequency */
    bu_rpm,

    /* Energy (additional) */
    bu_foot_pound,

    /* Mass (additional) */
    bu_dram, bu_pennyweight,

    /* Length (additional) */
    bu_chain, bu_rod,

    /* Volume (additional) */
    bu_gill, bu_gill_uk,

    /* Acceleration / gravity */
    bu_standard_gravity,

    /* Power (additional) */
    bu_metric_horsepower,

    /* Angle (additional) */
    bu_revolution,

    /* Time (additional) */
    bu_month, bu_fortnight,

    /* Pressure (additional) */
    bu_atmosphere_technical,

    /* Textile */
    bu_tex, bu_denier,

    /* Apothecary / dry volume */
    bu_fluid_dram, bu_minim, bu_peck, bu_bushel,

    /* Old German */
    bu_pfund, bu_zentner, bu_doppelzentner, bu_lot,
    bu_prussian_line, bu_prussian_zoll, bu_prussian_fuss,
    bu_prussian_elle, bu_prussian_rute, bu_klafter,
    bu_german_mile, bu_morgen, bu_scheffel,

    /* Additional physical units */
    bu_survey_foot, bu_league, bu_cable, bu_hand,
    bu_quintal, bu_scruple, bu_baud,

    /* Historical temperature scales */
    bu_delisle, bu_newton_temp, bu_reaumur, bu_romer,

    /* Dimensionless ratio units */
    bu_percent, bu_per_mille, bu_per_myriad,
    bu_per_cent_mille, bu_ppm, bu_ppb,   /* bu_ppt and bu_ppq close
                                          * block 10 — see the note below */

    /* … the pH scale, the named speed units, the five water-hardness
     * degrees, the concentration units, and the turbidity and salinity
     * scales — then: */

    /* Temperature differences (Δ°C shares bu_delta_kelvin, Δ°Ra shares
     * bu_delta_fahrenheit — the same interval, so an alias not an id) */
    bu_delta_kelvin, bu_delta_fahrenheit, bu_delta_delisle,
    bu_delta_newton_temp, bu_delta_reaumur, bu_delta_romer,

    /* … the six units the profiles needed, the US survey lengths, the
     * typographic lengths, the US dry volumes and the trade measures, and
     * the rest of block 10 up to bu_horsepower_water = 100260. The whole run is
     * generated into include/bovnar_units.gen.h from
     * src/gendata/units.bvnr, in that file's order — read it there rather
     * than from this abridged listing. */

    /* Block 90 — the currencies — has NO named enumerators: a currency is
     * resolved by string via bvn_parse_currency_str and carried as the
     * numeric base value, and the catalogue in bovnar_currency.c is
     * index-aligned to BVN_CURRENCY_FIRST. */

    /* Blocks 20–80 — the profiles' opaque units — are generated into
     * include/bovnar_profiles.gen.h: bu_ucum_iu = 200000, bu_unece_one =
     * 300000, and so on. Only 20 and 30 contribute any today. */
} value_base_unit_t;

/* Not a member of the enum, and NOT a bound on it: the number of rows in the
 * dense tables the library indexes by bvni_unit_slot(). The id space is
 * sparse, so it has no "one past the end". */
/* #define BVN_UNIT_SLOT_COUNT 331 */

unit_exponent_t

#define BVN_EXPONENT_MIN    (-100)
#define BVN_EXPONENT_MAX    ( 100)

typedef enum unit_exponent_e {
    exp_range_min  = BVN_EXPONENT_MIN,
    exp_range_max  = BVN_EXPONENT_MAX,
    exp_invalid    =   0,
    exp_linear     =   1,  exp_square    =  2,  exp_cubic     =  3,
    exp_quartic    =   4,  exp_quintic   =  5,  exp_sextic    =  6,
    exp_septic     =   7,  exp_octic     =  8,  exp_nonic     =  9,
    exp_neg_linear =  -1,  exp_neg_square= -2,  exp_neg_cubic = -3,
    exp_neg_quartic=  -4,  exp_neg_quintic=-5,  exp_neg_sextic= -6,
    exp_neg_septic =  -7,  exp_neg_octic = -8,  exp_neg_nonic = -9,
} unit_exponent_t;

The named enumerators are not the range. They cover ±1…±9 and are kept because callers use them; the type carries any integer in [BVN_EXPONENT_MIN, BVN_EXPONENT_MAX] = ±100, which is what §6 and the grammar describe. exp_range_min/exp_range_max are enumerators so that the underlying type is wide enough for them on every conforming compiler; neither is a valid exponent to use. Do not switch exhaustively over this enum, and do not assume a value outside the named nine is invalid — ask bvn_int_to_exponent, which returns exp_invalid for anything out of range.

exp_invalid (value 0) is the zero-initialization sentinel. Always initialize components before use.

11.2 Structures

value_unit_component_t

typedef struct value_unit_component_s {
    value_base_unit_t   base;
    unit_exponent_t     exponent;
    value_unit_prefix_t prefix;
} value_unit_component_t;

where:

typedef struct value_unit_prefix_s {
    prefix_system_t system;
    union {
        si_prefix_id_t   si;
        iec_prefix_id_t  iec;
    } id;
} value_unit_prefix_t;

value_unit_t

#define BVNR_MAX_UNIT_COMPONENTS  32

typedef struct value_unit_s {
    uint32_t               num_components;
    value_unit_component_t components[BVNR_MAX_UNIT_COMPONENTS];
} value_unit_t;

For an explicit no_unit annotation, num_components == 0 (BVN_UNIT_NONE). For an absent unit parameter in an explicit annotation (e.g. <float:64>), num_components == 1 with base == bu_none.

bvnr_data_t (unit field)

typedef struct bvnr_data_s {
    token_type_t       type;
    value_type_spec_t  value_type;
    value_unit_t       value_unit;   /* parsed physical unit or currency */
    const void*        data;
    uint32_t           length;
    const void*        frac_data;    /* spec 1.1 — ISO datetime sub-second digits, else NULL */
    uint32_t           frac_length;  /* spec 1.1 — length of frac_data, else 0 */
    bool               converted;    /* a read-time conversion restated this value */
    bvnr_converted_t   conv;         /* its result: the unit delivered, and the digits */
} bvnr_data_t;

converted and conv are the unit-facing half of the struct: when a want_unit hook or a unit policy restated a value, conv carries the unit it was delivered in and the converted digits, while value_unit still carries the unit the document wrote. A consumer that requested a conversion must read converted to know whether it happened — see Unit Policy §2.2.

11.3 Convenience Macros

/* Dimensionless or single-component without prefix */
#define BVN_UNIT_NO_PREFIX(b)          \
    ((value_unit_t){ .num_components = 1, .components = {{  \
        .base=(b), .exponent=exp_linear,                    \
        .prefix.system=prefix_si, .prefix.id.si=si_none }}})

/* Single-component with SI prefix */
#define BVN_UNIT_SI(b, p)  \
    ((value_unit_t){ .num_components = 1, .components = {{  \
        .base=(b), .exponent=exp_linear,                    \
        .prefix.system=prefix_si, .prefix.id.si=(p) }}})

/* Single-component with IEC prefix */
#define BVN_UNIT_IEC(b, p) \
    ((value_unit_t){ .num_components = 1, .components = {{  \
        .base=(b), .exponent=exp_linear,                    \
        .prefix.system=prefix_iec, .prefix.id.iec=(p) }}})

/* Single-component with SI prefix and explicit exponent */
#define BVN_UNIT_SI_EXP(b, p, e) \
    ((value_unit_t){ .num_components = 1, .components = {{  \
        .base=(b), .exponent=(e),                           \
        .prefix.system=prefix_si, .prefix.id.si=(p) }}})

/* Empty unit — explicit no_unit / internal sentinel */
#define BVN_UNIT_NONE  ((value_unit_t){ .num_components = 0 })

/* Two-component compound unit, both SI-prefixed */
#define BVN_UNIT_COMPOUND2(b1,p1,e1, b2,p2,e2)                        \
    ((value_unit_t){ .num_components = 2, .components = {              \
        { .base=(b1), .exponent=(e1),                                  \
          .prefix.system=prefix_si, .prefix.id.si=(p1) },              \
        { .base=(b2), .exponent=(e2),                                  \
          .prefix.system=prefix_si, .prefix.id.si=(p2) }}})

Usage:

value_unit_t kg  = BVN_UNIT_SI(bu_gram, si_kilo);
value_unit_t gib = BVN_UNIT_IEC(bu_byte, iec_gibi);
value_unit_t m2  = BVN_UNIT_SI_EXP(bu_meter, si_none, exp_square);
value_unit_t usd = BVN_UNIT_NO_PREFIX(bu_usd);    /* single currency unit */
value_unit_t none = BVN_UNIT_NONE;

12. C API Functions

12.1 Parsing a Unit String

value_unit_t bvn_parse_unit  (const uint8_t* unit, bool* ok);
value_unit_t bvn_parse_unit_n(const uint8_t* unit, uint32_t len, bool* ok);

bvn_parse_unit parses a NUL-terminated UTF-8 unit string. bvn_parse_unit_n is the length-bounded variant used internally when the unit string is a slice of a larger type-annotation buffer. Both set *ok = false on any parse error (unknown prefix, unknown base unit, unknown currency code, too many components, empty component, or NULL input).

Single-pass parsing algorithm:

  1. memcmp against "no_unit" → return BVN_UNIT_NONE immediately on match.
  2. Scan for separator characters to distinguish simple vs. compound paths.
  3. For compound units, split on 0x2A (*), 0x2F (/), 0xC2 0xB7 (·); parse each slice as a component; negate denominator exponents.
  4. For each component, if it is introduced by the $ sigil (after any prefix and its optional ~), look the code up in the currency table; otherwise look it up in the physical unit table only. A bare code is never a currency.
bool ok;
value_unit_t u = bvn_parse_unit((const uint8_t *)"k~g·m/s²", &ok);
/* u.num_components == 3:
   [0]: bu_gram,   exp_linear,     si_kilo
   [1]: bu_meter,  exp_linear,     si_none
   [2]: bu_second, exp_neg_square, si_none */

value_unit_t c = bvn_parse_unit((const uint8_t *)"USD/oz_t", &ok);
/* c.num_components == 2:
   [0]: bu_usd,       exp_linear,     si_none  (currency)
   [1]: bu_troy_ounce, exp_neg_linear, si_none */

12.2 Serializing a Unit

int32_t bvn_unit_to_string   (value_unit_t u, char* buf, size_t bufsize);
int32_t bvn_unit_to_string_ex(value_unit_t u, char* buf, size_t bufsize,
                               bvn_unit_flags_t flags);

Serializes u to a canonical UTF-8 string. Returns the number of bytes written (excluding NUL) or -1 on buffer overflow or invalid component.

Flag Effect
BVN_UNIT_FLAGS_NONE Unicode superscript exponents, no reduction
BVN_UNIT_ASCII_EXP ASCII caret form (^N) for all exponents
BVN_UNIT_REDUCE Reduce via bvn_unit_reduce before serializing

BVN_UNIT_REDUCE changes the unit, and this function does not change the value. Reduction folds every prefix out, so k~g serializes as "g" — a string that denotes a quantity 1000× smaller than what you passed in. The value has to move with it, and the writer does that for you (5 k~m is written as 5000 m, in exact rational arithmetic); nothing else does.

A direct caller must not use bvn_unit_reduce's scale for this. That is the scale to the fully reduced unit, and this function does not always emit the fully reduced unit: where the reduction lands on a named SI unit the formatter re-attaches the prefix, so k~N comes back "k~N" with nothing to rescale, while bvn_unit_reduce still reports 1000. Applying it there multiplies by a thousand twice over, and the two cases are indistinguishable from outside — both are a lone unit carrying a kilo prefix.

The recipe is to convert to the unit that is actually emitted, which is what bvnr_writer.c does:

c char ubuf[BVNR_UNIT_STRING_MAX]; if (bvn_unit_to_string_ex(u, ubuf, sizeof ubuf, BVN_UNIT_REDUCE) < 0) return false; /* overflowing reduction */ bool ok = false; value_unit_t emitted = bvn_parse_unit((const uint8_t *)ubuf, &ok); if (!ok || bvn_unit_equal(emitted, u)) return true; /* nothing moved */ /* now convert the value from `u` to `emitted` — exactly, if it must be exact */ bvn_unit_convert_rational(vnum, vden, u, emitted, out_num, out_den, &exact);

bvn_unit_reduce's scale remains the right answer for its own returned unit; it is simply not the unit this function prints.

The collapse never substitutes one named unit for another. Sv and Gy share a dimension vector, as do Bq, Bd and Hz, and W, VA and var; each reduces to itself. A lone base at an exponent other than 1 is a different case and still collapses — s⁻¹ is what Hz names, and m~s⁻¹ comes back k~Hz.

An overflowing reduction returns -1 rather than a unit. When a summed exponent leaves the range the format can spell, more bases survive than a unit may carry, or the folded scale leaves float range, bvn_unit_reduce drops a component — so the result is a different unit, not a shorter spelling of the same one. m¹⁰⁰·m² is m¹⁰², and such a unit used to serialise as "no_unit". Without BVN_UNIT_REDUCE these units write normally. Rewriting an equivalent dose as an absorbed dose in the document would be a stronger act than offering the conversion, which §12.4 still does when a caller asks.

char buf[64];
value_unit_t u = /* k~g·m/s² */;
bvn_unit_to_string(u, buf, sizeof(buf));
/* buf == "k~g·m/s²" */

bvn_unit_to_string_ex(u, buf, sizeof(buf), BVN_UNIT_ASCII_EXP);
/* buf == "k~g*m/s^2" */

Validation Predicate

bool bvn_unit_valid(value_unit_t u);

Returns true if every component has a valid exponent, known base unit (physical or currency), and a legal prefix for that base. Both serialization functions call this before writing.

12.3 Prefix Factor and Exponent Queries

double  bvn_unit_prefix_factor  (value_unit_t u);
int32_t bvn_unit_prefix_exponent(value_unit_t u);

bvn_unit_prefix_factor returns the multiplicative scale contributed by the prefixes, ignoring base-unit identity. For si_none/iec_none prefixes the factor is 1.0.

bvn_unit_prefix_exponent returns the sum of (prefix_base_exponent × unit_exponent) across all components — the component exponent signed, not its magnitude, so m/k~m is -3 and k~m⁻¹ is -3 while k~m is +3.

The sum is only meaningful when one prefix system is in play. SI exponents are powers of ten and IEC exponents powers of two, and this function adds them into a single integer: Ki~B/k~s returns 7, which is neither 2⁷ nor 10⁷ — the actual scale is 1.024. For a mixed unit use bvn_unit_prefix_factor, which multiplies the two systems' factors correctly, or read the components yourself.

12.4 SI Conversion API

Functions in bovnar_si_units.h provide dimensional analysis, compatibility checking, and value conversion between compatible physical units. A currency has no SI conversion row at all — it has a catalogue row in bovnar_currency.c instead — so bvn_unit_to_si_factor and bvn_unit_dimension_vector set *ok = false / return false for one, and bvn_units_compatible reports a currency incompatible even with itself. bvn_units_convertible, bvn_unit_convert_value and bvn_unit_convert_rational still handle currencies, through the prefix-only path described below; that difference is the reason the first of those three exists.

/* Full SI factor (physical units only) */
double bvn_unit_to_si_factor(value_unit_t u,
                              bool   *is_affine,
                              double *affine_offset,
                              bool   *ok);

/* SI dimension vector */
bool bvn_unit_dimension_vector(value_unit_t u,
                                int32_t dims[bvn_si_dim_count]);

/* Dimensional compatibility check */
bool bvn_units_compatible(value_unit_t a, value_unit_t b);

/* The predicate to SCREEN a conversion target with */
bool bvn_units_convertible(value_unit_t a, value_unit_t b);

/* The coherent SI form of a unit, if it has one */
bool bvn_unit_si_normal_form(value_unit_t u, value_unit_t *out);

/* Conversion factor: value_in_b = value_in_a × k */
double bvn_unit_convert_factor(value_unit_t a, value_unit_t b,
                                bool *ok, bool *requires_affine);

/* Unit reduction */
value_unit_t bvn_unit_reduce(value_unit_t u,
                              double *scale, bool *overflow);

/* Prefix validity */
bool bvn_prefix_unit_valid(value_unit_prefix_t prefix,
                            value_base_unit_t base);

/* Convert one value (handles the affine scales) */
bool bvn_unit_convert_value(double value, value_unit_t from,
                             value_unit_t to, double *out);

/* Convert one EXACT rational — the lossless path */
bool bvn_unit_convert_rational(const bvn_int_t *vnum, const bvn_int_t *vden,
                                value_unit_t from, value_unit_t to,
                                bvn_int_t *out_num, bvn_int_t *out_den,
                                bool *exact);

/* Exponent integer conversion */
int32_t        bvn_exponent_to_int (unit_exponent_t e);
unit_exponent_t bvn_int_to_exponent(int32_t n);

bvn_unit_convert_rational is the engine behind the reader's want_unit hook and behind every conversion a unit policy performs: it converts the exact rational vnum/vden in arbitrary precision, so a 1056-bit float or a 512-bit integer converts with no loss beyond the library's own declared factor. It sets *exact = false when the true factor is irrational (a π-based angle, a parsec, a water-hardness scale) — the result is then only an approximation and a lossless consumer must reject it. It returns false in three cases a caller reporting a diagnosis must tell apart: the units are dimensionally incompatible, the unit is structurally invalid, or the exact factor needs more than BVN_INT_MAX_BITS (reachable only from deliberately extreme units — Q~m¹⁰⁰·Q~g¹⁰⁰ to q~m¹⁰⁰·q~g¹⁰⁰ needs 10^12000 — and not a statement that the units disagree). Ask bvn_units_convertible to separate the first two from the third. To render the result, see bvn_rational_to_str and bvn_rational_str_bufsize in §3.4 of the read/write API — the renderer never truncates, so size the buffer with the second before calling the first.

Every out-parameter above is optional. NULL means "do not report this one"; the function behaves identically otherwise and its return value is unchanged. That makes bvn_unit_dimension_vector(u, NULL) the bare predicate "does u have a dimension vector at all", and bvn_unit_si_normal_form(u, NULL) the predicate "does u have an SI form". The rule does not extend to an argument the answer is made of: a NULL bvn_int_t in bvn_unit_convert_rational, or a NULL buffer handed to a formatter, is a refused call.

bvn_units_convertible is the one to screen a conversion target with, and it is not bvn_units_compatible. That function answers "do these two carry the same physical dimension", and a currency deliberately carries none — so it reports false for k~$USD → $USD and even for $USD → $USD, both of which the conversion entry points perform correctly. bvn_unit_convert_factor is not the answer either: it reports *ok = false for °F → °C, because an affine conversion has no single multiplicative factor, so screening on it drops every temperature in the format. bvn_units_convertible is "dimensionally compatible, or the same unit apart from its prefixes", which is the set bvn_unit_convert_value and bvn_unit_convert_rational draw from — a superset of what they accept, not the same set.

It is a screen, not a guarantee, and the gap is exactly two shapes wide. Code that screens with this must still handle a conversion that declines, and the two reasons it can decline are worth telling apart:

Nothing else is in the gap. python/tests/test_convertible_implies_convertible.py sweeps the catalogue and its quotients and squares — a domain that cannot reach the capacity bound — and requires every pair that passes the screen and is then refused to be the affine shape; a companion case pins the capacity shape directly.

The reader's unit policy closes the gap on its own side rather than living with it: bvn_policy_selects asks bvni_unit_affine_misplaced before it asks this, so a whole-document target never selects such a pair — leaving the value untouched, as an unmatched target should — while a per-field rule or a require_dimension_of naming it is error_unit_mismatch, because a rule is an assertion the caller made by naming the field. Screening on the dimensional answer alone let --field .rate=K/h report OK against a °C/h document.

bvn_unit_si_normal_form writes the coherent SI form of u — the product of SI base units carrying the same dimension, prefixes folded out (mass comes back as k~g, the SI base unit for mass being the kilogram). It returns false, leaving *out untouched, when there is no such form to name:

The form returned is checked against u with bvn_units_convertible before it is returned, so a form this function gives back is one the conversion entry points accept. A temperature interval normalises to ΔK and never to K, which the dimension vector cannot say — both are Θ¹ — so the quantity kind is consulted (§3.12). This is the function bvnr_normalise_si is built on.

For affine units (bu_celsius, bu_fahrenheit), *is_affine is set to true and *affine_offset receives the additive offset applied after multiplying by the returned factor.

An affine unit has an SI value only alone, at exponent 1. °C², °C·°F, °C/h and °C·m all set *ok = false; bvn_unit_convert_value and bvn_unit_convert_rational refuse them, and the reader turns that into error_unit_mismatch (38). The offset is a number of kelvin, and a product whose SI unit is K·s⁻¹ or K·m has nowhere to put it: the earlier code added it unscaled, so 20 °C/h converted to K/h as 983360 and 20 °C·m to K·m as 293.15 whatever the metres did. Both are arithmetic on a quantity that does not exist. °C/h still parses and is still a legal annotation — a consumer that means a temperature difference can read the components itself and apply its own semantics; what the library will not do is hand back a number for it. This matches pint, which forbids an offset unit inside a product outright.

bool ok, affine;
double offset;
value_unit_t u = bvn_parse_unit((const uint8_t *)"°C", &ok);
double f = bvn_unit_to_si_factor(u, &affine, &offset, &ok);
/* f == 1.0, affine == true, offset == 273.15 */

12.5 Currency API

#include "bovnar_currency.h"

/* Classification */
bool bvn_unit_is_currency(int base);
bool bvn_unit_is_fiat    (int base);
bool bvn_unit_is_crypto  (int base);

/* Minor-unit exponent: 1 major unit = 10^N minor units */
uint8_t bvn_currency_minor_unit(int base, bool *ok);

/* Full currency metadata */
const bvn_currency_info_t *bvn_currency_info(int base);

/* Look up by 3–4 char code string; returns 0 (bu_none) on failure */
int bvn_parse_currency_str(const uint8_t *s, uint32_t len);

/* Prefix validity for a specific currency */
bool bvn_currency_prefix_valid(int base, int prefix_system);

The bvn_currency_info_t structure:

typedef struct {
    char     code[5];           /* "USD", "BTC", etc.                  */
    uint16_t numeric_code;      /* ISO 4217 numeric code (0 for crypto) */
    uint8_t  minor_unit;        /* decimal places                       */
    bool     is_crypto;         /* true for cryptocurrencies            */
    char     name[48];          /* "US Dollar", "Bitcoin", etc.         */
} bvn_currency_info_t;

Examples:

bool ok;
uint8_t n = bvn_currency_minor_unit(bu_kwd, &ok);  /* n=3, ok=true  */
uint8_t m = bvn_currency_minor_unit(bu_jpy, &ok);  /* m=0, ok=true  */
uint8_t x = bvn_currency_minor_unit(bu_meter, &ok);/* x=0, ok=false */

const bvn_currency_info_t *ci = bvn_currency_info(bu_usd);
/* ci->code="USD", ci->numeric_code=840, ci->minor_unit=2,
   ci->is_crypto=false, ci->name="US Dollar" */

int cv = bvn_parse_currency_str((const uint8_t *)"EUR", 3);  /* cv=177 */
int cc = bvn_parse_currency_str((const uint8_t *)"DOGE", 4); /* cc=326 */
int cx = bvn_parse_currency_str((const uint8_t *)"xyz", 3);  /* cx=0   */

/* Distinguishing cup (volume) from CUP (currency) in code: */
value_unit_t volume   = bvn_parse_unit((const uint8_t *)"cup", &ok);
value_unit_t currency = bvn_parse_unit((const uint8_t *)"CUP", &ok);
assert(!bvn_unit_is_currency(volume.components[0].base));   /* true */
assert( bvn_unit_is_currency(currency.components[0].base)); /* true */

12.6 Unit Profile API (under implementation)

Four functions serve the profile notations. bvn_parse_unit itself is unchanged and takes every notation; these cover what a caller needs around it.

error_code_t bvn_unit_error_code(const uint8_t *unit, uint32_t len);
bool         bvn_unit_is_profile_only(value_unit_t u);
int32_t      bvn_unit_to_profile(const char *ns, value_unit_t u,
                                 char *buf, size_t bufsize);
int32_t      bvn_unit_to_ucum(value_unit_t u, char *buf, size_t bufsize);

bvn_unit_error_code says why a unit string bvn_parse_unit rejected is not a unit — error_unit_illegal for malformed input or an unknown atom, error_unit_profile_unknown for an unrecognised namespace, error_unit_profile_unsupported for a valid profile expression with no representation here. It re-parses, so it is for the error path only; a string that does parse returns error_none.

bvn_unit_is_profile_only is true when a unit has no native spelling, which is exactly the units carrying an opaque base unit: a UCUM arbitrary atom ([IU], [PFU], …) or a UNECE package or count code (XBX, C62, …). For those, bvn_unit_to_string emits the profile form in the namespace that owns the unit, and re-parsing that output yields the same unit.

bvn_unit_to_profile writes a code in the named vocabulary — ucum, unece, qudt, qudt-qk or udunits, without the <ns>: prefix — returning its length or a negative value. bvn_unit_to_ucum is the ucum case, kept for callers that predate the others. Both are partial by construction, in three ways: the Old German units, the water-hardness degrees, the turbidity kinds, PSU, CF, mph, kph and every currency have no code in any of these vocabularies; an opaque unit belonging to a different profile has none either; and a flat vocabulary (unece, qudt, qudt-qk) can spell only a single unprefixed component, so k~m/h has no UNECE form even though unece:KMH parses to exactly it. All three are refused rather than approximated.

char buf[BVNR_UNIT_STRING_MAX];
bool ok = true;
value_unit_t u = bvn_parse_unit((const uint8_t *)"ucum:mm[Hg]", &ok);   /* == mmHg */
bvn_unit_to_string(u, buf, sizeof buf);                                 /* "mmHg"    */
bvn_unit_to_ucum  (u, buf, sizeof buf);                                 /* "mm[Hg]"  */

Note that in a document the notation is gated on an explicit #!bovnar 1.2 directive (§11.9 of the specification) — a version this build does not itself advertise, because the notation is under implementation. These API entry points have no document and therefore no version, so they accept the notation unconditionally.

12.7 Python API

from bovnar.enums import BaseUnit
from bovnar.currency import (
    is_currency, is_fiat, is_crypto,
    minor_unit, currency_info, currency_name, from_code,
    all_fiat, all_crypto,
)

assert is_currency(BaseUnit.USD)        # True
assert is_fiat(BaseUnit.XAU)            # True (gold is ISO 4217 X-code)
assert is_crypto(BaseUnit.ETH)          # True
assert not is_fiat(BaseUnit.BTC)        # True (BTC is crypto, not fiat)

assert minor_unit(BaseUnit.USD)  == 2   # cents
assert minor_unit(BaseUnit.JPY)  == 0   # indivisible
assert minor_unit(BaseUnit.KWD)  == 3   # fils
assert minor_unit(BaseUnit.BTC)  == 8   # satoshis
assert minor_unit(BaseUnit.ETH)  == 18  # wei

info = currency_info(BaseUnit.EUR)
assert info.code == "EUR"
assert info.numeric_code == 978
assert info.minor_unit == 2

btc = from_code("BTC")
assert btc == BaseUnit.BTC

fiat_count   = sum(1 for _ in all_fiat())    # 166
crypto_count = sum(1 for _ in all_crypto())  # 50

13. Integration with the Parser Event Stream

Unit information flows into the application through two paths:

  1. Type-annotation unit — parsed from <family:…,unit-param> by the lexer, validated by the validator, delivered in the ev_type_annotation_type_family_parameter unit event.
  2. Inline unit suffix — parsed from the suffix following a scalar value literal before the terminating ;.

In both cases the effective unit is reported in the bvnr_data_t.value_unit field of the ev_data event.

13.1 Full event sequence — physical unit

Input: .force = <float:64,k~g·m/s²> 9.81;

ev_assignment_start          data = "force"
ev_type_annotation_start     data = "float:64,k~g·m/s²"
ev_type_annotation_type_family  data = "float"
ev_type_annotation_type_family_parameter   ← width=64
ev_type_annotation_type_family_parameter   ← unit:
    value_unit = { num_components=3,
      [0] bu_gram,   exp_linear,     {prefix_si, si_kilo}
      [1] bu_meter,  exp_linear,     {prefix_si, si_none}
      [2] bu_second, exp_neg_square, {prefix_si, si_none} }
ev_type_annotation_end
ev_data   data="9.81"

13.2 Full event sequence — currency unit

Input: .price = <float_dec:64,$USD> 19.99;

ev_assignment_start          data = "price"
ev_type_annotation_start     data = "float_dec:64,$USD"
ev_type_annotation_type_family  data = "float_dec"
ev_type_annotation_type_family_parameter   ← width=64
ev_type_annotation_type_family_parameter   ← unit:
    value_unit = { num_components=1,
      [0] USD, exp_linear, {prefix_si, si_none} }
ev_type_annotation_end
ev_data   data="19.99"

For events with an explicit type annotation but no unit parameter, the unit event is not emitted. For no_unit, the unit event IS emitted with BVN_UNIT_NONE (num_components=0). For synthesised (default) type annotations, the unit event IS emitted with BVN_UNIT_NO_PREFIX(bu_none).

13.3 Inline unit suffix — event stream view

Input: .distance = 1500 m;

ev_assignment_start      data = "distance"
ev_type_annotation_start data = "uint"      ← synthesised
ev_type_annotation_type_family  data = "uint"
ev_type_annotation_type_family_parameter  ← width=64  (synthesised)
ev_type_annotation_type_family_parameter  ← base=10   (synthesised)
ev_type_annotation_type_family_parameter  ← unit: BVN_UNIT_NO_PREFIX(bu_none)
ev_type_annotation_end
ev_data   data="1500"
    value_unit = { num_components=1,
      [0] bu_meter, exp_linear, {prefix_si, si_none} }

The value_unit field of ev_data always reflects the final, reconciled unit.

13.4 Practical callback

bool my_verified_handler(void* userdata, bvnr_event_t ev, bvnr_data_t* d)
{
    if (ev != ev_type_annotation_type_family_parameter)
        return true;

    value_unit_t u = d->value_unit;
    if (u.num_components == 0 ||
        (u.num_components == 1 && u.components[0].base == bu_none))
        return true;

    if (bvn_unit_is_currency(u.components[0].base)) {
        const bvn_currency_info_t *ci =
            bvn_currency_info(u.components[0].base);
        printf("currency: %s  minor_unit=%u\n",
               ci->code, ci->minor_unit);
        return true;
    }

    char unit_str[128];
    bvn_unit_to_string(u, unit_str, sizeof(unit_str));
    printf("unit: %s  (prefix_factor: %g)\n",
           unit_str, bvn_unit_prefix_factor(u));
    return true;
}

14. Validation Errors

The validator raises the following unit-specific errors:

Error code Value Trigger condition
error_unit_illegal 32 Unparseable unit string: unknown prefix, unknown base unit, unknown currency code after $, a bare token in neither the physical-unit table nor (lacking the $ sigil) recognised as a currency (e.g. XYZ, or bare USD), invalid prefix–unit combination (e.g. IEC prefix on a currency, sub-kilo SI prefix on byte), empty component between separators (e.g. m//s), or more than BVNR_MAX_UNIT_COMPONENTS (32) components
error_unit_too_long 22 An inline unit suffix exceeds its 255-byte lexer buffer. A unit written in an annotation reaches the type-annotation body's own cap first and raises error_type_too_long (21) instead — see §8
error_unit_mismatch 38 An inline unit suffix and an explicit type-annotation unit are both present, but parse to different value_unit_t representations
error_unexpected_input_byte 15 An inline unit suffix appears inside an array element

All four errors are raised during the on_unverified → validator phase. In continue_on_error mode the parser invokes on_error and enters the resync state machine, which skips to the next ; at the current nesting depth. bvnr_reader_get_error, …_line, …_column, …_byte, and …_offset all report the location of the offending token.


15. Annotated Examples

15.1 Physical Quantities

# Thermodynamic temperature
.ambient_temp  = <float:64,K>          293.15;

# Temperature in Celsius (affine: K = °C + 273.15)
.room_temp     = <float:32,°C>          20.0;

# Velocity and acceleration
.wind_speed    = <float:64,m/s>         12.5;
.gravity       = <float:64,m/s²>         9.80665;
.gravity_asc   = <float:64,m/s^2>        9.80665;  # ASCII caret — identical

# Pressure and energy
.tire_pressure = <float:32,k~Pa>       250.0;
.heat_energy   = <float:64,k~J>       5400.0;

# Flow rate (liters per minute)
.pump_flow     = <float:32,L/min>       15.0;

# Angles
.bearing       = <float:64,°>          270.0;
.phase         = <float:64,rad>          1.5708;

# Volume — US culinary
.recipe_water  = <float_dec:32,cup>      2.0;   # "cup" (lowercase) = US cup
.recipe_flour  = <float_dec:32,tbsp>     3.0;

# Duration
.shelf_life    = <uint:32,wk>           52;
.service_life  = <float:64,yr>          10.0;

15.2 Digital Storage

.packet_size = <uint:32,B>      1500;
.cache_size  = <uint:64,Ki~B>    512;
.ram_size    = <uint:64,Mi~B>   4096;
.disk_size   = <uint:64,Gi~B>    500;
.link_rate   = <uint:32,M~b>    1000;
.nic_speed   = <float:64,G~b/s>   10.0;

15.3 Compound SI Quantities

# Force: Newton = k~g·m/s²
.force          = <float:64,k~g·m/s²>    9.81;
.force_alt      = <float:64,k~g·m·s⁻²>  9.81;   # identical internal form

# Energy: Joule = k~g·m²/s²
.kinetic_energy = <float:64,k~g·m²/s²> 1000.0;

# Electric field
.field_strength = <float:64,V/m>         150.0;

# Torque
.torque         = <float:64,N·m>          25.0;

15.4 Currency Amounts and Rates

# ── Fiat scalar amounts ────────────────────────────────────────────────────
.price_usd     = <float_dec:64,$USD>   19.99;
.balance_eur   = <float_dec:64,$EUR>  342.00;
.yen_fee       = <uint:64,$JPY>           500;    # zero minor unit — integer only
.kwd_invoice   = <uint:64,$KWD>          3500;    # 3.500 KWD in fils

# "CUP" (uppercase) is the Cuban Peso, NOT the US cup volume unit:
.cup_balance   = <float_dec:64,$CUP>    25.00;   # 25 Cuban Pesos

# ── Crypto scalar amounts ──────────────────────────────────────────────────
.btc_sat       = <uint:64,$BTC>   54782000;       # on-chain satoshis
.eth_readable  = <float_dec:64,$ETH>    2.5;
.doge_bag      = <float_dec:64,$DOGE> 42000.0;
.usdt_stable   = <float_dec:64,$USDT>  5000.00;

# ── Compound units ─────────────────────────────────────────────────────────
.gold_price    = <float_dec:64,$USD/oz_t>   2351.40;  # $/troy oz
.wheat         = <float_dec:64,$USD/bsh>       5.82;  # $/bushel
.rent          = <float_dec:64,$EUR/m²>       12.50;  # €/m²
.billing_rate  = <float_dec:64,$EUR/h>        150.00; # €/h
.eur_usd       = <float_dec:64,$USD/$EUR>       1.0842; # exchange rate

# ── Reporting scale ────────────────────────────────────────────────────────
.fund_nav      = <float_dec:64,k~$USD>    250.0;     # $250,000
.gdp           = <float_dec:64,M~$EUR> 42800.0;      # €42.8 billion

# ── Exchange rate with timestamp ───────────────────────────────────────────
.snapshot = {
    .epoch    = <uint:64,s>              1716400000;
    .eur_usd  = <float_dec:64,$USD/$EUR>        1.0842;
};

# ── Array of prices ────────────────────────────────────────────────────────
.tier_prices   = <float_dec:64,$USD> [9.99, 19.99, 49.99, 99.99];

15.5 Error Cases

# Empty component → error_unit_illegal
.bad1 = <float:64,m//s>      1.0;

# Too many components (33 > 32) → error_unit_illegal
.bad2 = <float:64,m*s*g*A*K*mol*cd*b*V*Hz*N*Pa*J*W*Ω*F*C*S*Wb*T*H*lm*lx*Bq*Gy*kat*L*min*h*d*bar*eV*Da> 1.0;

# Unknown base unit → error_unit_illegal
.bad3 = <float:64,foobar>    1.0;

# IEC prefix on a currency → error_unit_illegal
.bad4 = <float_dec:64,Ki~$USD> 1.0;

# Annotation unit differs from inline unit → error_unit_mismatch
.bad5 = <float:64,m> 1.0 s;

# Inline unit inside an array → error_unexpected_input_byte
.bad6 = <float:64,m> [1.0 m, 2.0 m];   # ERROR: suffix inside array

# Correct: dimensionless explicit
.ok1  = <uint:32,no_unit>    42;

# Correct: omitted unit (same behaviour as no_unit)
.ok2  = <uint:32>            42;

# Correct: BTU is a valid alias for bu_btu (currency lookup returns 0, physical table matches)
.ok3  = <float:64,BTU>      1.0;    # valid: same as Btu or btu

# Correct: sub-kilo SI prefix on currency is accepted
.ok4  = <float_dec:64,m~$USD> 0.001; # valid: milli-dollar (one tenth of a cent)

# Correct: cup (volume) vs CUP (currency) — both valid, different meaning
.vol  = <float_dec:32,cup>  2.0;    # US cup (236.6 mL)
.bal  = <float_dec:64,$CUP> 25.00;   # Cuban Peso

See also


End of Bovnar — Unit & Currency Reference (Bovnar spec 1.1).