Guitar Neck ###

import numpy as np
import pandas as pd
from plotnine import (
    ggplot,
    aes,
    geom_point,
    geom_path,
    scale_x_continuous,
    scale_y_continuous,
    guides,
    theme,
    element_line,
    element_rect,
)
from mizani.transforms import trans

Using a transformed x-axis to visualise guitar chords

The x-axis is transformed to resemble the narrowing width of frets on a 25.5 inch Strat. To do that we create custom transformation.

The key parts of any transform object are the transform and inverse functions.

class frets_trans(trans):
    """
    Frets Transformation
    """

    number_of_frets = 23  # Including fret 0
    domain = (0, number_of_frets - 1)

    @staticmethod
    def transform(x):
        x = np.asarray(x)
        return 25.5 - (25.5 / (2 ** (x / 12)))

    @staticmethod
    def inverse(x):
        x = np.asarray(x)
        return 12 * np.log2(25.5 / (25.5 - x))

    @classmethod
    def breaks_(cls, limits):
        # Fixed major breaks
        return cls.domain

    @classmethod
    def minor_breaks(cls, major, limits):
        # The major breaks as passed to this method are in transformed space.
        # The minor breaks are calculated in data space to reveal the
        # non-linearity of the scale.
        _major = cls.inverse(major)
        minor = cls.transform(np.linspace(*_major, cls.number_of_frets))
        return minor

The above transform is different from most in that, breaks and minor breaks do not change. This is common of very specialized scales. It can also be a key requirement when creating graphics for demontration purposes.

Some chord Data

# Notes: the 0 fret is an open strum, all other frets are played half-way between fret bars.
# The strings are 1:low E, 2: A, 3: D, 4: G, 5: B, 6: E
c_chord = pd.DataFrame({"Fret": [0, 2.5, 1.5, 0, 0.5, 0], "String": [1, 2, 3, 4, 5, 6]})

# Sequence based on the number of notes in the chord
c_chord["Sequence"] = list(range(1, 1 + len(c_chord["Fret"])))

# Standard markings for a Stratocaster
markings = pd.DataFrame(
    {
        "Fret": [2.5, 4.5, 6.5, 8.5, 11.5, 11.5, 14.5, 16.5, 18.5, 20.5],
        "String": [3.5, 3.5, 3.5, 3.5, 2, 5, 3.5, 3.5, 3.5, 3.5],
    }
)

Visualizing the chord

# Gallery, elaborate

# Look and feel of the graphic
neck_color = "#FFDDCC"
fret_color = "#998888"
string_color = "#AA9944"

neck_theme = theme(
    figure_size=(10, 2),
    panel_background=element_rect(fill=neck_color),
    panel_grid_major_y=element_line(color=string_color, size=2.2),
    panel_grid_major_x=element_line(color=fret_color, size=2.2),
    panel_grid_minor_x=element_line(color=fret_color, size=1),
)

(
    ggplot(c_chord, aes("Fret", "String"))
    + geom_path(aes(color="Sequence"), size=3)
    + geom_point(aes(color="Sequence"), fill="#FFFFFF", size=3)
    + geom_point(data=markings, fill="#000000", size=4)
    + scale_x_continuous(trans=frets_trans)
    + scale_y_continuous(breaks=range(0, 7), minor_breaks=[])
    + guides(color=False)
    + neck_theme
)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
File ~/scm/python/plotnine/.venv/lib/python3.13/site-packages/IPython/core/formatters.py:984, in IPythonDisplayFormatter.__call__(self, obj)
    982 method = get_real_method(obj, self.print_method)
    983 if method is not None:
--> 984     method()
    985     return True

File ~/scm/python/plotnine/plotnine/ggplot.py:149, in ggplot._ipython_display_(self)
    142 def _ipython_display_(self):
    143     """
    144     Display plot in the output of the cell
    145 
    146     This method will always be called when a ggplot object is the
    147     last in the cell.
    148     """
--> 149     self._display()

File ~/scm/python/plotnine/plotnine/ggplot.py:190, in ggplot._display(self)
    187     self.theme = self.theme.to_retina()
    189 buf = BytesIO()
--> 190 self.save(buf, "png" if format == "retina" else format, verbose=False)
    191 figure_size_px = self.theme._figure_size_px
    192 display_func = get_display_function(format, figure_size_px)

File ~/scm/python/plotnine/plotnine/ggplot.py:702, in ggplot.save(self, filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs)
    653 def save(
    654     self,
    655     filename: Optional[str | Path | BytesIO] = None,
   (...)    664     **kwargs: Any,
    665 ):
    666     """
    667     Save a ggplot object as an image file
    668 
   (...)    700         Additional arguments to pass to matplotlib `savefig()`.
    701     """
