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:
| Prefix | k | M | G | c | m | µ / u | n |
|---|---|---|---|---|---|---|---|
| Factor | 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. Prefix
decomposition is only tried when a token is not a known unit on its own.
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 |
| Length (non-SI) | mi, ft, in, yd |
| Time (non-SI) | min, h, d |
| Volume | L/l (litre), mL/ml |
| Pressure/energy | bar, atm, 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 already an ordinary identifier — the readability convention inhas weight: unit.Massabove is a qualified name, not a reserved word. Reservingunitfor this declaration would break that existing convention, 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 are values, not a range type
There is no quantity primitive type. A quantity is a value;
its type is the dimension the value carries. Model a quantity-valued attribute
with a numeric range and give it 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. Dimensions are not a keyword or a grammar feature — they
are ordinary concepts, declared in units.dlf (see the
code samples for this page)
(Mass, Length, Time, Speed, Force, Pressure, Energy, Power,
Frequency, Area, Volume, Density, Momentum, and the other four SI
base dimensions). Drop that file into a project — dolfin has no import
statement, but a project is just a directory of .dlf files whose
declarations are all merged together — and reference a dimension by name:
concept Animal:
has weight: unit.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)
unit. is a readability convention, not a namespace import: a qualified type
name resolves by its last segment, so unit.Mass and a bare Mass are the
same reference. Analysis recognises the concept by name and 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 emits owl:DatatypeProperty
with an rdfs:range naming the dimension IRI (e.g.
https://dolfin.dev/dimension/M1), not the Mass concept itself — the value
is a literal, not an instance of that concept.
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. - 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
Compiling a quantity uses representation “L”: the value is a typed literal
whose datatype is a canonical unit IRI, and each distinct unit’s
(coefficient, dimension, symbol) is stated once in a definition block.
@prefix dq: <https://dolfin.dev/quantity#> .
:rex :weight "45"^^<https://dolfin.dev/unit/kg> .
:bella :weight "90"^^<https://dolfin.dev/unit/lb> .
# one definition block per distinct unit that appears in the data:
<https://dolfin.dev/unit/kg> dq:coefficient 1.0 ;
dq:dimension "M1" ;
dq:symbol "kg" .
<https://dolfin.dev/unit/lb> dq:coefficient 0.45359237 ;
dq:dimension "M1" ;
dq:symbol "lb" .
- Unit datatype IRI —
https://dolfin.dev/unit/+ a canonical unit string. The canonical form keeps the written unit (no SI reduction), orders numerator terms before denominator terms, and renders powers assym/sym2/sym-1:km/handkm.h^-1both becomeunit:km.h-1;m/sbecomesunit:m.s-1;N/m^2becomesunit:N.m-2. It is written as a full IRI, not a prefixed name, because the local part contains.and-. dq:terms —dq:coefficient(factor to the SI base magnitude),dq:dimension(the canonical dimension string, base orderL M T I Θ N J, e.g."L1.T-1"), anddq:symbol(the human-readable unit, e.g."km/h").- A dimensionless quantity carries no unit and degrades to a plain
"<value>"^^xsd:double.
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:
?s :topSpeed ?v .
BIND(datatype(?v) AS ?u)
?u dq:coefficient ?c ; dq:dimension ?d .
FILTER(?d = "L1.T-1" && (xsd:double(str(?v)) * ?c) >= 16.666…)
The target magnitude (16.666…) and dimension ("L1.T-1") are computed at
compile time from the comparison literal. Rules unfold the same way for the
reasoner, using dedicated dq: comparison predicates
(dq:greaterThanOrEqual, …) 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:kg renders as 45 kg, "42"^^unit:km.h-1 as 42 km/h.
The datatype IRI maps back to a symbol via the definition block’s dq:symbol.
This is representation “L”, the default. A self-describing structured-node form (“S”) that inlines
dq:value/dq:coefficient/dq:dimension/dq:symbolon a blank node, needing no definition block, is a documented alternative.
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: unit.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) |
| 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.