julia icon indicating copy to clipboard operation
julia copied to clipboard

rad-deg conversions for more types

Open aplavin opened this issue 3 years ago • 24 comments

rad-deg conversion is basically a simple scaling, and removing the explicit restriction makes it work for all types defining *, / both in Base and in packages

aplavin avatar Jul 06 '22 12:07 aplavin

I'm personally not a fan of loosening signatures beyond necessity. ::Number looks to me a reasonable signature for such functions, what kind of non-Number types could benefit from being able to run rad2deg/deg2rad?

giordano avatar Jul 09 '22 11:07 giordano

Within Base, I often wish these conversions worked with ranges and missings. They are semantically just multiplications by the scaling factor, and should behave accordingly. Currently, we have:

# turns a range into a vector
julia> (0:90) .|> deg2rad
91-element Vector{Float64}:
...

# doesn't work
julia> (0:90) |> deg2rad
ERROR: MethodError: no method matching deg2rad(::UnitRange{Int64})

# works and returns a range
julia> (0:90) * pi/180
0.0:0.017453292519943295:1.5707963267948966

and

julia> missing |> deg2rad
ERROR: MethodError: no method matching deg2rad(::Missing)

julia> missing * pi/180
missing

Note that other functions aren't restricted to specific types, where it makes sense: eg https://github.com/JuliaLang/julia/blob/8499445de68f44ab9f8f44b446405bb8a11fe72b/base/math.jl#L1301.

aplavin avatar Jul 09 '22 12:07 aplavin

bump

aplavin avatar Aug 15 '22 07:08 aplavin

From triage: this is potentially confusing since rad2deg(vec) would work whereas most similar cases require a dot. We do think the signature can be expanded to Union{Number, Missing} at least. Personally I don't mind dropping the type so much but it bothers some people.

JeffBezanson avatar Aug 18 '22 18:08 JeffBezanson

For me, converting between degree and radian ranges is also a very important usecase, that won't be solved by adding Missing. I find the triage decision unfortunate, this means the best way to convert rad/deg still remains x * pi/180 instead of deg2rad(x). The conversion is basically a multiplication, so I would personally expect it to behave accordingly.

aplavin avatar Aug 19 '22 13:08 aplavin

One interesting problem with allowing this to be called on e.g. arrays is that it might give a different answer than broadcasting: we will compute array/pi first, followed by *180, instead of calling rad2deg on each element, which might use the float method (a single multiply) or indeed different custom implementations for each element. One example is Unitful, which changes between radian and degree units in these functions.

A possible improvement is to define new Irrationals for pi/180 and 180/pi, so we can define these functions directly as scaling by those constants, but that still wouldn't handle the Unitful case.

JeffBezanson avatar Sep 02 '22 19:09 JeffBezanson

Great point! While conceptually deg <-> rad is just a multiplication, it's not always the case in code.

Still, it's a linear operation, and linear operations tend to work directly on arrays in Julia: examples are addition and multiplication by a number (such as pi/180 :) ). Such arithmetic operations often have separate implementations for arrays and ranges. Adding a few simple deg2rad methods in this fashion would solve all issues:

  • Ranges can be converted, keeping them ranges and not arrays
  • Everything works correctly, even with fancy types like Unitful (and Unitful ranges!)

Implementation would just broadcast deg2rad for abstractarrays, with separate methods for ranges - exactly as in https://github.com/JuliaLang/julia/blob/24f53313eed272f6d783c2e241fcd72dc342a2df/base/range.jl#L1258-L1266 with deg2rad instead of -. Even the comment there fits, "linear operations on ranges".

aplavin avatar Sep 02 '22 19:09 aplavin

Thanks for the comments above I realized that removing the type constraint is wrong and can cause incorrect behavior.

However, extending deg <-> rad conversions to arrays is totally fine, and nicely fits other linear operations implemented for them. Stuff like real(A), -A, etc works for all arrays automatically in Julia, and correctly returns ranges when passed a range. Here, I just add deg2rad and rad2deg to this list of operations.

Also, adding Missing following @JeffBezanson's comment.

Will add tests if this change is OK as it is.

aplavin avatar Feb 05 '23 19:02 aplavin

Pardon my curiosity but why do you prefer deg2rad(0:90) over deg2rad.(0:90)? They have the same return type, are equally performant, and take pretty much the same effort to type. What am I missing?