--> 702     sv = self.save_helper(
    703         filename=filename,
    704         format=format,
    705         path=path,
    706         width=width,
    707         height=height,
    708         units=units,
    709         dpi=dpi,
    710         limitsize=limitsize,
    711         verbose=verbose,
    712         **kwargs,
    713     )
    715     with plot_context(self).rc_context:
    716         sv.figure.savefig(**sv.kwargs)

File ~/scm/python/plotnine/plotnine/ggplot.py:650, in ggplot.save_helper(self, filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs)
    647 if dpi is not None:
    648     self.theme = self.theme + theme(dpi=dpi)
--> 650 figure = self.draw(show=False)
    651 return mpl_save_view(figure, fig_kwargs)

File ~/scm/python/plotnine/plotnine/ggplot.py:322, in ggplot.draw(self, show)
    319     self._create_figure()
    320 figure = self.figure
--> 322 self._build()
    324 # setup
    325 self.axs = self.facet.setup(self)

File ~/scm/python/plotnine/plotnine/ggplot.py:423, in ggplot._build(self)
    420     layers.map(npscales)
    422 # Train coordinate system
--> 423 layout.setup_panel_params(self.coordinates)
    425 # fill in the defaults
    426 layers.use_defaults_after_scale(scales)

File ~/scm/python/plotnine/plotnine/facets/layout.py:198, in Layout.setup_panel_params(self, coord)
    196 for i, j in self.layout[cols].itertuples(index=False):
    197     i, j = i - 1, j - 1
--> 198     params = coord.setup_panel_params(
    199         self.panel_scales_x[i], self.panel_scales_y[j]
    200     )
    201     self.panel_params.append(params)

File ~/scm/python/plotnine/plotnine/coords/coord_cartesian.py:88, in coord_cartesian.setup_panel_params(self, scale_x, scale_y)
     84     sv = scale.view(limits=coord_limits, range=ranges.range)
     85     return sv
     87 out = panel_view(
---> 88     x=get_scale_view(scale_x, self.limits.x),
     89     y=get_scale_view(scale_y, self.limits.y),
     90 )
     91 return out

File ~/scm/python/plotnine/plotnine/coords/coord_cartesian.py:84, in coord_cartesian.setup_panel_params.<locals>.get_scale_view(scale, limits)
     80 expansion = scale.default_expansion(expand=self.expand)
     81 ranges = scale.expand_limits(
     82     scale.final_limits, expansion, coord_limits, identity_trans()
     83 )
---> 84 sv = scale.view(limits=coord_limits, range=ranges.range)
     85 return sv

File ~/scm/python/plotnine/plotnine/scales/scale_continuous.py:328, in scale_continuous.view(self, limits, range)
    325 labels = self.get_labels(breaks)
    327 ubreaks = self.get_breaks(range)
--> 328 minor_breaks = self.get_minor_breaks(ubreaks, range)
    330 sv = scale_view(
    331     scale=self,
    332     aesthetics=self.aesthetics,
   (...)    338     minor_breaks=minor_breaks,
    339 )
    340 return sv

File ~/scm/python/plotnine/plotnine/scales/scale_continuous.py:473, in scale_continuous.get_minor_breaks(self, major, limits)
    471     minor_breaks = []
    472 elif self.minor_breaks is True:
--> 473     minor_breaks: Sequence[float] = self._trans.minor_breaks(
    474         major, limits
    475     )  # pyright: ignore
    476 elif isinstance(self.minor_breaks, int):
    477     minor_breaks: Sequence[float] = self._trans.minor_breaks(
    478         major,
    479         limits,
    480         self.minor_breaks,  # pyright: ignore
    481     )

Cell In[2], line 30, in frets_trans.minor_breaks(cls, major, limits)
     24 @classmethod
     25 def minor_breaks(cls, major, limits):
     26     # The major breaks as passed to this method are in transformed space.
     27     # The minor breaks are calculated in data space to reveal the
     28     # non-linearity of the scale.
     29     _major = cls.inverse(major)
---> 30     minor = cls.transform(np.linspace(*_major, cls.number_of_frets))
     31     return minor

File ~/scm/python/plotnine/.venv/lib/python3.13/site-packages/numpy/_core/function_base.py:121, in linspace(start, stop, num, endpoint, retstep, dtype, axis, device)
     25 @array_function_dispatch(_linspace_dispatcher)
     26 def linspace(start, stop, num=50, endpoint=True, retstep=False, dtype=None,
     27              axis=0, *, device=None):
     28     """
     29     Return evenly spaced numbers over a specified interval.
     30 
   (...)    119 
    120     """
--> 121     num = operator.index(num)
    122     if num < 0:
    123         raise ValueError(
    124             "Number of samples, %s, must be non-negative." % num
    125         )

TypeError: 'numpy.float64' object cannot be interpreted as an integer
<plotnine.ggplot.ggplot at 0x120e718c0>

Credit: This example was motivated by Jonathan Vitale who wanted to create graphics for a guitar scale trainer.