Bovnar — Unit & Currency Reference
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
- Overview
- Syntax — Annotation as a Type Parameter
- Physical Base Units
- 3.1 SI Base Units
- 3.2 Named SI-Derived Units
- 3.3 Non-SI Units Accepted for Use with SI
- 3.4 Imperial and US Customary Units
- 3.5 Pressure Units
- 3.6 Energy Units
- 3.7 Power Units
- 3.8 Force Units
- 3.9 Speed and Rotational Frequency Units
- 3.10 Volume Units
- 3.11 Area Units
- 3.12 Angle Units
- 3.13 CGS Units
- 3.14 Radiation Units
- 3.15 Logarithmic Units
- 3.16 Electrical Power Units
- 3.17 Digital Units
- 3.18 Textile Linear Density
- 3.19 US Apothecary / Dry Volume
- 3.20 Old German Units
- 3.21 Additional Length Units
- 3.22 Additional Mass Units
- 3.23 Acceleration
- 3.24 Signal Rate
- 3.25 Ratio and Proportion Units
- 3.26 Named Speed Units
- 3.27 Acidity
- 3.28 Water Hardness
- 3.29 Conductivity and Dissolved Solids
- 3.30 Turbidity and Salinity
- 3.31 Sentinel Value
- 3.32 Units the Unit Profiles Needed
- Prefixes
- 4.1 SI Prefixes
- 4.2 IEC Binary Prefixes
- 4.3 Compact Prefix Form
- Unit Notation Grammar
- 5.1 Simple Units
- 5.2 Compound Units
- 5.3 Separators
- 5.4 Denominator Semantics
- Exponents
- The
no_unitKeyword - Constraints and Limits
- Currency Codes
- Symbol Disambiguation
- C Data Model
- 11.1 Enumerations
- 11.2 Structures
- 11.3 Convenience Macros
- C API Functions
- 12.1 Parsing a Unit String
- 12.2 Serializing a Unit
- 12.3 Prefix Factor and Exponent Queries
- 12.4 SI Conversion API
- 12.5 Currency API
- 12.6 Unit Profile API (under implementation)
- 12.7 Python API
- Integration with the Parser Event Stream
- Validation Errors
- Annotated Examples
- 15.1 Physical Quantities
- 15.2 Digital Storage
- 15.3 Compound SI Quantities
- 15.4 Currency Amounts and Rates
- 15.5 Error Cases
- See also
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:
- Physical units — 264 named base units covering SI, Imperial, CGS, radiation, surveying, culinary, Old German, and digital storage quantities.
- Currency codes — 216 monetary denominations: 166 ISO 4217 alphabetic codes (including precious-metal X-codes; 4 are historical — HRK retired 2023-01-01, SLL replaced by SLE 2022, ZWL superseded by ZWG 2024, BGN retired 2026-01-01 — and
ANGcoexists with its successorXCG, which inherited its numeric code 532; see §9.2) and 50 cryptocurrency tickers.
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
- SI-first. All SI base units, all 22 BIPM-2019 named derived units, and all 24 current SI prefixes (quecto … quetta) are supported.
- Binary-prefix aware. IEC 80000-13 binary prefixes (kibi … yobi) are supported for digital storage quantities, plus
Ri/Qi(robi, quebi) as a forward-looking extension — those two are a proposal, not part of IEC 80000-13, which stops at yobi. - Compound units. Derived quantities (m/s, kg·m/s², USD/oz_t) are expressed inline without separate schema definitions.
- Two exponent notations. Unicode superscript (
m²,s⁻²) and ASCII caret (m^2,s^-2) are accepted equivalently. - Currency as a first-class unit. ISO 4217 and cryptocurrency codes participate in all unit composition rules — prefixes, compound expressions, and the
value_unit_trepresentation — with no special-case parsing. - Dimensionless values. The keyword
no_unitis the canonical representation of a dimensionless quantity.
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 samevalue_unit_tthis 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,omandcf; 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.2directive 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> — identical2.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 dimensionlessThe 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_mismatch2.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_tconstant 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 thek~(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,lxandphare defined through the steradian, so they carry its quantity kind (§3.12 and §11 of Unit Ambiguities);cdandsbdo 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 refusedlm ↔ cd·srand convertedlm ↔ cdat factor 1, which is the same claim with thesrsilently dropped. What holds now:
Converts Refused lm↔cd·sr,lx↔lm/m²↔cd·sr/m²,ph↔lx,sb↔cd/m²lm↔cd(flux vs intensity),lx↔cd/m²andph↔sb(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
thouandmilare accepted for 1/1000 of an inch (25.4 µm). The canonical output form isthou. Note thatmildoes not mean milliradian; milliradians are writtenm~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
drand 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 spellsh~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).RanotR—Ris 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 asdelta_plus the scale's own short form. There is deliberately no baredelta_Cordelta_F: those read as a delta coulomb and a delta farad, and the scales themselves do not alias bareCorFeither.delta_Kis unambiguous becauseKis 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) |
mH2Otakes prefixes, so the spellings people actually write fall out of it:c~mH2O(or the compactcmH2O) is the centimetre of water a ventilator is set in, andm~mH2Othe millimetre. The value is the conventional column — water at 1000 kg/m³ under standard gravity — the same kind of convention that fixesmmHgat exactly 133.322387415 Pa. A column measured at a stated temperature (UDUNITS'water_4C, QUDT'sIN_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.
calis the THERMOCHEMICAL calorie andBtuthe INTERNATIONAL TABLE BTU, which is not an inconsistency but the two vocabularies' own defaults: UCUM's unqualifiedcalis thermochemical and its unqualified[Btu]is too, while UDUNITS' unqualifiedcalorieis 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 whyBtu_thstates an exact rational rather than a decimal.
BTUalias note:BTU(all uppercase, three characters) is a valid alias forbu_btu. Because currencies require the$sigil, the bare tokenBTUis a physical-unit lookup and resolves tobu_btu;Btuandbtuare 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⁻¹ |
knhas dimension m·s⁻¹.rpmhas 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³ |
cupdisambiguation: The canonical symbol for the US cup volume unit iscup(all lowercase). The Cuban Peso (ISO 4217 code 192) is written with the mandatory currency sigil as$CUP; the bare uppercase tokenCUPiserror_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
°→radworks and only the factor is irrational. What the kind stops is an angle drifting into a plain count:rev/minis an angular rate andrpma cycle rate, and they differ by exactly 2π.srcarries the kind at weight 2, because a steradian israd²— 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² |
sbandphare 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, sophconverts withlxandsbwithcd/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_compatiblereportsfalsefor 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,dBandpHa 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_tcarries; anddBis 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_compatiblereturnstruewhen 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
b→Bis 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 inM~b/sand a file size inM~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~B→Bis 1024,M~b→k~bis 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(notmin, 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.pychecks 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 fromft(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 ppm ≡ 0.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
pptrand notppt. Two independent reasons, and either alone would settle it.pptalready resolved — as the compact form ofp~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 andgen_units.pyrefuses at build time. Andpptis 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 tokenpptis refused outright (§4.3); writepptr,‰, orp~ptif the picopint really is what you meant.
pptvandppqv, 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) |
pHvsp~H: the two differ by one character and by seven orders of dimension.pHis the acidity scale;p~His the picohenry. Case matters too:phis the phot (§3.13). This is exactly why acidity had to become a unit — without it,pHresolves 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 |
gpgis a unit,gr/galis a compound, and they are not the same thing.gpgis 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.
valis the equivalent as water analysis uses it. The ions counted are divalent, so one equivalent is half a mole andm~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 genericeq/equivalentalias.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 witherror_unit_inexact(47) rather than inventing precision — the same answer it gives for a π-based angle.bvn_unit_convert_valueandbvn_unit_to_si_factor, which work in double precision, convert normally.
mval/Landmeq/Lare one unit.eqis an accepted spelling ofval, so the German and English conventions both work and both carry the divalent caveat above.
dHis 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'sppmis the dimensionless 10⁻⁶ and is not interchangeable with°aH. See07_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
ppmis the dimensionless 10⁻⁶ — for a dilute aqueous solution at 1 kg/L the two are numerically the same, so writingppmis defensible here in a way it is not for hardness (§3.28), but the dimensions differ:ppmis a ratio,mg/Lis 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/cmand the factor separately, or record the TDS asmg/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;FTUsays even less, since its geometry is unstated by definition.FAUis 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.JTUis 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,FTUandFAUaccept prefixes (m~NTUis real in ultrapure-water work).JTUdoes not: the candle method cannot resolve below roughly 25 JTU, so a milli-JTU is not a measurement.Watch the case.
fauis the femto-astronomical-unit,cFthe centifarad — the turbidity and conductivity scales are uppercase only. See07_bovnar_unit_ambiguities.md.PSU is not per-mille. Practical salinity is a conductivity ratio, so it is dimensionless by construction and
PSUis 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‰org/kgis 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
ptandln.ptis 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 whichgen_units.pynow 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_ftis 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 isac·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) |
darcytakes prefixes, so the millidarcy every reservoir report is written in ism~darcy(or compactlymdarcy).thm_ecis the therm European gas billing uses; nativethmis 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 prefixedBtu.shakeexists 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.
pntis the DTP point (¹⁄₇₂ in) andpnt_prthe printer's point (0.013837 in), 0.37 % apart;hpis the mechanical horsepower,PSthe metric one andhp_Ethe electric 746 W, whilehp_Bis thirteen times any of them;bshis the US dry bushel andbsh_ukthe 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.
abVneeded a unit rather than a prefix because there is no decade prefix at 10⁻⁸ and prefixes do not stack.ATanddebyeare 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. ASCIIuis accepted as an input-only alias forµ(e.g.u~mparses asµ~m); the canonical output form is alwaysµ. Noteuis also the bare symbol for the dalton, but the two never collide: the base unit is whatever the longest alias suffix says, souis the dalton and theuinumoru~mis 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
- IEC prefixes (
Ki…Qi) are only permitted onbandB.Ki~m→error_unit_illegal. - SI sub-kilo prefixes (
d,c,m,µ,n,p,f,a,z,y,r,q,da,h) are forbidden onbandB. - German units (
bu_pfundthroughbu_scheffel) accept onlysi_none/iec_none. - Ratio units (
%,‰,‱,pcm,ppm,ppb,pptr,ppq) likewise: a prefixed per-cent is meaningless.k~%→error_unit_illegal(§3.25). - Scales that are already a scale —
pH,mph,kph, the five water-hardness degrees,gpg,CF,PSU,JTU— take no prefix either. Each carries its reason in §3.26–§3.30;NTU,FNU,FTUandFAUdo take one, because a milli-NTU is a real ultrapure-water measurement. - Currency units accept SI prefixes of any magnitude (see §9.4). IEC prefixes are forbidden on all currency codes.
.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 currency4.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 tooBoth 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:
- A bare unit alias always wins. The base symbol is the longest alias suffix, so
minis the minute (not milli-inch),cdthe candela (not centi-day),ftthe foot (not femto-tonne),atthe technical atmosphere (not atto-tonne),dBthe decibel (not deci-byte). The separated form reaches the other reading:m~in,c~d,f~t. - Where two prefixed readings compete, the longer base symbol wins — for the same reason.
datis deci-at(technical atmosphere), not deca-tonne;dauis deci-au, not deca-dalton. Writeda~t/da~ufor those. - A prefix cannot be stacked.
kkgandk~kgare botherror_unit_illegal. - Prefix–unit validity is unchanged.
Kimfails for the same reasonKi~mdoes; so domB,kPfd,kppm. - A few compact spellings are refused by name. A token that is a well-known annotation for something else would otherwise become a valid — and quietly wrong — unit, which is the failure mode the format exists to prevent. These stay
error_unit_illegal:
| 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,0005.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 rateThe sub-grammar is semantic, not lexical. The outer lexer captures the type-annotation body as a raw byte sequence;
bvn_parse_unitparses 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 |
m² |
exp_square |
s^-2 |
s⁻² |
exp_neg_square |
m^+2 |
m² |
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
exp_invalid(value 0): Zero-initialized sentinel. API functions that have an error output path reject it and signal an error. The two prefix query functions (bvn_unit_prefix_factor,bvn_unit_prefix_exponent) silently skip components withexp_invalid.exp_linear(value 1): Stored for both an explicit¹/^1and any component written without an exponent suffix.
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, noterror_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 reacheserror_type_too_longfirst. An inline unit suffix has a buffer to itself and is the path that reacheserror_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:
- 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_illegalis raised — a$introduces a currency and nothing else. - A component without a
$is looked up only in the physical unit table. A bare code such asUSDorCUPis therefore never a currency; if it is not a physical unit it iserror_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 definesUYWwith 4, but the catalogue omits the fund and bond codes (BOV CHE CHW COU MXV USN UYI UYW,XBA–XBD,XSU,XUA) and the no-currency codeXXX: they denote accounting units and placeholders rather than money a value can be denominated in. The four historical codesHRK,SLL,ZWL, andBGNare 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 priceThe 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 rateCurrency × 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. Usefloat_decfor decimal-exact storage.
float_fixis wrong for monetary values. Q-format stores values asinteger × 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); CUP → error_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 |
SOL → error_unit_illegal (no physical unit) |
$SOL → Solana (crypto) |
|
BAR |
BAR → error_unit_illegal; use lowercase bar |
(not ISO 4217) | |
ERG |
ERG → error_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 unitCalling 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_COUNTis that row count. A bounds check is no longer a membership test either — askbvn_unit_validon the unit, orbvn_unit_is_currencyon 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:
memcmpagainst"no_unit"→ returnBVN_UNIT_NONEimmediately on match.- Scan for separator characters to distinguish simple vs. compound paths.
- For compound units, split on
0x2A(*),0x2F(/),0xC2 0xB7(·); parse each slice as a component; negate denominator exponents. - 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_REDUCEchanges the unit, and this function does not change the value. Reduction folds every prefix out, sok~gserializes 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 (5k~mis written as5000 m, in exact rational arithmetic); nothing else does.A direct caller must not use
bvn_unit_reduce'sscalefor 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, sok~Ncomes back"k~N"with nothing to rescale, whilebvn_unit_reducestill 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.cdoes:
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'sscaleremains 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.
SvandGyshare a dimension vector, as doBq,BdandHz, andW,VAandvar; each reduces to itself. A lone base at an exponent other than 1 is a different case and still collapses —s⁻¹is whatHznames, andm~s⁻¹comes backk~Hz.An overflowing reduction returns
-1rather 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_reducedrops a component — so the result is a different unit, not a shorter spelling of the same one.m¹⁰⁰·m²ism¹⁰², and such a unit used to serialise as"no_unit". WithoutBVN_UNIT_REDUCEthese 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~sreturns7, which is neither 2⁷ nor 10⁷ — the actual scale is 1.024. For a mixed unit usebvn_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:
- An affine scale outside "alone, at exponent 1".
s/°Cis dimensionally compatible withs/Kand passes here; the conversion entry points refuse it, because an affine scale means nothing at an exponent other than 1 (§3.12 above; see also Unit Ambiguities §10). This one is reachable from ordinary units and is the one a policy has to fence. - Capacity. The exact factor needs more than
BVN_INT_MAX_BITS, as the paragraph above describes:Q~m¹⁰⁰ → q~m¹⁰⁰is 10⁶⁰⁰⁰ and declines, while remaining a perfectly well-defined conversion between compatible units. This is why the screen answerstruethere — separating "too large to compute" from "these units disagree" is the question it exists to answer, and it is reachable only from deliberately extreme prefixes and exponents.
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:
- a currency, which has no dimension vector at all;
- any dimensionless unit (
%,ppm,dB,pH,rad,°, the turbidity scales). Deliberate: normalising a ratio would silently turn35 %into a bare0.35, and normalising an angle would need the irrational factor between°andrad; - a unit whose SI form the conversion engine would then refuse. The dimension vector does not determine a unit:
lm,lxandphcarry the steradian's quantity kind, which no dimension vector can express, so rebuilding them from dimensions yieldscdandcd/m²— a different quantity (§3.2).s/°Chas the dimensions ofs/Kand is unconvertible for the affine reason above.
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:
- Type-annotation unit — parsed from
<family:…,unit-param>by the lexer, validated by the validator, delivered in theev_type_annotation_type_family_parameterunit event. - 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 PesoSee also
- Specification §11 — Units System — how a unit is attached to a value
- Unit & Currency Cheat Sheet — every symbol in this registry, in table form
- Unit Ambiguities — every token that could plausibly mean two things
- Unit Profiles — writing UCUM, UNECE, QUDT and UDUNITS codes in a unit slot, and where the vocabularies disagree
- Read & Write API —
bvn_parse_unit,bvn_unit_to_string, and read-time conversion - Python Bindings — the same unit model from Python, with the NumPy and pint bridges
End of Bovnar — Unit & Currency Reference (Bovnar spec 1.1).