Makie: one (nice?) way to control the units of axes

Suppose we have (x,y) data following y = f(x), x is measured in centimeters, and y is measured in micrometers. When plotting the (x,y) data, we would want to show the x-axis in centimeters and the y-axis in micrometers, i.e., show the tick labels generated in appropriate units without large exponents of base 10.

In Makie (or any plotting package in general), this is achieved by dividing the data by the units, like

xs = ...  # x data
ys = ... # y data
xunit = 1e-2  # preferred unit of x data (centimeter)
yunit = 1e-6  # preferred unit of y data (micrometer)

fig = Figure()
fig[1,1] = Axis(fig, ...)
lines!(xs ./ xunit, ys ./ yunit, ...)  # divide data by units

This works, but I don’t like the fact that the data passed to lines!() change from xs to xs ./ xunit. When we plot data in different units, we want the way the data are displayed changed, not the data themselves. So, I feel that changing the units should be achieved by changing the properties of axes, rather than the data themselves. In other words, I would like to do something like

xs = ...
ys = ...

fig = Figure()
fig[1,1] = Axis(fig, xunit=1e-2, yunit=1e-6, ...)  # units are set as properties of Axis
lines!(xs, ys, ...)  # data remain unchanged even if units change

Another advantage of this way of controlling the axis units is that we can control the units of all plotted datasets in one place. For example, suppose we have many datasets (xs1, ys1), (xs2, ys2), (xs3, ys3), … Then the code would look like

lines!(xs1, ys1, ...)
lines!(xs2, ys2, ...)
lines!(xs2, ys2, ...)

rather than the more convoluted

lines!(xs1 ./ xunit, ys1 ./yunit, ...)
lines!(xs2 ./ xunit, ys2 ./yunit, ...)
lines!(xs3 ./ xunit, ys3 ./yunit, ...)

Currently we don’t have properties like xunit and yunit in Makie.Axis, so I have been looking for a simple workaround for quite a while. Recently, I noticed that Makie’s docstrings have been updated with information about customization:

help?> Axis.xtickformat
[...]
  Usually, the tick values are determined first using Makie.get_tickvalues, after which
  Makie.get_ticklabels(xtickformat, xtickvalues) is called. If there is a special method
  defined, tick values and labels can be determined together using Makie.get_ticks instead.
[...]

Using the information as a clue, I experimented with a few possibilities, and here is the one I like. Define a type that contains the axis unit:

struct AxisUnit
    unit
end

Then, define Makie.get_ticks mentioned in the docstrings for this type:

function Makie.get_ticks(ticks, scale, au::AxisUnit, vmin, vmax)
    tickvalues, ticklabels = Makie.get_ticks(ticks, scale, Makie.automatic, vmin / au.unit, vmax / au.unit)
    tickvalues .*= au.unit

    return tickvalues, ticklabels
end

This function delegates the arguments to the default Makie.get_ticks, but normalizes the axis bounds vmin and vmax with respect to au.unit before doing so in order to generate correct tick values and labels in the unit. However, because the data to plot are not normalized, the function unnormalizes the tick values before returning.

With these two simple additions, we can control the axis units as follows:

fig = Figure()
fig[1,1] = Axis(fig, xtickformat=AxisUnit(1e-2), ytickformat=AxisUnit(1e-6), ...)
xs = ...
ys = ...
lines!(xs, ys, ...)

Here is an actual working example

using CairoMakie

# Custom code for axis unit control
struct AxisUnit
    unit
end

function Makie.get_ticks(ticks, scale, au::AxisUnit, vmin, vmax)
    tickvalues, ticklabels = Makie.get_ticks(ticks, scale, Makie.automatic, vmin / au.unit, vmax / au.unit)
    tickvalues .*= au.unit

    return tickvalues, ticklabels
end

# Two x-datasets between 0 and 1 cm
xs1 = sort(rand(100)) .* 1e-2
xs2 = sort(rand(200)) .* 1e-2

