Happy to share DynamicQuantities.jl v0.7.0!
The package has had some major changes over the last few versions, so I think it deserved a new post.
DynamicQuantities defines a simple statically-typed Quantity
type for storing physical units.
Physical dimensions are stored as a value, as opposed to a parametric type, as in Unitful.jl. This is done to allow for calculations where physical dimensions are not known at compile time.
Changes since the last post (described more below)
- Performance improvements (via better compiler inlining)
- Units, and unit parsing via the
@u_str
macro - Physical constants, and constant parsing via the
Constants.*
prefix (also in@u_str
) SymbolicDimensions
for working with symbolic units and constants- Standard
Dimensions
will eagerly convert to SI units; this avoids it. - Symbolic unit/constant parsing via the
@us_str
macro.
- Standard
QuantityArray <: AbstractArray
for efficiently storing arrays of quantities that have the same units- Extensions for ScientificTypes.jl, LinearAlgebra.jl, as well as an extension to convert to/from Unitful.jl quantities
AbstractQuantity
andAbstractDimensions
for extending behavior or defining custom spaces of physical dimensions
Thanks to contributions from Gaurav Arya @odow @Oscar_Smith @j-fu, and suggestions/feedback from @non-Jedi @ChrisRackauckas @mcabbott and many others in the last thread and on GitHub!
Performance
DynamicQuantities can greatly outperform Unitful when the compiler cannot infer dimensions in a function:
julia> using BenchmarkTools, DynamicQuantities; import Unitful
julia> dyn_uni = 0.2u"m^0.5 * kg * mol^3"
0.2 mĀ¹įĀ² kg molĀ³
julia> unitful = convert(Unitful.Quantity, dyn_uni)
0.2 kg mĀ¹įĀ² molĀ³
julia> f(x, i) = x ^ i * 0.3;
julia> @btime f($dyn_uni, 1);
2.750 ns (0 allocations: 0 bytes)
julia> @btime f($unitful, 1);
2.718 Ī¼s (30 allocations: 1.34 KiB)
Note the Ī¼ and n: this is a 1000x speedup. Here, the DynamicQuantities quantity object allows the compiler to build a function that is type stable, while the Unitful quantity object, which stores its dimensions in the type, requires type inference at runtime.
However, if the dimensions in your function can be inferred by the compiler, then you can get better speeds with Unitful:
julia> g(x) = x ^ 2 * 0.3;
julia> @btime g($dyn_uni);
1.875 ns (0 allocations: 0 bytes)
julia> @btime g($unitful);
1.500 ns (0 allocations: 0 bytes)
While both of these are type stable, because Unitful parametrizes the type on the dimensions, functions can specialize to units and the compiler can optimize away units from the code.
Aside: The compiler seems to be pretty good at inlining things especially with the recent update (thanks to Gaurav Arya for helping get this through!), so this performance gap seems to have shrunk for even statically typed calculations. This above calculation used to be 5x in favor of Unitful. However, this depends on compiler constant propagation so it is calculation dependent how big this gap would be.
Types
At the heart of the package is just two immutable structs:
struct Dimensions{R<:Real} <: AbstractDimensions{R}
length::R
mass::R
time::R
current::R
temperature::R
luminosity::R
amount::R
end
struct Quantity{T,D<:AbstractDimensions} <: AbstractQuantity{T,D}
value::T
dimensions::D
end
The R
type here is typically a rational-like number (the default is an internal FixedRational
type ā a rational number with fixed denominator which gives much faster operations than Rational
).
Whatās nice about the abstract interface is you can just write a custom AbstractDimensions
type with the physical dimensions you want to use, and via the use of @oxinaboxās Tricks.jl, static_fieldnames
is used to compile all of the unit propagation methods at first call. So e.g., struct MyDimensions{R} <: AbstractDimensions{R}; length::R; time::R; end
would work out of the box! (Quantity(0.3, MyDimensions, length=1, mass=-1)
would then be 0.3 m/s
, and you could calculate away)
Generic usage
You can create a Quantity
object by using the convenience macro u"..."
:
julia> x = 0.3u"km/s"
300.0 m sā»Ā¹
julia> room_temp = 100u"kPa"
100000.0 mā»Ā¹ kg sā»Ā²
This supports a wide range of SI base and derived units, with common prefixes.
You can also construct values explicitly with the Quantity
type, with a value and keyword arguments for the powers of the physical dimensions (mass
, length
, time
, current
, temperature
, luminosity
, amount
):
julia> x = Quantity(300.0, length=1, time=-1)
300.0 m sā»Ā¹
Elementary calculations with +, -, *, /, sqrt, cbrt, abs
are supported, and ^
will use rationalize
to get a reasonable power from exponentiation:
julia> x * y
12600.0 m kg sā»Ā¹
julia> x / y
7.142857142857143 m kgā»Ā¹ sā»Ā¹
julia> x ^ 3
2.7e7 mĀ³ sā»Ā³
julia> x ^ -1
0.0033333333333333335 mā»Ā¹ s
julia> sqrt(x)
17.320508075688775 mĀ¹įĀ² sā»Ā¹įĀ²
julia> x ^ 1.5
5196.152422706632 mĀ³įĀ² sā»Ā³įĀ²
Each of these values has the same type, which means we donāt need to perform type inference at runtime.
Furthermore, we can do dimensional analysis by detecting DimensionError
:
julia> x + 3 * x
1.2 mĀ¹įĀ² kg
julia> x + y
ERROR: DimensionError: 0.3 mĀ¹įĀ² kg and 10.2 kgĀ² sā»Ā² have incompatible dimensions
The dimensions of a Quantity
can be accessed either with dimension(quantity)
for the entire Dimensions
object:
julia> dimension(x)
mĀ¹įĀ² kg
or with umass
, ulength
, etc., for the various dimensions:
julia> umass(x)
1//1
julia> ulength(x)
1//2
Finally, you can strip units with ustrip
:
julia> ustrip(x)
0.2
Constants
There are a variety of physical constants accessible
via the Constants
submodule:
julia> Constants.c
2.99792458e8 m sā»Ā¹
These can also be used inside the u"..."
macro:
julia> u"Constants.c * Hz"
2.99792458e8 m sā»Ā²
For the full list, see the docs.
Symbolic Units
You can also choose to not eagerly convert to SI base units, instead leaving the units as the user had written them. For example:
julia> q = 100us"cm * kPa"
100.0 cm kPa
julia> q^2
10000.0 cmĀ² kPaĀ²
You can convert to regular SI base units with expand_units
:
julia> expand_units(q^2)
1.0e6 kgĀ² sā»ā“
This also works with constants:
julia> x = us"Constants.c * Hz"
1.0 Hz c
julia> x^2
1.0 HzĀ² cĀ²
julia> expand_units(x^2)
8.987551787368176e16 mĀ² sā»ā“
This dimensions type works a bit differently as it stores all the dimensions in a sparse vector (source for the curious). All unit calculations are performed as operations on this sparse vector (this is convenient for user interfaces, and still reasonably fast, but not as fast as the regular immutable Dimensions
).
Arrays
For working with an array of quantities that have the same dimensions, you can use a QuantityArray
:
julia> ar = QuantityArray(rand(3), u"m/s")
3-element QuantityArray(::Vector{Float64}, ::Quantity{Float64, Dimensions{DynamicQuantities.FixedRational{Int32, 25200}}}):
0.2729202669351497 m sā»Ā¹
0.992546340360901 m sā»Ā¹
0.16863543422972482 m sā»Ā¹
This QuantityArray
is a subtype <:AbstractArray{Quantity{Float64,Dimensions{...}},1}
,
meaning that indexing a specific element will return a Quantity
:
julia> ar[2]
0.992546340360901 m sā»Ā¹
julia> ar[2] *= 2
1.985092680721802 m sā»Ā¹
julia> ar[2] += 0.5u"m/s"
2.485092680721802 m sā»Ā¹
Which performs dimension checks.
This has a custom broadcasting interface which allows the compiler to avoid redundant dimension calculations and dimensional analysis, relative to if you had simply used an array of quantities:
julia> f(v) = v^2 * 1.5;
julia> @btime $f.(xa) setup=(xa = randn(100000) .* u"km/s");
109.500 Ī¼s (2 allocations: 3.81 MiB)
julia> @btime $f.(qa) setup=(xa = randn(100000) .* u"km/s"; qa = QuantityArray(xa));
50.917 Ī¼s (3 allocations: 781.34 KiB)
So we can see the QuantityArray
version saves on both time and memory.
Unitful
DynamicQuantities allows you to convert back and forth from Unitful.jl:
julia> import Unitful; import DynamicQuantities
julia> x = 0.5Unitful.u"km/s"
0.5 km sā»Ā¹
julia> y = convert(DynamicQuantities.Quantity, x)
500.0 m sā»Ā¹
julia> y2 = y^2 * 0.3
75000.0 mĀ² sā»Ā²
julia> x2 = convert(Unitful.Quantity, y2)
75000.0 mĀ² sā»Ā²
julia> x^2*0.3 == x2
true
Other
One other thing to mention, which is actually part of the reason I started working on this package: SymbolicRegression.jl now supports dimensional constraints as part of equation discovery searches! This feature required the creation of this package, as expressions are runtime generated so Unitful.jl could not be used efficiently. Example here: Examples Ā· SymbolicRegression.jl