diff --git a/Project.toml b/Project.toml index 4105b05e..df029d09 100644 --- a/Project.toml +++ b/Project.toml @@ -11,12 +11,14 @@ Tricks = "410a4b4d-49e4-4fbc-ab6d-cb71b17b3775" [weakdeps] LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" ScientificTypes = "321657f4-b219-11e9-178b-2701a2544e81" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [extensions] DynamicQuantitiesLinearAlgebraExt = "LinearAlgebra" +DynamicQuantitiesMakieExt = "Makie" DynamicQuantitiesMeasurementsExt = "Measurements" DynamicQuantitiesScientificTypesExt = "ScientificTypes" DynamicQuantitiesUnitfulExt = "Unitful" @@ -24,6 +26,7 @@ DynamicQuantitiesUnitfulExt = "Unitful" [compat] DispatchDoctor = "0.4" LinearAlgebra = "1" +Makie = ">= 0.21.0" Measurements = "2" PrecompileTools = "1" ScientificTypes = "3" @@ -34,6 +37,7 @@ julia = "1.10" [extras] LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" ScientificTypes = "321657f4-b219-11e9-178b-2701a2544e81" Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" diff --git a/docs/Project.toml b/docs/Project.toml index dfa65cd1..d09d2cd7 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,2 +1,4 @@ [deps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" diff --git a/docs/src/examples.md b/docs/src/examples.md index 47a056ee..24bd198c 100644 --- a/docs/src/examples.md +++ b/docs/src/examples.md @@ -40,13 +40,13 @@ Also, using `DynamicQuantities.Constants`, we were able to obtain the (dimension Let's solve a simple projectile motion problem. First load the `DynamicQuantities` module: -```julia +```@example projectile using DynamicQuantities ``` Set up initial conditions as quantities: -```julia +```@example projectile # Can explicitly import units: using DynamicQuantities: km, m, s, min @@ -54,39 +54,44 @@ y0 = 10km v0 = 250m/s θ = deg2rad(60) g = 9.81m/s^2 +nothing # hide ``` Next, we use trig functions to calculate x and y components of initial velocity. `vx0` is the x component and `vy0` is the y component: -```julia +```@example projectile vx0 = v0 * cos(θ) vy0 = v0 * sin(θ) +nothing # hide ``` Next, let's create a time vector from 0 seconds to 1.3 minutes. Note that these are the same dimension (time), so it's fine to treat them as dimensionally equivalent! -```julia +```@example projectile t = range(0s, 1.3min, length=100) +nothing # hide ``` Next, use kinematic equations to calculate x and y as a function of time. `x(t)` is the x position at time t, and `y(t)` is the y position: -```julia +```@example projectile x(t) = vx0*t y(t) = vy0*t - 0.5*g*t^2 + y0 +nothing # hide ``` These are functions, so let's evaluate them: -```julia +```@example projectile x_si = x.(t) y_si = y.(t) +nothing # hide ``` These are regular vectors of quantities @@ -106,6 +111,8 @@ Now, we plot: plot(x_km, y_km, label="Trajectory", xlabel="x [km]", ylabel="y [km]") ``` +See [Plotting](@ref) for more plotting support with units. + ## 3. Using dimensional angles Say that we wish to track angles as a unit, rather than assume @@ -504,3 +511,60 @@ function my_func(x::UnionAbstractQuantity{T,D}) where {T,D} return x / ustrip(x) end ``` + +### Plotting + +We can use [`Makie.jl`](https://docs.makie.org/v0.22/) to create plots with units. Below are a few usage examples. See [Makie.jl > Dimension conversion](https://docs.makie.org/stable/explanations/dim-converts#Current-conversions-in-Makie) for more. + +!!! warning "Experimental" + Unit support is still a new feature, so please report an issue if you notice any unintended behavior. + +Continuing from [2. Projectile motion](@ref), we can also plot `x_si` and `y_si` directly without needing to manually strip their units beforehand: + +```@example projectile +using CairoMakie + +lines(x_si, y_si; axis=(xlabel="x", ylabel="y")) +``` + +To convert units, we pass a `DQConversion` object to `axis` with our desired unit: + +```@example projectile +# Temporary until this is upstreamed to Makie.jl +const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion + +lines(x_si, y_si; + axis = ( + xlabel = "x", + ylabel = "y", + dim1_conversion = DQConversion(us"km"), + dim2_conversion = DQConversion(us"km"), + ) +) +``` + +!!! note + A [Symbolic Dimensions](@ref) object is passed to `DQConversion` for this conversion to work properly. Passing a regular `Quantity`, e.g., (u"km") will throw an error. + +Finally, the desired units for a figure can also be set ahead of time. All plot objects within it will automatically convert to the given units: + +```@example projectile +fig = Figure() + +ax = Axis(fig[1, 1]; + xlabel = "time", + ylabel = "displacement", + dim1_conversion=DQConversion(us"s"), + dim2_conversion=DQConversion(us"km"), +) + +lines!(ax, t, x_si; label="x") +lines!(ax, t, y_si; label="y") + +axislegend() + +fig +``` + +!!! tip + For nice unit support with Makie.jl and tabular data, see [AlgebraOfGraphics.jl > Units](https://aog.makie.org/dev/examples/scales/units#units). diff --git a/ext/DynamicQuantitiesMakieExt.jl b/ext/DynamicQuantitiesMakieExt.jl new file mode 100644 index 00000000..0d1ab993 --- /dev/null +++ b/ext/DynamicQuantitiesMakieExt.jl @@ -0,0 +1,170 @@ +module DynamicQuantitiesMakieExt + +using DynamicQuantities: UnionAbstractQuantity, SymbolicDimensions, ustrip, dimension +using TestItems: @testitem + +import Makie as M + +M.expand_dimensions(::M.PointBased, y::AbstractVector{<:UnionAbstractQuantity}) = (keys(y), y) +M.create_dim_conversion(::Type{<:UnionAbstractQuantity}) = DQConversion() +M.MakieCore.should_dim_convert(::Type{<:UnionAbstractQuantity}) = true + +unit_string(quantity::UnionAbstractQuantity) = string(dimension(quantity)) + +function unit_convert(::M.Automatic, x) + x +end + +function unit_convert(quantity::UnionAbstractQuantity, x::AbstractArray) + # Note: unit_convert.(Ref(quantity), x) currently causes broadcasting error for `QuantityArray`s + map(Base.Fix1(unit_convert, quantity), x) +end + +function unit_convert(quantity::UnionAbstractQuantity, value) + conv = ustrip(quantity, value) + return Float64(conv) +end + +""" + DQConversion(unit=automatic; units_in_label=false) + +Allows to plot arrays of DynamicQuantity objects into an axis. + +# Arguments +- `unit=automatic`: sets the unit as conversion target. If left at automatic, the best unit will be chosen for all plots + values plotted to the axis (e.g. years for long periods, or km for long distances, or nanoseconds for short times). +- `units_in_label=true`: controls, whether plots are shown in the label_prefix of the axis labels, or in the tick labels + +# Examples + +```julia +using DynamicQuantities, CairoMakie + +# DQConversion will get chosen automatically, +scatter(1:4, [1u"ns", 2u"ns", 3u"ns", 4u"ns"]) +``` + +Fix unit to always use Meter & display unit in the xlabel: + +```julia +# Temporary until this is upstreamed to Makie.jl +const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion + +dqc = DQConversion(us"m"; units_in_label=false) + +scatter(1:4, [0.01u"km", 0.02u"km", 0.03u"km", 0.04u"km"]; axis=(dim2_conversion=dqc, xlabel="x (km)")) +``` +""" +struct DQConversion <: M.AbstractDimConversion + quantity::M.Observable{UnionAbstractQuantity{T1, SymbolicDimensions{T2}} where {T1 <: Real, T2 <: Real}} + automatic_units::Bool + units_in_label::M.Observable{Bool} +end + +function DQConversion(quantity=M.automatic; units_in_label=true) + return DQConversion(quantity, quantity isa M.Automatic, units_in_label) +end + +M.needs_tick_update_observable(conversion::DQConversion) = conversion.quantity + +function M.get_ticks(conversion::DQConversion, ticks, scale, formatter, vmin, vmax) + quantity = conversion.quantity[] + quantity isa M.Automatic && return [], [] + unit_str = unit_string(quantity) + tick_vals, labels = M.get_ticks(ticks, scale, formatter, vmin, vmax) + if conversion.units_in_label[] + labels = labels .* unit_str + end + return tick_vals, labels +end + +function M.convert_dim_observable(conversion::DQConversion, value_obs::M.Observable, deregister) + # TODO: replace with update_extrema + if conversion.automatic_units + conversion.quantity[] = oneunit(value_obs[][1]) + end + result = map(conversion.quantity, value_obs; ignore_equal_values=true) do unit, values + if !isempty(values) + # try if conversion works, to through error if not! + # Is there a function for this to check in DynamicQuantities? + unit_convert(unit, values[1]) + end + return M.convert_dim_value(conversion, values) + end + append!(deregister, result.inputs) + return result +end + +function M.convert_dim_value(conversion::DQConversion, values) + return unit_convert(conversion.quantity[], values) +end + +@testitem "1 arg expansion" begin + using DynamicQuantities, Makie, Dates + + f, ax, pl = scatter(u"m" .* (1:10)) + @test pl isa Scatter{Tuple{Vector{Point2{Float64}}}} +end + +@testitem "recipe" begin + using DynamicQuantities, Makie, Dates + const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion + + @recipe(DQPlot, x) do scene + return Attributes() + end + + function Makie.plot!(plot::DQPlot) + return scatter!(plot, plot.x, map(x -> x .* u"s", plot.x)) + end + + f, ax, pl = dqplot(1:5) + + pl_conversion = Makie.get_conversions(pl) + ax_conversion = Makie.get_conversions(ax) + + @test pl_conversion[2] isa DQConversion + @test ax_conversion[2] isa DQConversion + @test pl.plots[1][1][] == Point{2,Float32}.(1:5, 1:5) +end + +@testitem "unit switching" begin + using DynamicQuantities, Makie + f, ax, pl = scatter((1:10)u"m") + @test_throws DynamicQuantities.DimensionError scatter!(ax, (1:10)u"kg") + @test_throws MethodError scatter!(ax, (1:10)) +end + +@testitem "observables cleanup" begin + using DynamicQuantities, Makie + + function test_cleanup(arg) + obs = Observable(arg) + f, ax, pl = scatter(obs) + @test length(obs.listeners) == 1 + delete!(ax, pl) + @test length(obs.listeners) == 0 + end + + test_cleanup([0.01u"km", 0.02u"km", 0.03u"km", 0.04u"km"]) +end + +# TODO: Move upstream to Makie.jl +@testitem "reftest" begin + using DynamicQuantities, Makie + const DQConversion = Base.get_extension(DynamicQuantities, :DynamicQuantitiesMakieExt).DQConversion + + fig = Figure() + + ax1 = Axis(fig[1, 1]; dim2_conversion=DQConversion(us"J/s")) + ax2 = Axis(fig[1, 2]; dim2_conversion=DQConversion(us"mm/m^2")) + ax3 = Axis(fig[2, 1]; dim1_conversion=DQConversion(us"W/m^2"), dim2_conversion=DQConversion(us"μm")) + ax4 = Axis(fig[2, 2]; dim1_conversion=DQConversion(us"W/m^2")) + + scatter!(ax1, (1:10) .* u"J/s") + scatter!(ax2, (1:10) .* u"K", exp.(1:10) .* u"mm/m^2") + scatter!(ax3, 10 .^ (1:6) .* u"W/m^2", (1:6) .* 1000 .* u"nm") + scatter!(ax4, (0:10) .* u"W/m^2", (0:10) .* u"g") + scatter!(ax4, (0:10) .* u"kW/m^2", (0:10) .* u"kg") +end + +end diff --git a/test/Project.toml b/test/Project.toml index cf8f60df..d7f27a97 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,6 +2,7 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" DispatchDoctor = "8d63f2c5-f18a-4cf2-ba9d-b3f60fc568c8" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" Ratios = "c84ed2f1-dad5-54f0-aa8e-dbefe2724439" SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"