Overloading getproperty(::PyObject) - Plotting Measurements.jl errorbars with PyPlot.jl

I want to automatically plot arrays of Measurement's as scatterplots with errorbars using PyPlot.jl. For the interactive PyPlot interface adding the following method to scatter() works nicely:

using PyPlot
import PyPlot: scatter
import Measurements: value, uncertainty

function scatter(x::AbstractArray{<:Measurement}, y::AbstractArray{<:Measurement}, args...; kwargs...)
    errorbar(value.(x), value.(y), uncertainty.(y), uncertainty.(x), args...; kwargs...)
end

scatter(x, y::AbstractArray{<:Measurement}, args...; kwargs...) = scatter(x .± 0, y, args...; kwargs...)
scatter(x::AbstractArray{<:Measurement}, y, args...; kwargs...) = scatter(x, y .± 0, args...; kwargs...)

# Test the new scatter
x = 1:0.1:3
y = sin.(x) .± 0.2cos.(x)
scatter(x, y, marker="s", color="C2")

However, I don’t know how to preceed to get a similar effect with the object-oriented interface of matplotlib. I.e. I want to be able to do

fig, ax = PyPlot.subplots(figsize=(6,4))
x = 1:0.1:3
y = sin.(x) .± 0.2cos.(x)
ax.scatter(x, y, marker="s", color="C2")

and get the same plot as before.

I guess I would have to somehow overload getproperty(::PyObject, ::Symbol) to get a different method returned for getproperty(ax, :plot). But since ax is of type PyObject, I can’t really dispatch on that without affecting all other PyObjects as well. Also, is it possible to add methods to a PyObject <bound method Axes.plot of <AxesSubplot:>>, or is that all unchangeable from julia?

This is possible, but realize that you would have to monkey-patch Matplotlib itself (which is possible via PyCall, but not generally recommended)…

1 Like

Sounds like it is time to dive into the depths of PyCall (or decide I don’t really need the feature that badly) :smiley:

It’s not really the depths — you do it pretty much the same way you would do it in Python. To change the Axes.scatter method, I think you can do something like

let oldscatter = matplotlib.axes.Axes.scatter
    myscatter(args...) = oldscatter(args...)
    function myscatter(self, x::AbstractArray{<:Measurement}, ...)
        # do something 
    end
    matplotlib.axes.Axes.scatter = myscatter # monkey-patch the Axes class
end

where you should remember that class methods take self as a first argument.

2 Likes

It seems there is no self argument when calling ax.scatter: If I do as you suggest

using PyPlot
using Measurements
import Measurements: value, uncertainty

let oldscatter = matplotlib.axes.Axes.scatter
    function myscatter(args...; kwargs...)
        @show args
        @show kwargs
        oldscatter(args...; kwargs...)
    end
    function myscatter(self,
                       x::AbstractArray{<:Measurement},
                       y::AbstractArray{<:Measurement},
                       args...; kwargs...)
        self.errorbar(value.(x), value.(y), uncertainty.(y), uncertainty.(x), args...; kwargs...)
    end
    matplotlib.axes.Axes.scatter = myscatter
end

and then test via

fig, ax = PyPlot.subplots(figsize=(6,4))
x = 1:0.1:3
y = sin.(x) .± 0.2cos.(x)
ax.scatter(x.± 0, y, marker="s", color="C2")

I get the following errors

args = (Measurement{Float64}[1.0 ± 0.0, 1.1 ± 0.0, 1.2 ± 0.0, 1.3 ± 0.0, 1.4 ± 0.0, 1.5 ± 0.0, 1.6 ± 0.0, 1.7 ± 0.0, 1.8 ± 0.0, 1.9 ± 0.0, 2.0 ± 0.0, 2.1 ± 0.0, 2.2 ± 0.0, 2.3 ± 0.0, 2.4 ± 0.0, 2.5 ± 0.0, 2.6 ± 0.0, 2.7 ± 0.0, 2.8 ± 0.0, 2.9 ± 0.0, 3.0 ± 0.0], Measurement{Float64}[0.84 ± 0.11, 0.891 ± 0.091, 0.932 ± 0.072, 0.964 ± 0.053, 0.985 ± 0.034, 0.997 ± 0.014, 0.9996 ± -0.0058, 0.992 ± -0.026, 0.974 ± -0.045, 0.946 ± -0.065, 0.909 ± -0.083, 0.86 ± -0.1, 0.81 ± -0.12, 0.75 ± -0.13, 0.68 ± -0.15, 0.6 ± -0.16, 0.52 ± -0.17, 0.43 ± -0.18, 0.33 ± -0.19, 0.24 ± -0.19, 0.14 ± -0.2])
kwargs = Base.Iterators.Pairs(:color => "C1")
ERROR: MethodError: no method matching Float64(::Measurement{Float64})
Closest candidates are:
  (::Type{T})(::Real, ::RoundingMode) where T<:AbstractFloat at rounding.jl:200
  (::Type{T})(::T) where T<:Number at boot.jl:760
  (::Type{T})(::AbstractChar) where T<:Union{AbstractChar, Number} at char.jl:50
  ...

indicating that the first version of myscatter is called and without a self argument.

Where is self here? I could of course change to myscatter(x::AbstractArray{<:Measurement}, y::...) to get correctly dispatched on, but then I don’t know how to get self to draw to the correct axis.

I wonder if Python is getting confused because myscatter gets converted to a callable Python object, but not a “real” Python function? Maybe try

matplotlib.axes.Axes.scatter = PyCall.jlfun2pyfun(myscatter)

which wraps it in a Python lambda.

Nope, now it doesn’t even call myscatter but uses the old scatter method (The args = ... output doesn’t show up and I get the same error message)