Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Define helpers for unitful interfaces. #698

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

RomeoV
Copy link

@RomeoV RomeoV commented Nov 20, 2023

Summary

This commit adds two simple functions WithDims (and WithUnits) that return a Quantity type with the dimensions (and units) constrained to those of the parameters

With this PR, we can write

circumference_of_circle(r::WithDims(u"m")) = pi*r^2
# or
circumference_of_square(s::WithUnits(u"m")) = 4*side

circumference_of_circle(1.0m)  # works
circumference_of_circle((1//1)m)  # works
import ForwardDiff: Dual
circumference_of_circle(Dual(1.0)m)  # also works (!), see e.g. https://github.com/JuliaDiff/ForwardDiff.jl/issues/328

The difference between WithDims and WithUnits the two is that the first one only constrains the dimension, and the latter constrains both dimension and unit (i.e. doesn't allow e.g. km) -- see Units vs Dims.

Rationale

I found myself trying to write simulation code with strongly typed interfaces, i.e. including information about the units. Initially I wrote my interfaces like so:

const Meters = typeof(1.0m);
circumference_of_circle(r::Meters) = pi*r^2

However, when trying to autodiff through this code, I run into a problem, because Meters has the numerical type Float64 baked in, and autodiff evaluates on a type Quantity{Dual{Float64}} (roughly).

We can instead define Meters like so:

const Meters{T<:Real} = Quantity{T, dimension(1.0m), unit(1.0m)}
circumference_of_circle(r::Meters{T}) where {T} = pi*r^2
# or
circumference_of_circle(r::Quantity{T, dimension(1.0m), unit(1.0m)}) where {T} = pi*r^2

but I thought a better approach would be to provide some syntactic sugar to this "unit constraint".
This led to the construction of the syntactic sugar described in the summary.

I'm happy to receive any feedback on the idea and the naming. Other names could be e.g. quantity_with_dims (but too long for my taste), or dims_as etc., but similar is already Julia lingo and feels appropriate in this context.

This commit adds two simple functions `similar_dims` (and `similar_units`)
that return a `Quantity` type with the dimensions (and units)
constrained to those of the parameters

I found myself trying to write simulation code with strongly typed
interfaces, i.e. including information about the units. Initially I
wrote my interfaces like so:

```julia
const Meters = typeof(1.0m);
circumference_of_circle(r::Meters) = pi*r^2
```

However, when trying to autodiff through this code, I run into a
problem, because `Meters` has the numerical type `Float64` baked in, and
autodiff evaluates on a type `Quantity{Dual{Float64}}` (roughly).

We can instead define `Meters` like so:

```julia
const Meters{T<:Real} = Quantity{T, dimension(1.0m), unit(1.0m)}
circumference_of_circle(r::Meters{T}) where {T} = pi*r^2
circumference_of_circle(r::Quantity{T, dimension(1.0m), unit(1.0m)}) where {T} = pi*r^2
```

but I thought a better approach would be to provide some syntactic sugar
to this "unit constraint".

With this PR, we can write

```julia
circumference_of_circle(r::similar_dims(u"m")) where {T} = pi*r^2
circumference_of_circle(r::similar_units(u"m")) where {T} = pi*r^2
```

The difference is that the first one only constrains the dimension, and
the latter constrains both dimension and unit (i.e. doesn't allow e.g. `km`).

I'm happy to receive any feedback on the idea and the naming.
Other names could be e.g. `quantity_with_dims` (but too long for my
taste), or `dims_as` etc., but `similar` is already Julia lingo and
feels appropriate in this context.
@sostock
Copy link
Collaborator

sostock commented Dec 2, 2023

Instead of WithDims(u"m") one can also use Unitful.Length, although it is different in two ways:

  • WithDims only allows <:Real numbers as values:
    julia> (1+2im)u"V" isa Unitful.Voltage
    true
    
    julia> (1+2im)u"V" isa WithDims(u"V")
    false
  • WithDims doesn’t include Levels (i.e., logarithmic quantities):
    julia> 1u"dBm" isa Unitful.Power
    true
    
    julia> 1u"dBm" isa WithDims(u"W")
    false

Is there a reason for only allowing <:Real values in WithDims and WithUnits?

src/utils.jl Outdated Show resolved Hide resolved
src/utils.jl Outdated Show resolved Hide resolved
src/utils.jl Outdated Show resolved Hide resolved
src/utils.jl Outdated Show resolved Hide resolved
test/runtests.jl Outdated Show resolved Hide resolved
test/runtests.jl Outdated Show resolved Hide resolved
RomeoV and others added 2 commits December 3, 2023 22:52
Rewrite the tests as type-checks only as suggested in the PR review.

Co-authored-by: Sebastian Stock <[email protected]>
@RomeoV
Copy link
Author

RomeoV commented Dec 4, 2023

Thanks for the review.

To your points:

Instead of WithDims(u"m") one can also use Unitful.Length

That is true for "atomic" dimensions, but becomes much more difficult for any composite dimensions (e.g. energy).
Consider the example I added to the docstring in 6d28e4c:

julia> kinetic_energy(mass::WithUnits(kg), velocity::WithUnits(m/s))::WithUnits(J) = mass*velocity^2 |> x->uconvert(J, x)
julia> kinetic_energy(1000kg, uconvert(m/s, 100km/hr))
62500000//81 J

With this PR, we can request for instance m/s, and "promise" to return Joule (J), in a very succinct way.
Notice that something like this doesn't work:

import Uniful: Length, Time
kinetic_energy(mass::Mass, velocity::Length/Time) = mass*velocity^2

because Length/Time is not defined.
Finally, even if it would work, constraining a type as Mass*Length^2/Time^2 is harder to mentally parse than WithUnits(J), and only carries dimensional information, not choice of specific units.

EDIT: I have only now noticed all the different options for pre-defined unit combinations like Unitful.Energy, Unitful.Velocity and so on, which indeed cover a lot of the use-cases that made me write this PR.
I do think that having the ability to quickly use "arithmetic" like in the example above can be useful if the required unit is not yet defined, and also being able to specify units (rather than just dimensions) can be nice, in particular for return types.

WithDims only allows <:Real numbers as values

That is true, and until I read your comment I have also never considered non-real unitful quantities.
I have since consulted some of my friends, and apparently Voltage is indeed sometimes associated with complex numbers, as you have also suggested.
Let me share three thoughts why I think constraining to reals is nonetheless a good design choice:

  • The seven SI base units are all real-valued. Therefore, any units derived through product and division of the former will also have a real value.
  • Since this PR is only syntactic sugar, my main concern is /clarity/ for the users of the interfaces defined using WithDims, e.g. kinetic_energy above. Therefore, I think the interface should be as restrictive as possible, to provide the smallest possible amount of "misunderstanding".
  • It seems to me that electrical impedance is represented as a complex number only for mathematical convenience, not out of a "natural" justification.
    Regardless, this seems to be a rare case.
    In these rare cases it is not difficult for the interface author to pass either a tuple (real and imaginary part) or to define WithDimsComplex themselves (one line of code).
    I find this a better trade-off rather than to weaken the clarity of all other interfaces.

@RomeoV
Copy link
Author

RomeoV commented Dec 4, 2023

WithDims doesn’t include Levels (i.e., logarithmic quantities)

That is true, I can get to that still. There seems to be Gain and Level, which are related to MixedUnits as far as I can see. Should be easy to support if we want to move forward with this.

@Eben60
Copy link
Contributor

Eben60 commented Dec 23, 2023

Regardless, this seems to be a rare case.

Using complex numbers for electrical units like voltage, current, or impedance is a very common case in electrical engineering.

It seems to me that electrical impedance is represented as a complex number only for mathematical convenience

Sure. It is "only" a way of concise representation of complex relations. But then, most of the mathematics is "only" a way of concise representation of complex relations.

The seven SI base units are all real-valued.

Complex numbers can be equally used to represent oscillation processes of any nature, be it mechanical or electromagnetic. Both length (displacement) and current can be "complex-valued" in this sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants