[ANN] DynamicQuantities.jl: type stable physical quantities

DynamicQuantities.jl v0.4 is now a one-stop shop for physical quantities, with a built-in units parser available. Thanks to @odow @Oscar_Smith @mcabbott @j-fu for review and feedback.

You can create a quantity with the @u_str macro, like so:

julia> 0.2u"m^0.5 * kg * mol^3"
0.2 m¹ᐟ² kg mol³

julia> randn(3) .* u"GHz"
3-element Vector{Quantity{Float64, DynamicQuantities.FixedRational{Int32, 25200}}}:
 1.558255538781646e9 s⁻¹
 -1.0549637909362864e8 s⁻¹
 1.392099679975791e9 s⁻¹

julia> u"m/s^0.5"
1.0 m s⁻¹ᐟ²

The “dimensions” are now printed in terms of the SI base units to avoid misinterpretation.

These are the available units:

Base:

  • m: Length in meters. Available variants: fm, pm, nm, μm (/um), cm, dm, mm, km, Mm, Gm.
  • g: Mass in grams. Available variants: μg (/ug), mg, kg.
  • s: Time in seconds. Available variants: fs, ps, ns, μs (/us), ms, min, h (/hr), day, yr, kyr, Myr, Gyr.
  • A: Current in Amperes. Available variants: nA, μA (/uA), mA, kA.
  • K: Temperature in Kelvin. Available variant: mK.
  • cd: Luminosity in candela. Available variant: mcd.
  • mol: Amount in moles. Available variant: mmol.

Derived:

  • Hz: Frequency in Hertz. Available variants: kHz, MHz, GHz.
  • N: Force in Newtons.
  • Pa: Pressure in Pascals. Available variant: kPa.
  • J: Energy in Joules. Available variant: kJ.
  • W: Power in Watts. Available variants: kW, MW, GW.
  • C: Charge in Coulombs.
  • V: Voltage in Volts. Available variants: kV, MV, GV.
  • F: Capacitance in Farads.
  • Ω: Resistance in Ohms. Available variant: . Also available is ASCII ohm (with variant mohm).
  • T: Magnetic flux density in Teslas.
  • L: Volume in liters. Available variants: mL, dL.
  • bar: Pressure in bars.

In addition to @u_str and uparse which evaluate expressions in this namespace, you can also access these explicitly with, e.g., DynamicQuantities.Units.kW. All of these “units” are of type Quantity{Float64,FixedRational{Int32,25200}}, and uparse will return quantities of this type, but you can convert as needed with convert(::Type{Quantity{T,R}}, ...).

The Unitful.jl integration is now just for convenience, but is unnecessary for parsing user input.

4 Likes

Regarding encoding units in the “type” domain vs the “value” domain, I wonder about a design that makes it possible to have the best of both worlds. For example, removing the constraint R <: Real in

struct Dimensions{R}
    length::R
    mass::R
    time::R
    current::R
    temperature::R
    luminosity::R
    amount::R
end

You could define a dimension of length dynamically as Dimensions(1, 0, 0, 0, 0, 0, 0)or statically as e.g. Dimesnions(Val(1), Val(0), Val(0), Val(0), ...).

1 Like

That could be interesting to try. As a package developer, you could test a function for type stability, and if it is, you could switch to using a TypedDimension object. As to “Why not use Unitful.jl”, perhaps it’s nicer to have a way of easily switching back and forth between typed and untyped quantities, in case you find your code turns out to be type stable? But maybe I should just try to maintain full API compatibility with Unitful in this case so you could use that instead of duplicating efforts.

In any case, the following PR could be used to implement a TypedDimension:
Make `AbstractQuantity` and `AbstractDimensions` by MilesCranmer · Pull Request #24 · SymbolicML/DynamicQuantities.jl · GitHub.

For example, here’s a custom quantity with only length/mass/time dimensions:

julia> using DynamicQuantities

julia> struct MyDimensions{R} <: AbstractDimensions{R}
           length::R
           mass::R
           time::R
           scale::R
       end

julia> struct MyQuantity{T,R} <: AbstractQuantity{T,R}
           value::T
           dimensions::MyDimensions{R}
       end