barucden avatar Feb 06 '23 12:02 barucden

Well, that's not the case :)

They have the same return type

julia> deg2rad.(0:90)
91-element Vector{Float64}:

julia> deg2rad(0:90)  # this PR
91-element LinRange{Float64, Int64}:

are equally performant

julia> @btime deg2rad.(0:90);
  103.393 ns (1 allocation: 816 bytes)

julia> @btime deg2rad(0:90);  # this PR
  1.267 ns (0 allocations: 0 bytes)

aplavin avatar Feb 06 '23 13:02 aplavin

I see! I didn't notice the methods for ranges before. Does the current implementation give exact results though (is collect(rad2deg(x)) == rad2deg.(x))?

barucden avatar Feb 06 '23 13:02 barucden

Thanks for pointing this out! Now this equality seems to hold, I followed the approach of how it's done elsewhere in the codebase.

Don't understand why CI fails, any pointers?..

aplavin avatar Feb 07 '23 16:02 aplavin

I believe for f in (rad2deg, deg2rad) should iterate over symbols: for f in (:rad2deg, :deg2rad). Although I am not sure if that is what caused the build failure.

However, I still have doubts about the numerical accuracy. For example:

julia> collect(rad2deg(2:10)) == rad2deg.(2:10)
true
# but
julia> collect(rad2deg(1:10)) == rad2deg.(1:10)
false

The broadcasting approach simply applies function f to every element of range r, resulting in an array a, hence a[i] = f(r[i]). On the contrary, as I understand it, your approach boils down to a[i] = f(first(r)) + (i - 1) * s, where s = f(step(r)) or s = (f(last(r)) - f(first(r))) / length(r) depending on the type of r. So despite being theoretically correct for a linear f, it seems to me that it will always be less numerically accurate than the broadcasting approach. I might be wrong though.

(Have you considered JuliaArrays/MappedArrays.jl for your use-case? It supports ranges as well.)

barucden avatar Feb 08 '23 09:02 barucden

I think this kind of accuracy is too high of a bar to require from a function. It doesn't even hold for simple multiplication:

julia> f(x) = x*1.2345
julia> collect(f(1:10)) == f.(1:10)
false

Floating point computations are approximate anyway.

(Have you considered JuliaArrays/MappedArrays.jl for your use-case? It supports ranges as well.)

Yes, there are lazy map implementations like this (though I prefer FlexiMaps.mapview for generality :) ), but the result isn't a range:

julia> r = mappedarray(deg2rad, 1:10)
10-element mappedarray(deg2rad, ::UnitRange{Int64}) with eltype Float64:
...

julia> step(r)
ERROR: MethodError: no method matching step

UPD: FlexiMaps.mapview now does preserve a range when possible:

julia> using FlexiMaps

julia> r = mapview(deg2rad, 1:10)
0.017453292519943295:0.017453292519943295:0.17453292519943295

julia> step(r)
0.017453292519943295

aplavin avatar Feb 08 '23 09:02 aplavin

Bump...

aplavin avatar Mar 09 '23 09:03 aplavin

And another bump...

aplavin avatar Apr 04 '23 17:04 aplavin

Bump

aplavin avatar May 14 '23 12:05 aplavin

bump

aplavin avatar Jun 01 '23 20:06 aplavin

bump

aplavin avatar Jun 13 '23 07:06 aplavin

Bump @giordano @JeffBezanson (pinging those who commented earlier in the discussion)

aplavin avatar Jul 13 '23 06:07 aplavin

bump!

aplavin avatar Aug 16 '23 20:08 aplavin

bump

aplavin avatar Oct 19 '23 18:10 aplavin

bump...

aplavin avatar Dec 27 '23 21:12 aplavin

if you remove the ::AbstractArray methods I suspect you will find more success with the remaining components of the PR. I still feel like those are unnecessary to add

adienes avatar Dec 27 '23 21:12 adienes

I do not think that expanding of the scope of rad2deg and deg2rad to arrays is a good idea. The reason multiplication and division support array/scalar interactions is because of the mathematical notion of vector-scalar products. The only justification I can see here is for performance, so instead I recommend specializing on broadcasted ranges.

Widening the type signature to accept missing is okay. I'm not enthusiastic about it, but it does seem appropriate.

(aside: I don't like the functions at all #54144)

LilithHafner avatar Apr 19 '24 14:04 LilithHafner