# Two y-datasets between -1 and 1 µm
ys1 = cos.(π .* xs1 / 1e-2) .* 1e-6
ys2 = sin.(π .* xs2 / 1e-2) .* 1e-6

# Plots
fig = Figure(fontsize=24)
fig[1,1] = Axis(fig, xtickformat=AxisUnit(1e-2), ytickformat=AxisUnit(1e-6), xlabel="cm", ylabel="µm")
lines!(xs1, ys1, color=:red, linewidth=3, label="cosine")
lines!(xs2, ys2, color=:blue, linewidth=3, label="sine")
axislegend()
display(fig)

Here is the result:

Hope people find this useful. If many people like this feature, I may want to create a pull request.

5 Likes

There is some work ongoing to give Axis actual awareness of special types being used on its x and y axes. Like units, categorical values, timestamps. This will also hook into tick formatting, but go a step further, so that plotting incompatible data doesn’t work. But I don’t have a timeline, we want to get the 0.20 release done first.

1 Like

Thanks for sharing this. Just a nitpick about semantics.

I usually think of a unit as something with dimensions, e.g. m, g. In my opinion your code does scaling to deal with prefixes and such. For example, 1e-2 = centi and 1e-6 = µ, without caring about the base unit of m. And also works for things not related to prefixes, such as one might take an axis in radians and plot in multiples of pi.

So I would recommend calling the adjustment AxisScale or some such. Nevertheless, thanks for figuring this out and showing you managed to accomplish this.

1 Like

@artkuo, glad to know that you like my finding. And thanks for the suggestion! I agree with you. If I end up creating a PR, I will name the type appropriately.

I’ve never used Makie, but you could look into how Unitful.jl and Plots.jl interact (formerly UnitfulRecipes.jl).

So this PR should achieve that automatically, without specifying a x/y unit.
For units with the same base unit like time, space etc, it also supports mixing them in the same axis.
It should offer pretty complete Unitful support once merged.

3 Likes

@gustaphe and @sdanisch, the concept of tagging units to data and showing them in the units of axes is the most correct procedure in principle. However, I find that such a procedure is sometimes too convoluted. If data are already tagged with Unitful units, that’s a different story, but in many cases, data are produced as plain numbers, even though they are produced in some assumed units (e.g., solutions of differential equations solvers). Plotting such numeric data directly, without having to use Unitful, feels more straightforward to me. Having to learn how to use Unitful in order to perform axis scaling feels like too much for me, because plotting is often just a means, not an end goal: I want to generate plots by the simplest method possible and move on to the next step.

Having to use Unitful for axis scaling could be also too restrictive sometimes. There are many cases where using Unitful could lead to weird situations; for example, in some cases I want to plot two datasets that are in different physical dimensions (e.g., one in length and one in mass) on the same axes, to inspect the correlation between the two.

I was initially supportive about the idea of attaching units to data for axis scaling, but for these reasons I don’t like the idea anymore. It is definitely a nice feature to have, but it shouldn’t be the only way to achieve axis scaling.

1 Like

That is a typical case where you not being able to do what you want is a good thing.

1 Like

This is quite a strong statement to make in general.

Do you think it’s completely unreasonable to inspect the correlation of to different quantities, that are conceptually equivalent, e.g. kinetic energy and temperature but obviously cannot carry the same unit?

Sure, for any kind of publication you would have to homogenize it in a way, (e.g. by multiplying temperature with boltzmanns constant) but for a quick consistency check I think it’s not outright wrong to skip this step and still plot them on the same axis with appropriate scaling.

Just my 2c…

2 Likes

Then you plot them against each other (looking for a straight line), or their ratio (looking for a constant). Visually identifying correlation is difficult, you’re likely to get both false positives and false negatives.

If you’re looking for things like “how long after the spike in pressure does the temperature start rising?”, my preferred way is plot(plot(t, p), plot(t, T); layout=(2,1)). No unit confusion, no need to rescale manually.