julia> x = MyQuantity(1.2, time=5//2, scale=3)
1.2 s⁵ᐟ² scale³

julia> x^2
1.44 s⁵ scale⁶

All the math and utilities are defined on the abstract types (and use fieldnames to find the dimensions), so everything should just work.

The other thing you would need is overload getproperty to get dimensions from the type Val{length} instead of from the value, and everything else should work.

If we can’t use AbstractArray as a trait, maybe we could just make AbstractQuantity a trait using SimpleTraits.jl instead?

This would look like:

@traitfn function Base.:+(
    x::Q1, y::Q2
) where {AbstractQuantity{Q1},AbstractQuantity{Q2}}
    dimension(x) != dimension(y) && throw(DimensionError(x, y))
    Q = promote_type(typeof.((x, y))...)
    return Q(ustrip(x) + ustrip(y), dimension(x))
end

where we would have

@traitimpl AbstractQuantity{Q} <- quantity_like(Q)

where quantity_like(Q) checks that type Q defines a .value and a .dimensions, and that the typeof(dimensions) satisfies AbstractDimensions{X}.

Then we can create a QuantityArray{T,R,N,V<:AbstractArray{T,N}} <: AbstractArray{T,N}, and have

@traitimpl AbstractQuantity{QuantityArray}

So it can dispatch on all the internal calls, while still being treated like an array in other libraries.

I guess this approach is also easier to integrate in other libraries that rely on dispatch to some abstract type.

Now that we have type-stable dimensional analysis, maybe it’s time for type-stable Multidimensional Analysis.

1 Like

That is an awesome initiative, thanks for working on it.

I wonder what are your thoughts on scientific types besides the dimensions? The ScientificTypes.jl package has many issues that I am planning to address, but it has the scitype trait function that queries if a quantity is “Continuous”, “Count”, … This is very useful in statistical work and often ignored in type systems that absorb data from users.

If we could somehow integrate these worlds into a single world with dimensions and scientific interpretation, that would be great. Any chance a trait like scitype could be added to the dynamic quantities? If a quantity is m/s we automatically know it is a Continuous variable for example.

1 Like

Sure, happy to explore putting it in as an extension! Would you want all quantity objects to be “Continuous”? Or would use of the mol be “Count”? What if it is a fraction of a mol? I guess if that would be the case, things get tricky, because the physical dimensions are not revealed in the type – so any trait would technically be a union of continuous/count.

I guess it’s also a bit difficult to nail down a single definition because it’s also context-dependent. s⁻¹ could be a “count” if you are counting discrete things per second. But it could also be the frequency of radiation, which is continuous.

Regarding multidimensional analysis: it was a rough attempt years ago, but you might be interested in GitHub - goretkin/UnitfulLinearAlgebra.jl

2 Likes

I think that shouldn’t be a problem, we can take scitype of values as well.

That is an interesting observation. I would always assume that rates or frequencies are continuous variables. The count variable here would be the numerator of the rate variable.

Actually, good point, it would be continuous either way there.

I suppose if and only if uamount(::Quantity) > 0 (the dimension corresponding to mol) while every other dimension is exactly 0, it would it be a “Count”, while everything else would be Continuous? Or are there other types?

I didn’t explore these ideas enough alongside units, but you can take a look at the type hierarchy here to see if something else pops up in your mind:

mol is 6e22 molecules and can be considered continuous in most practical cases. C is 6e18 elementary charges, and in most cases can be considered contimuous, too (though in many other cases physics deals with elementary charges). All other units are “even more” continuous, aren’t they?

I think I would agree with you. I guess it depends on what these terms are supposed to mean in the package. @juliohm?

Note also that the Quantity type has an arbitrary element type (i.e., Quantity{T}). So you could store integers in a Quantity for example. Maybe if you are counting 1 kg weights, then it makes sense to treat it as a count.

In other words, maybe

scitype(::Type{Q}) where {Q<:AbstractQuantity{<:Integer}} = Count
scitype(::Type{Q}) where {Q<:AbstractQuantity{<:AbstractFloat}} = Continuous

makes the most sense?


But this is difficult because quantities are defined in the base SI units. So you would not be able to “count” milliseconds if we take this type definition…

The general use of these scientific types is to decide whether or not we can take means, standard deviations, and other statistics that are only valid with continuous variables. Whenever we get categorical variables such as group labels we cannot perform certain types of analysis and need to do something else instead. If the type system could provide us with this information, we could then automate a bunch of transformations with mixed continuous and categorical variables.

In that case it sounds like the most sensible choice is to just forward the value type? i.e.,

scitype(q::AbstractQuantity) = scitype(ustrip(q))

which would make:

scitype(Quantity{Int}(1.0u"m")) == Count
scitype(Quantity{Float64}(1.0u"m")) == Continuous

there are more molecules in a mol than representable Float64 values in the same exponent (52 bits ≈ 3.5e15 unique numbers)

@juliohm I finally got around to making a PR to add scitype; please take a look!

It just forwards scitype to the value. If you want to use integers for quantities then the scitype will be Count, which I think sort of feels semantically best. Thoughts?

using DynamicQuantities
using ScientificTypes

x = 1.0u"m/s"

@test scitype(x) <: Continuous
@test scitype([x]) <: AbstractVector{<:Continuous}
@test scitype(randn(32) .* u"m/s") <: AbstractVector{<:Continuous}

# Converted to integer 1 m/s => Count
@test scitype(Quantity{Int}(x)) <: Count

X = (; x=randn(32) .* u"m/s")

@test scitype(X) <: Table{<:AbstractVector{<:Continuous}}

sch = schema(X)

@test first(sch.names) == :x
@test first(sch.scitypes) == Continuous
@test first(sch.types) <: Quantity{Float64}
2 Likes

That is awesome @MilesCranmer , thanks for the quick implementation of the feature request!

2 Likes