Units & Quantities
A quantity is a physical measurement, a number paired with a unit: 45 kg,
42 km/h, 9.81 m/s^2, 10 N/m^2. Dolfin writes them as smart literals,
exactly like the temporal values of Chapter 9:
a keyword followed by human-friendly notation in parentheses.
fact rex a Dog
weight quantity(45 kg)
Behind the keyword sits a small dimensional-analysis engine. It knows the SI system, resolves prefixes, does unit arithmetic, and reduces everything to the seven SI base dimensions so that quantities written in different units can still be compared.
The quantity(...) smart literal
The general form is a value, then a unit expression:
quantity( <number> <unit-expression> )
| Written | Means |
|---|---|
quantity(45 kg) | 45 kilograms |
quantity(42 km/h) | 42 kilometres per hour |
quantity(9.81 m/s^2) | 9.81 metres per second squared |
quantity(10 N/m^2) | 10 newtons per square metre |
quantity(1.609 km.s^(-1)) | 1.609 kilometres per second |
quantity(273.15 K) | 273.15 kelvin |
quantity(1e3 Pa) | 1000 pascals |
The literal is resolved at compile time: the value, the unit, and the dimension vector are computed once, when the file is parsed. A malformed quantity (unknown unit, impossible conversion) is a compile error, not a runtime surprise (see Errors).
Unit expressions
A unit expression is one or more unit tokens combined with . (multiply), /
(divide), and ^ (power):
quantity(5 kg) # a single unit
quantity(42 km/h) # a quotient
quantity(9.81 m.s^(-2)) # a product with a negative power
quantity(10 N/m^2) # 'm^2' is metre-squared
.multiplies the units on either side:N.mis a newton-metre./divides:km/his kilometres per hour. A unit after/has its exponent negated, sokm/handkm.h^(-1)mean the same thing.^nraises a unit to a power. Negative powers use^(-n)or a bare^-n:s^(-1),m^2,m.s^-2.
Prefixes
SI prefixes compose with any unit, so you do not enumerate km, cm, mm
separately, they are k+m, c+m, m+m:
All 24 SI prefixes are recognised, from quetta (Q, 10³⁰) down to quecto
(q, 10⁻³⁰):
| Prefix | Q | R | Y | Z | E | P | T | G | M | k | h | da |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Factor | 10³⁰ | 10²⁷ | 10²⁴ | 10²¹ | 10¹⁸ | 10¹⁵ | 10¹² | 10⁹ | 10⁶ | 10³ | 10² | 10¹ |
| Prefix | d | c | m | µ / μ / u | n | p | f | a | z | y | r | q |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Factor | 10⁻¹ | 10⁻² | 10⁻³ | 10⁻⁶ | 10⁻⁹ | 10⁻¹² | 10⁻¹⁵ | 10⁻¹⁸ | 10⁻²¹ | 10⁻²⁴ | 10⁻²⁷ | 10⁻³⁰ |
kg, mg, MHz, kPa, cm all resolve compositionally. When a token is both
a standalone unit and a prefix, the standalone unit wins: m is a metre (not
milli-anything), T is a tesla (not tera), h is an hour, d is a day, kt is
a knot, pt a pint. Prefix decomposition is only tried when a token is not a
known unit on its own.
Some units never take a prefix: kg (prefix the gram: mg, not mkg), the
tonne t (kt is the knot; there is no kilotonne), min, h, d, ha, u,
and the non-metric units (ft, in, yd, mi, nmi, kt, oz, lb, ct,
pt, atm, at).
Known units
The built-in registry covers the SI base units, the named derived units, and the common non-SI units:
| Layer | Tokens (selection) |
|---|---|
| SI base | m, kg, s, A, K, mol, cd |
| SI derived | N, Pa, J, W, Hz, V, Ohm/Ω, F, C, T |
| Mass (non-SI) | g, t (tonne), oz, lb, ct (carat), u/Da (dalton) |
| Length (non-SI) | mi, ft, in, yd, nmi (nautical mile) |
| Area | ha (hectare) |
| Speed | kt (knot) |
| Time (non-SI) | min, h, d |
| Volume | L/l (litre), mL/ml, pt (US liquid pint) |
| Pressure/energy | bar, atm, at (technical atmosphere), cal, kcal |
| Temperature | K; °C/degC, °F/degF (standalone only) |
An unknown token is a compile error.
User-defined units (unitdef)
The built-in registry above is fixed — it ships with dolfin-units and cannot
be extended from a .dlf file. unitdef is the separate mechanism for a
project to declare its own units: currencies, or units that aren’t
physically commensurable at all (a bunch of carrots is not a length or a
mass). It is a top-level declaration, like concept or property:
unitdef USD: scale 0.92 EUR
unitdef family vegetables
unitdef bunch_of_carrots: nominal of vegetables scale 2
unitdef cabbages: nominal of vegetables scale 1
Why
unitdefand notunit.unitis an ordinary identifier, and older models still write qualified ranges such ashas weight: unit.Mass(see legacy names). Reservingunitfor this declaration would break them, so the declaration keyword isunitdef.
There are two independent kinds of unitdef, and they behave very
differently. Picking the wrong one for what you’re modelling gets you either
silently-wrong arithmetic or arithmetic dolfin unnecessarily refuses.
Derived units (currencies, and anything else on a real dimension)
unitdef USD: scale 0.92 EUR
This declares USD as an ordinary unit: 1 USD = 0.92 EUR. It behaves
exactly like declaring km from m — dolfin-units ships EUR as its one
built-in currency unit (an arbitrary base pick, scale 1.0, no other
currency and no live exchange rate bundled), and a project adds any other
currency itself, with whatever rate it wants to use.
Because a derived unit is a real dimension (dolfin-units has a currency
axis alongside the seven SI ones), it composes and converts exactly like any
other unit:
quantity(25 USD/h) # composes fine — a salary rate
quantity(100 USD + 8 EUR) # auto-converts and adds, like `1 km + 500 m`
quantity(100 USD as EUR) # → 92 EUR
scale <factor> <reference> requires <reference> to already be a
resolvable unit (built-in, or another unitdef — declared in any order,
even across files: dolfin resolves references in dependency order, not
declaration order). A reference that never resolves is a compile error
naming the broken unitdef.
Nominal (incommensurable) units and families
unitdef family vegetables
unitdef bunch_of_carrots: nominal of vegetables scale 2
unitdef cabbages: nominal of vegetables scale 1
unitdef family <name> declares an abstract, dimensionless grouping — not a
usable unit by itself, just something other unitdefs can belong to.
unitdef <name>: nominal of <family> scale <factor> declares a unit that
belongs to that family, with a per-unit scale used only when explicitly
widening into the family (see below) — it is not a conversion rate between
nominal units.
Unlike a derived unit, a nominal unit is not a real dimension: two different nominal units are incommensurable by design, even if they belong to the same family:
quantity(2 bunch_of_carrots + 3 bunch_of_carrots) # OK: same unit, ordinary add → 5
quantity(2 bunch_of_carrots + 3 cabbages) # error: incommensurable units
The only way to +/- different nominal units of the same family is an
explicit as <family> cast, which widens each operand via its declared
scale into the family’s dimensionless base and only then combines them:
quantity(2 bunch_of_carrots + 3 cabbages as vegetables) # → 7 (2×2 + 3×1)
quantity(3 bunch_of_carrots as vegetables) # → 6 (single operand also widens)
quantity(3 bunch_of_carrots as fruit) # error: not a member of `fruit`
Casting to a family you aren’t a member of, or casting a non-nominal
quantity to a family, is the same INVALID_QUANTITY-class compile error as
casting 5 km as s.
*// follow a different rule than +/-: a nominal unit composes freely
with an ordinary (non-nominal) unit, same as any other unit — a carrot-cutting
machine’s rate is a perfectly good quantity:
quantity(10 bunch_of_carrots / 2 h) # → 5 bunch_of_carrots/h, a rate
Two nominal units can also multiply/divide against each other, as long as
they belong to the same family — the result is nominal-typed but no longer
a plain member of the family (bunch_of_carrots * cabbages is a
vegetables^2-shaped quantity, not a vegetables):
quantity(2 bunch_of_carrots * 3 cabbages) # → 6 bunch_of_carrots.cabbages
quantity(6 bunch_of_carrots / 3 cabbages) # → 2 bunch_of_carrots/cabbages
Multiplying/dividing nominal units from different families is still
rejected — there’s no meaningful composite of bunch_of_carrots (vegetables)
and some unit from an unrelated family:
quantity(2 bunch_of_carrots * 3 apples) # error: incommensurable units (different families)
| Operation | Same unit | Same family, no cast | Same family, as <family> | Different family |
|---|---|---|---|---|
+ / - | ✅ | ❌ error | ✅ widens via scale | ❌ error |
* / / | ✅ | ✅ (nominal, family-typed) | n/a (not a valid cast target for *//) | ❌ error |
* / / with an ordinary (non-nominal) unit | ✅ composes into a rate, e.g. bunch_of_carrots/h | — | — | — |
Arithmetic and conversion
A quantity literal may contain a single arithmetic operation between two measurements, evaluated at parse time:
quantity(42 km / 30 min) # a speed: 1.4 km/min
quantity(10 N / 1 m^2) # a pressure: 10 N/m^2 (i.e. 10 Pa)
A trailing as converts the result to a target unit, or reduces it to SI base
units. The target must be dimensionally compatible:
quantity(42 km/h as m/s) # → 11.666… m/s
quantity(90 lb as kg) # → 40.82… kg
quantity(42 km / 30 min as km/h) # → 84 km/h
quantity(10 N/m^2 as Pa) # → 10 Pa (named derived unit)
quantity(5 km as SI) # → 5000 m (reduce to base units)
Converting across incompatible dimensions (quantity(5 km as s), a length to a
time) is a compile error.
The dimensional rules are the ordinary ones:
| Operation | Requirement |
|---|---|
+ / - | both operands share the same dimension |
* / / | none (dimensions combine) |
as / convert | target dimension equals source dimension |
These are the rules for ordinary (physical, and derived/currency) units. A
nominal unit follows a
different rule for +/-: it requires the same specific unit (not merely
the same dimension — nominal units are all dimensionless). *// compose
freely with ordinary units (bunch_of_carrots/h) and with other nominal
units of the same family (bunch_of_carrots * cabbages); different
families still reject. See that section for the as <family> widening cast,
which is the only way to +/- two different nominal units.
Dimensions
Every unit reduces to a vector over the seven SI base dimensions:
| Dimension | Letter | SI base unit |
|---|---|---|
| Length | L | m |
| Mass | M | kg |
| Time | T | s |
| Electric current | I | A |
| Temperature | Θ | K |
| Amount of substance | N | mol |
| Luminous intensity | J | cd |
A quantity’s dimension is its real type. km/h and mi/h are different
units but the same dimension (L1.T-1, a speed), and so they are comparable;
kg and m are not. Two quantities are dimensionally compatible when their
dimension vectors are equal.
There is an eighth, non-SI axis, currency, used only by EUR and any
derived currency unitdef a project declares
— it behaves exactly like the seven physical axes above (composes, converts,
compares). Nominal units sit
outside this vector entirely: they’re dimensionless (all-zero vector) but
carry a separate family tag that ordinary dimension-vector equality doesn’t
see, which is precisely why they need their own arithmetic rules instead of
just being “another dimension”.
Quantities as values of a float field
There is no quantity primitive type: a quantity’s type is the
dimension the value carries. The loosest way to hold one is a numeric range
with quantity(...) values:
concept Animal:
has weight: optional float # the value supplies the unit
fact rex a Dog
weight quantity(45 kg)
The declared float range says only “a number”. The unit lives in the value, so
one attribute can hold quantity(45 kg) for one animal and quantity(90 lb) for
another and still compare them correctly. float accepts any quantity
(quantity(45 kg), quantity(3 s)) or a bare number alike — nothing checks the
dimension.
Dimension-typed properties
To require a specific dimension, declare the range as a physical dimension
instead of float. The dimensions are built-in lowercase quantity
datatypes, like float or date; nothing needs to be declared (there is no
concept Mass and no units file to import):
| Type | Dimension | QUDT quantity kind (rdfs:range) |
|---|---|---|
length | L1 | quantitykind:Length |
mass | M1 | quantitykind:Mass |
duration | T1 | (xsd:duration, see below) |
current | I1 | quantitykind:ElectricCurrent |
temperature | Th1 | quantitykind:Temperature |
amount | N1 | quantitykind:AmountOfSubstance |
luminosity | J1 | quantitykind:LuminousIntensity |
speed | L1.T-1 | quantitykind:Velocity |
acceleration | L1.T-2 | quantitykind:Acceleration |
force | L1.M1.T-2 | quantitykind:Force |
pressure | L-1.M1.T-2 | quantitykind:Pressure |
energy | L2.M1.T-2 | quantitykind:Energy |
power | L2.M1.T-3 | quantitykind:Power |
frequency | T-1 | quantitykind:Frequency |
area | L2 | quantitykind:Area |
volume | L3 | quantitykind:Volume |
density | L-3.M1 | quantitykind:Density |
momentum | L1.M1.T-1 | quantitykind:Momentum |
These names are types only in type position. They are not keywords, so
length, power or area stay valid field names: has length: length is
fine. time is not in the list: it is the xsd:time clock-time type, and the
time dimension is duration.
concept Animal:
has weight: mass # was: optional float
fact rex a Dog
weight quantity(45 kg) # OK — a mass
fact oops a Dog
weight quantity(3 m) # error: S008 dimension mismatch (length, not mass)
Analysis checks every
quantity(...) value assigned to that property against the dimension —
a mismatched dimension or a bare number (weight 45) is diagnostic S008
DIMENSION_MISMATCH. In Turtle, such a property is an owl:DatatypeProperty
whose rdfs:range is the QUDT quantity kind (quantitykind:Mass), declared
as an rdfs:Datatype; the value is a literal whose datatype is its unit (see
Representation in Turtle).
duration is the time dimension
A duration field holds a time span, written either as a temporal
duration(...) or as any time
quantity. Both forms mean the same value:
concept Dog:
has nap: optional duration
fact rex a Dog
nap duration(20 min) # → "PT20M"^^xsd:dayTimeDuration
fact bella a Dog
nap quantity(20 min) # → "PT20M"^^xsd:dayTimeDuration, the same value
In unit algebra duration is the T1 dimension, so quantity(3 km / 30 min)
is a speed. Months and years have no fixed length, so mo and y are
refused in quantity arithmetic and conversion (quantity(1 mo) is error S007
“Calendar unit ‘mo’ has variable length”). Calendar durations remain
available as duration(1y 6mo), which compiles to "P1Y6M"^^xsd:duration.
Legacy capitalised names
Models written before the built-in types declared concept Mass (often in a
shared units.dlf) and used it as a range (has weight: Mass or
has weight: unit.Mass). Such models still compile to the same Turtle as
has weight: mass, with two lint warnings, each with a quick-fix. The
capitalised name only resolves while that declaration is there: Mass alone,
with no concept Mass, is an unresolved type, so apply both fixes.
| Lint | Triggered by | Quick-fix |
|---|---|---|
quantity/capitalized-dimension-type | has weight: Mass, unit.Mass | replace with mass |
quantity/builtin-dimension-concept | concept Mass | delete the declaration |
A concept named after a built-in dimension is not written as an rdfs:Class.
The legacy name Time maps to duration.
Comparisons
A comparison against a quantity, in a rule or query constraint block, is unit-aware:
rule flag_overweight_dog:
match:
?dog a Dog
?dog weight [ > quantity(40 kg) ]
then:
?dog a OverweightAnimal
The comparison compares physical magnitudes, not lexical numbers:
- The two operands must share a dimension. A mismatch (
weightin kilograms compared toquantity(3 m)) makes the comparison fail and the row is dropped, it never matches. In a query, when the property’s dimension is declared (has weight: mass), analysis reports this as an S008 warning (`weight` is a Mass but is compared with a Length quantity, this query matches nothing). A plainfloatproperty carries no dimension, so a mismatch on it stays silent. - Compatible operands are compared by their SI magnitude (value × coefficient).
So a dog whose
weightwas writtenquantity(90 lb)(≈ 40.8 kg) does trip a> quantity(40 kg)threshold, even though 90 and 40 alone would not suggest it.
For the unit-aware path to run, both sides must be quantities. A bare number
(weight [ > 40.0 ]) is compared as a plain scalar, with no unit reasoning, so
mixing a quantity(...) value with a bare-number threshold does not do what you
want. Keep both the stored values and the threshold as quantity(...).
Use
> < >= <=, not= / !=. After a quantity is normalised to its SI magnitude, two “equal” measurements almost never match to the bit, so exact (in)equality is unreliable. The linter warns on=/!=against a quantity (rulesemantic/quantity-exact-comparison). The ordering operators are the sound ones.
Representation in Turtle
A quantity compiles to a typed literal whose datatype is its unit, taken
from QUDT, the shared vocabulary for units and quantity
kinds. Each distinct unit used in the output gets one qudt: description:
@prefix qudt: <http://qudt.org/schema/qudt/> .
@prefix quantitykind: <http://qudt.org/vocab/quantitykind/> .
:weight a owl:DatatypeProperty ;
rdfs:range quantitykind:Mass .
:rex :weight "45"^^<http://qudt.org/vocab/unit/KiloGM> .
:bella :weight "90"^^<http://qudt.org/vocab/unit/LB> .
:rex :nap "PT20M"^^xsd:dayTimeDuration .
# one block per quantity kind used as a range, and per unit used in the data:
quantitykind:Mass a qudt:QuantityKind, rdfs:Datatype .
<http://qudt.org/vocab/unit/KiloGM> a qudt:Unit, rdfs:Datatype ;
qudt:conversionMultiplier 1 ;
qudt:hasDimensionVector <http://qudt.org/vocab/dimensionvector/A0E0L0I0M1H0T0D0> ;
qudt:symbol "kg" ;
qudt:hasQuantityKind quantitykind:Mass .
<http://qudt.org/vocab/unit/LB> a qudt:Unit, rdfs:Datatype ;
qudt:conversionMultiplier 0.45359237 ;
qudt:hasDimensionVector <http://qudt.org/vocab/dimensionvector/A0E0L0I0M1H0T0D0> ;
qudt:symbol "lb" ;
qudt:hasQuantityKind quantitykind:Mass .
- Unit datatype: the QUDT unit IRI (
kg→unit:KiloGM,km/h→unit:KiloM-PER-HR,lb→unit:LB), written as a full IRI (<http://qudt.org/vocab/unit/KiloGM>), not a prefixed name. The value is kept as written, in its own unit (no SI reduction). - Units QUDT lacks (a
unitdef, an unusual compound such askm/min) get a datatype in the project namespace,<namespace>unit/<canonical>(km/min→…/unit/km.min-1,USD→…/unit/USD), described with the samequdt:triples. qudt:terms:qudt:conversionMultiplier(factor to the SI base magnitude),qudt:hasDimensionVector(a QUDTqkdv:dimension vector),qudt:symbol(the human-readable unit),qudt:hasQuantityKind. A currency unit has no dimension vector; its kind isquantitykind:Currency.- Range: a property typed
masshas rangequantitykind:Mass, itself declareda qudt:QuantityKind, rdfs:Datatype. OWL reasoners do not treat aunit:KiloGMvalue as a member of that range: the dimension check is done by the SHACL shapes, which compare the value’squdt:hasDimensionVector. - Time: any time quantity, and
duration(...)without months or years, is anxsd:dayTimeDuration("PT20M"), with no unit block. Adurationfield has rangexsd:duration. - A dimensionless quantity carries no unit and degrades to a plain
"<value>"^^xsd:double. - The
qudt:andquantitykind:prefixes are only declared when the output has quantities.
How comparisons unfold
A quantity comparison in a query does not emit a bare scalar FILTER. It unfolds
to standard SPARQL 1.1 that resolves the stored value’s unit and compares SI
magnitudes, guarded by the dimension vector:
?s :topSpeed ?v .
BIND(datatype(?v) AS ?u)
?u <http://qudt.org/schema/qudt/conversionMultiplier> ?c .
?u <http://qudt.org/schema/qudt/hasDimensionVector> ?d .
FILTER(?d = <http://qudt.org/vocab/dimensionvector/A0E0L1I0M0H0T-1D0>
&& (xsd:double(str(?v)) * ?c) >= 16.666…)
The target magnitude (16.666…) and dimension vector are computed at compile
time from the comparison literal. The unit descriptions must be in the queried
dataset. A time target compares xsd:dayTimeDuration values directly
(FILTER(?v >= "PT20M"^^xsd:dayTimeDuration)). Rules unfold the same way for
the reasoner, using dedicated comparison predicates (dq:greaterThanOrEqual, …,
in the https://dolfin.dev/quantity# namespace, since QUDT has no comparison
functions) rather than overloaded math: builtins.
Displaying results
A result value keeps its unit datatype, so a client can reconstruct the unit for
display: "45"^^unit:KiloGM renders as 45 kg, "42"^^unit:KiloM-PER-HR as
42 km/h. The datatype IRI maps back to a symbol via its qudt:symbol.
Errors
| Situation | Result |
|---|---|
Unknown unit token (quantity(42 zonks)) | compile error INVALID_QUANTITY (S007) |
Incompatible as conversion (5 km as s) | compile error INVALID_QUANTITY (S007) |
Wrong dimension on a dimension-typed property (has weight: mass, value quantity(3 m)) | compile error DIMENSION_MISMATCH (S008) |
Bare number on a dimension-typed property (weight 45) | compile error DIMENSION_MISMATCH (S008) |
Two different nominal units combined without a cast (2 bunch_of_carrots + 3 cabbages) | compile error, incommensurable units |
*// between nominal units of different families (2 bunch_of_carrots * 3 apples) | compile error, incommensurable units |
as <family> on a unit that isn’t a member of that family | compile error INVALID_QUANTITY (S007) |
Months or years in a quantity (quantity(1 mo), 1 y as s) | compile error INVALID_QUANTITY (S007) |
| Dimension mismatch in a comparison | not an error, the row is dropped at run time |
The first four are reported by dolfin-analysis and surfaced in the editor.
The last is a data-dependent runtime condition, so it is handled by dropping
the non-matching row rather than by a diagnostic.