Source code for mescla.plotting.diagrams

"""Figures for EMMA and mixing analyses.

Every function takes an optional ``ax`` and returns the ``Axes`` it drew on, so the
figures compose into panels. Identity is always carried by marker shape and a direct
label as well as by colour -- see :mod:`mescla.plotting.theme` for why.
"""

from __future__ import annotations

from typing import Any

import numpy as np

from mescla._util import as_chem
from mescla.plotting.theme import (
    ENDMEMBER_SIZE,
    HAIRLINE,
    LINE_WIDTH,
    MARKERS,
    RING_WIDTH,
    SAMPLE_SIZE,
    Theme,
    apply_axes_style,
    get_theme,
    series_color,
)

__all__ = [
    "plot_measured_vs_predicted",
    "plot_mixing_line",
    "plot_ratios",
    "plot_residuals",
    "plot_scree",
    "plot_ternary",
    "plot_uspace",
]


def _new_axes(ax, theme: Theme, figsize=(6.4, 5.2)):
    import matplotlib.pyplot as plt

    if ax is None:
        _, ax = plt.subplots(figsize=figsize, facecolor=theme.surface)
    return ax


def _draw_endmembers(ax, points, labels, theme, offset=(9, 6)):
    """Draw end-members with a distinct shape, a surface ring and a direct label.

    Shape and the label carry identity; colour only reinforces it. The label is placed
    in display coordinates so it stays beside its marker whatever the data range.
    """
    for e, (point, label) in enumerate(zip(points, labels, strict=True)):
        ax.scatter(
            point[0], point[1],
            s=ENDMEMBER_SIZE,
            marker=MARKERS[e % len(MARKERS)],
            c=series_color(e, theme),
            edgecolors=theme.surface,
            linewidths=RING_WIDTH,
            zorder=5,
            label=label,
        )
        ax.annotate(
            label,
            xy=(point[0], point[1]),
            xycoords="data",
            xytext=offset,
            textcoords="offset points",
            fontsize=9,
            color=theme.ink,
            zorder=6,
            annotation_clip=True,
        )


[docs] def plot_uspace( model: Any, endmembers: Any = None, components: tuple[int, int] = (0, 1), ax: Any = None, theme: str | Theme = "light", show_hull: bool = True, ) -> Any: """The mixing diagram: samples and candidate end-members in U-space. The figure that decides whether a conceptual model is viable. Samples should fall inside the polygon spanned by the end-members; those outside cannot be explained by any non-negative mixture, and say a source is missing or a reaction is at work. Parameters ---------- model : EMMA A fitted model. endmembers : EndMembers, optional Candidates to project in and, if ``show_hull``, to join into a hull. components : tuple of int, default (0, 1) Which two components to plot. ax : matplotlib Axes, optional theme : {"light", "dark"} or Theme show_hull : bool, default True Returns ------- matplotlib Axes """ palette = get_theme(theme) ax = _new_axes(ax, palette) i, j = components scores = model.scores_ if scores.shape[1] <= max(i, j): raise ValueError( f"the fit retained only {scores.shape[1]} component(s); " f"cannot plot components {components}" ) ax.scatter( scores[:, i], scores[:, j], s=SAMPLE_SIZE, c=palette.neutral, alpha=0.55, linewidths=0, zorder=2, label="samples", ) if endmembers is not None: chem = as_chem(endmembers) projected = model.transform(chem)[:, [i, j]] if show_hull and len(projected) >= 3: from mescla.emma.endmembers import in_hull try: inside = in_hull(scores[:, [i, j]], projected) enclosed = float(np.mean(inside)) except np.linalg.LinAlgError: inside, enclosed = None, float("nan") order = _hull_order(projected) loop = np.vstack([projected[order], projected[order][:1]]) ax.plot( loop[:, 0], loop[:, 1], color=palette.ink_muted, linewidth=HAIRLINE * 1.5, zorder=3, ) ax.fill(loop[:, 0], loop[:, 1], color=palette.ink_muted, alpha=0.06, zorder=1) if inside is not None and not inside.all(): ax.scatter( scores[~inside, i], scores[~inside, j], s=SAMPLE_SIZE + 14, facecolors="none", edgecolors=palette.critical, linewidths=RING_WIDTH, zorder=4, label=f"outside hull ({100 * (1 - enclosed):.0f}%)", ) _draw_endmembers(ax, projected, chem.index, palette) variance = model.eigenvalues_ / model.eigenvalues_.sum() ax.set_xlabel(f"U{i + 1} ({100 * variance[i]:.0f}% of variance)") ax.set_ylabel(f"U{j + 1} ({100 * variance[j]:.0f}% of variance)") ax.set_title("Mixing subspace", loc="left", fontsize=11) apply_axes_style(ax, palette) ax.legend(frameon=False, fontsize=9, labelcolor=palette.ink_secondary, loc="best") return ax
def _hull_order(points: np.ndarray) -> np.ndarray: """Order 2-D points anticlockwise so the polygon does not self-intersect.""" centre = points.mean(axis=0) return np.argsort(np.arctan2(points[:, 1] - centre[1], points[:, 0] - centre[0]))
[docs] def plot_scree( model: Any, ax: Any = None, theme: str | Theme = "light", ) -> Any: """Eigenvalues against the broken-stick expectation and the rule of one. Everything is drawn on a single axis -- proportion of variance -- so the three criteria are directly comparable. (A cumulative curve would need a second y-scale, and a second scale invents relationships that are not in the data.) """ palette = get_theme(theme) ax = _new_axes(ax, palette, figsize=(6.4, 4.0)) values = model.eigenvalues_ m = len(values) proportion = values / values.sum() expected = np.array([np.sum(1.0 / np.arange(i, m + 1)) / m for i in range(1, m + 1)]) positions = np.arange(1, m + 1) retained = np.arange(m) < model.k ax.bar( positions[retained], proportion[retained], color=palette.series[0], width=0.62, zorder=2, label="retained", ) if (~retained).any(): ax.bar( positions[~retained], proportion[~retained], color=palette.neutral, alpha=0.4, width=0.62, zorder=2, label="discarded", ) ax.plot( positions, expected, color=palette.series[1], linewidth=LINE_WIDTH, marker="o", markersize=5, markeredgecolor=palette.surface, markeredgewidth=RING_WIDTH, zorder=4, label="broken-stick expectation", ) ax.axhline( 1.0 / m, color=palette.ink_muted, linewidth=HAIRLINE, zorder=3, ) ax.annotate( f"rule of one (1/{m})", xy=(m + 0.45, 1.0 / m), xytext=(0, 4), textcoords="offset points", fontsize=8, color=palette.ink_muted, va="bottom", ha="right", ) ax.set_xticks(positions) ax.set_xlabel("component") ax.set_ylabel("proportion of variance") ax.set_title( f"Rank selection: k = {model.k} => {model.n_endmembers} end-members", loc="left", fontsize=11, ) apply_axes_style(ax, palette) ax.legend(frameon=False, fontsize=9, labelcolor=palette.ink_secondary) return ax
[docs] def plot_residuals( observed: Any, fitted: np.ndarray, axes: Any = None, theme: str | Theme = "light", n_cols: int = 4, ) -> Any: """Residual against observed concentration, one panel per species. The diagnostic Hooper (2003) asks you to inspect by eye. A conservative species in a correctly ranked model leaves a structureless cloud about zero; trend or curvature is lack of fit. """ import matplotlib.pyplot as plt palette = get_theme(theme) chem = as_chem(observed) X = chem.data residual = np.asarray(fitted, float) - X n = chem.n_species n_rows = int(np.ceil(n / n_cols)) if axes is None: figure, axes = plt.subplots( n_rows, n_cols, figsize=(3.0 * n_cols, 2.5 * n_rows), facecolor=palette.surface, squeeze=False, ) figure.suptitle("Mixing residuals by species", x=0.02, ha="left", fontsize=11, color=palette.ink) axes = np.atleast_2d(axes) for k, species in enumerate(chem.species): ax = axes.flat[k] ax.axhline(0.0, color=palette.axis, linewidth=HAIRLINE, zorder=1) ax.scatter( X[:, k], residual[:, k], s=SAMPLE_SIZE, c=palette.series[0], alpha=0.6, linewidths=0, zorder=3, ) ax.set_title(species, loc="left", fontsize=10) ax.set_xlabel("observed") if k % n_cols == 0: ax.set_ylabel("predicted - observed") apply_axes_style(ax, palette) for k in range(n, axes.size): axes.flat[k].set_visible(False) if axes.size: axes.flat[0].figure.tight_layout() return axes
[docs] def plot_measured_vs_predicted( result: Any, observed: Any, ax: Any = None, theme: str | Theme = "light", log_scale: bool = True, annotate: int = 3, ) -> Any: """Measured against mixing-predicted concentration, all species on one panel. Points on the 1:1 line behave conservatively. Points above it mean the system gained the species (dissolution, desorption, exchange release); below, that it lost it (precipitation, sorption, redox consumption). Parameters ---------- annotate : int, default 3 How many of the worst-departing species to label directly. Labelling every point is unreadable, and crowded labels overlap; the table carries the rest. Labels that would collide with one already placed are dropped. """ palette = get_theme(theme) ax = _new_axes(ax, palette) chem = as_chem(observed) measured = np.nanmean(chem.data, axis=0) predicted = np.nanmean(result.fitted, axis=0) ax.scatter( predicted, measured, s=70, c=palette.series[0], edgecolors=palette.surface, linewidths=RING_WIDTH, zorder=4, label="species mean", ) finite = np.isfinite(measured) & np.isfinite(predicted) both = np.concatenate([measured[finite], predicted[finite]]) positive = both[both > 0] if log_scale and positive.size and positive.min() > 0: lo, hi = positive.min() * 0.5, positive.max() * 2.0 ax.set_xscale("log") ax.set_yscale("log") else: lo, hi = float(np.min(both)), float(np.max(both)) pad = 0.08 * (hi - lo or 1.0) lo, hi = lo - pad, hi + pad ax.plot([lo, hi], [lo, hi], color=palette.ink_muted, linewidth=HAIRLINE * 1.5, zorder=2, label="1:1 (conservative)") ax.set_xlim(lo, hi) ax.set_ylim(lo, hi) with np.errstate(divide="ignore", invalid="ignore"): departure = np.where(predicted > 0, (measured - predicted) / predicted, np.nan) placed: list[tuple[float, float]] = [] reference = (hi / lo) if log_scale and lo > 0 else (hi - lo) for k in np.argsort(-np.abs(np.nan_to_num(departure))): if len(placed) >= annotate: break position = (predicted[k], measured[k]) if log_scale and lo > 0: too_close = any( abs(np.log10(position[0] / q[0])) < 0.05 * np.log10(reference) and abs(np.log10(position[1] / q[1])) < 0.05 * np.log10(reference) for q in placed ) else: too_close = any( abs(position[0] - q[0]) < 0.05 * reference and abs(position[1] - q[1]) < 0.05 * reference for q in placed ) if too_close: continue placed.append(position) ax.annotate( chem.species[k], position, xytext=(7, 5), textcoords="offset points", fontsize=9, color=palette.ink, ) ax.set_xlabel("predicted by mixing") ax.set_ylabel("measured") ax.set_title("Measured vs mixing prediction", loc="left", fontsize=11) apply_axes_style(ax, palette) ax.legend(frameon=False, fontsize=9, labelcolor=palette.ink_secondary, loc="upper left") return ax
[docs] def plot_mixing_line( samples: Any, endmembers: Any, tracers: tuple[str, str], ax: Any = None, theme: str | Theme = "light", ) -> Any: """A bivariate tracer plot with the end-members and the mixing polygon. The oldest figure in the subject and still the most persuasive: if the samples fall on the line between two end-members, they are a mixture of them. """ palette = get_theme(theme) ax = _new_axes(ax, palette) Y = as_chem(samples) E = as_chem(endmembers) a, b = tracers for name, table in (("samples", Y), ("end-members", E)): missing = [t for t in tracers if t not in table.species] if missing: raise KeyError(f"{name} lack tracer(s) {missing}") ya, yb = Y.data[:, Y.species.index(a)], Y.data[:, Y.species.index(b)] ea, eb = E.data[:, E.species.index(a)], E.data[:, E.species.index(b)] ax.scatter(ya, yb, s=SAMPLE_SIZE, c=palette.neutral, alpha=0.55, linewidths=0, zorder=2, label="samples") points = np.column_stack([ea, eb]) if len(points) >= 2: order = _hull_order(points) if len(points) >= 3 else np.arange(len(points)) loop = points[order] if len(points) >= 3: loop = np.vstack([loop, loop[:1]]) ax.plot(loop[:, 0], loop[:, 1], color=palette.ink_muted, linewidth=HAIRLINE * 1.5, zorder=3) _draw_endmembers(ax, points, E.index, palette) ax.set_xlabel(f"{a} ({Y.units})") ax.set_ylabel(f"{b} ({Y.units})") ax.set_title(f"{b} vs {a}", loc="left", fontsize=11) apply_axes_style(ax, palette) ax.legend(frameon=False, fontsize=9, labelcolor=palette.ink_secondary) return ax
[docs] def plot_ratios( result: Any, ax: Any = None, theme: str | Theme = "light", order_by: int | None = None, x: Any = None, ) -> Any: """Stacked mixing ratios, one column per sample. Only adjacent colours touch in a stack, so the full categorical order is safe here. Parameters ---------- order_by : int, optional Sort samples by the contribution of this end-member -- much easier to read than sample order when there is no natural sequence. x : array_like, optional Positions for the columns, e.g. sampling dates. """ palette = get_theme(theme) ax = _new_axes(ax, palette, figsize=(8.0, 4.0)) ratios = result.ratios labels = result.endmembers.labels index = np.arange(len(ratios)) if x is None else np.asarray(x) if order_by is not None: sort = np.argsort(-ratios[:, order_by]) ratios = ratios[sort] index = np.arange(len(ratios)) width = 0.92 if x is None else None bottom = np.zeros(len(ratios)) for e, label in enumerate(labels): ax.bar( index, ratios[:, e], bottom=bottom, color=series_color(e, palette), label=label, width=width if width is not None else 0.8, linewidth=RING_WIDTH, edgecolor=palette.surface, zorder=2, ) bottom += ratios[:, e] ax.set_ylim(0, 1) ax.set_ylabel("mixing ratio") ax.set_xlabel("sample" if x is None else "") ax.set_title("End-member contributions", loc="left", fontsize=11) apply_axes_style(ax, palette, grid=False) ax.legend(frameon=False, fontsize=9, labelcolor=palette.ink_secondary, ncol=min(len(labels), 4), loc="upper center", bbox_to_anchor=(0.5, -0.12)) return ax
[docs] def plot_ternary( result: Any, ax: Any = None, theme: str | Theme = "light", ) -> Any: """Three-component mixing ratios on a ternary diagram. Each sample is one point inside the triangle; each corner is one end-member contributing 100%. Only defined for exactly three end-members. """ palette = get_theme(theme) ratios = result.ratios if ratios.shape[1] != 3: raise ValueError( f"a ternary diagram needs exactly 3 end-members, got {ratios.shape[1]}. " "Use plot_ratios() or plot_uspace() instead." ) ax = _new_axes(ax, palette, figsize=(5.6, 5.2)) corners = np.array([[0.0, 0.0], [1.0, 0.0], [0.5, np.sqrt(3) / 2]]) xy = ratios @ corners loop = np.vstack([corners, corners[:1]]) ax.plot(loop[:, 0], loop[:, 1], color=palette.axis, linewidth=HAIRLINE * 1.5, zorder=2) for fraction in (0.25, 0.5, 0.75): for i in range(3): j, k = (i + 1) % 3, (i + 2) % 3 start = corners[i] + fraction * (corners[j] - corners[i]) end = corners[i] + fraction * (corners[k] - corners[i]) ax.plot(*zip(start, end, strict=True), color=palette.grid, linewidth=HAIRLINE, zorder=1) ax.scatter(xy[:, 0], xy[:, 1], s=SAMPLE_SIZE + 8, c=palette.series[0], alpha=0.65, linewidths=0, zorder=4) # Give the corner labels room: with the axis off, matplotlib's autoscale would # otherwise clip the two that sit below the baseline. ax.set_xlim(-0.16, 1.16) ax.set_ylim(-0.14, np.sqrt(3) / 2 + 0.14) offsets = [(-0.05, -0.07), (0.05, -0.07), (0.0, 0.04)] for e, (label, corner, offset) in enumerate( zip(result.endmembers.labels, corners, offsets, strict=True) ): ax.scatter(*corner, s=ENDMEMBER_SIZE, marker=MARKERS[e % len(MARKERS)], c=series_color(e, palette), edgecolors=palette.surface, linewidths=RING_WIDTH, zorder=5) ax.annotate( label, corner + np.array(offset), ha="center", va="top" if offset[1] < 0 else "bottom", fontsize=10, color=palette.ink, ) ax.set_aspect("equal") ax.axis("off") ax.set_facecolor(palette.surface) if ax.figure is not None: ax.figure.set_facecolor(palette.surface) ax.set_title("Mixing ratios", loc="left", fontsize=11, color=palette.ink) return ax
[docs] def plot_ratios_with_uncertainty( result: Any, uncertainty: Any, axes: Any = None, theme: str | Theme = "light", order_by: int | None = None, max_samples: int = 60, ) -> Any: """Mixing ratios with their intervals, one panel per end-member. The figure that stops a reader over-reading a five-point difference between two samples. A stacked bar of point estimates cannot show that a fraction is +/- 0.12; this can, so prefer it whenever you have run :func:`~mescla.uncertainty.montecarlo.monte_carlo_ratios` or a resampling. Parameters ---------- result : MixingResult uncertainty : dict As returned by :func:`~mescla.uncertainty.montecarlo.monte_carlo_ratios`, :func:`~mescla.uncertainty.resampling.jackknife_endmembers` or :func:`~mescla.uncertainty.resampling.bootstrap_endmembers` -- anything with ``lower`` and ``upper`` arrays shaped like ``result.ratios``. axes : array of matplotlib Axes, optional theme : {"light", "dark"} or Theme order_by : int, optional Sort samples by this end-member's contribution. Sorting makes the spread legible; sample order rarely does. max_samples : int, default 60 Above this many samples the dots collide; the panel switches to a shaded interval band with the estimate drawn over it. Returns ------- array of matplotlib Axes """ import matplotlib.pyplot as plt palette = get_theme(theme) ratios = np.asarray(result.ratios, float) lower = np.asarray(uncertainty["lower"], float) upper = np.asarray(uncertainty["upper"], float) if lower.shape != ratios.shape or upper.shape != ratios.shape: raise ValueError( f"the interval arrays {lower.shape} do not match the ratios {ratios.shape}" ) labels = result.endmembers.labels n_endmembers = len(labels) order = np.argsort(-ratios[:, order_by]) if order_by is not None else np.arange(len(ratios)) ratios, lower, upper = ratios[order], lower[order], upper[order] positions = np.arange(len(ratios)) dense = len(ratios) > max_samples if axes is None: _, axes = plt.subplots( n_endmembers, 1, figsize=(9.0, 1.9 * n_endmembers + 0.8), sharex=True, facecolor=palette.surface, squeeze=False, ) axes = axes.ravel() axes = np.atleast_1d(axes) for e, (ax, label) in enumerate(zip(axes, labels, strict=True)): colour = series_color(e, palette) if dense: ax.fill_between( positions, lower[:, e], upper[:, e], color=colour, alpha=0.22, linewidth=0, zorder=2, label="95% interval", ) ax.plot(positions, ratios[:, e], color=colour, linewidth=LINE_WIDTH, zorder=3, label="estimate") else: ax.vlines(positions, lower[:, e], upper[:, e], color=colour, alpha=0.55, linewidth=LINE_WIDTH, zorder=2, label="95% interval") ax.plot(positions, ratios[:, e], linestyle="none", marker="o", markersize=5, color=colour, markeredgecolor=palette.surface, markeredgewidth=RING_WIDTH, zorder=3, label="estimate") mean_width = float(np.mean(upper[:, e] - lower[:, e])) ax.annotate( f"{label} mean contribution {ratios[:, e].mean():.0%}, " f"typical interval {mean_width:.2f} wide", xy=(0.005, 0.92), xycoords="axes fraction", fontsize=9, color=palette.ink, va="top", ) ax.set_ylim(0, 1) ax.set_ylabel("fraction") apply_axes_style(ax, palette) axes[-1].set_xlabel("sample" + (" (sorted)" if order_by is not None else "")) axes[0].legend(frameon=False, fontsize=9, labelcolor=palette.ink_secondary, loc="upper right", ncol=2) axes[0].figure.tight_layout() return axes
[docs] def plot_loadings( model: Any, n_components: int | None = None, axes: Any = None, theme: str | Theme = "light", ) -> Any: """Eigenvector loadings: which species drive which component. The figure where an EMMA becomes chemistry rather than linear algebra. In a hydrochemical data set the first component almost always has every species loading the same sign -- that is the dilution axis, and it discriminates nothing. The sources are separated by the components after it, where the signs split. Sign is carried by bar direction and reinforced by the diverging colours, and the species are ordered by their loading on the first component shown so the structure reads left to right. """ import matplotlib.pyplot as plt palette = get_theme(theme) loadings = model.loadings_frame() n_components = n_components or min(model.k + 1, loadings.shape[1]) negative, _, positive = palette.diverging if axes is None: _, axes = plt.subplots( 1, n_components, figsize=(3.6 * n_components, 3.8), sharey=True, facecolor=palette.surface, squeeze=False, ) axes = axes.ravel() axes = np.atleast_1d(axes) order = np.argsort(loadings.iloc[:, 0].to_numpy()) species = [loadings.index[i] for i in order] variance = model.eigenvalues_ / model.eigenvalues_.sum() for c, ax in enumerate(axes[:n_components]): values = loadings.iloc[order, c].to_numpy() ax.barh( np.arange(len(values)), values, color=[positive if v >= 0 else negative for v in values], height=0.68, zorder=3, ) ax.axvline(0.0, color=palette.axis, linewidth=HAIRLINE, zorder=2) ax.set_yticks(np.arange(len(values))) ax.set_yticklabels(species, fontsize=9) ax.set_xlabel("loading") ax.set_title( f"EV{c + 1} ({100 * variance[c]:.0f}% of variance)" + (" — retained" if c < model.k else ""), loc="left", fontsize=10, ) apply_axes_style(ax, palette) ax.grid(axis="y", visible=False) for ax in axes[n_components:]: ax.set_visible(False) axes[0].figure.tight_layout() return axes
[docs] def plot_reaction_departures( table: Any, ax: Any = None, theme: str | Theme = "light", threshold: float = 0.10, ) -> Any: """Departure from the mixing prediction, per species: the one-panel reaction summary. Sources to one side, sinks to the other, conservative species clustered at zero. The polarity is the whole content, so it is drawn with the diverging pair and a neutral midpoint rather than with categorical colours. Parameters ---------- table : DataFrame From :func:`~mescla.reactions.residuals.classify_source_sink`. threshold : float, default 0.10 The conservative band, drawn so the reader can see the rule that produced the verdicts rather than having to trust it. """ palette = get_theme(theme) ax = _new_axes(ax, palette, figsize=(7.4, 0.42 * len(table) + 1.8)) ordered = table.sort_values("relative_departure") values = ordered["relative_departure"].to_numpy(dtype=float) behaviour = ordered["behaviour"].to_list() negative, _, positive = palette.diverging colours = [ palette.neutral if b == "conservative" else (positive if v > 0 else negative) for v, b in zip(values, behaviour, strict=True) ] ax.axvspan(-threshold, threshold, color=palette.grid, alpha=0.55, zorder=1) ax.barh(np.arange(len(values)), values, color=colours, height=0.68, zorder=3) ax.axvline(0.0, color=palette.axis, linewidth=HAIRLINE, zorder=2) ax.set_yticks(np.arange(len(values))) ax.set_yticklabels(ordered.index, fontsize=9) ax.set_xlabel("departure from the mixing prediction") ax.set_title( "← lost by the system gained by the system →", loc="center", fontsize=9, color=palette.ink_secondary, ) ax.xaxis.set_major_formatter(lambda v, _: f"{v:+.0%}") # Leave room for the value labels, which sit outside the bar ends. span = float(np.nanmax(np.abs(values))) if len(values) else threshold limit = max(span, threshold) * 1.28 ax.set_xlim(-limit, limit) for y, (value, kind) in enumerate(zip(values, behaviour, strict=True)): if kind != "conservative": ax.annotate( f"{value:+.0%}", xy=(value, y), xytext=(5 if value > 0 else -5, 0), textcoords="offset points", va="center", ha="left" if value > 0 else "right", fontsize=8, color=palette.ink, ) apply_axes_style(ax, palette) ax.grid(axis="y", visible=False) ax.annotate( f"shaded band: |departure| < {threshold:.0%}, called conservative", xy=(0.5, -0.16), xycoords="axes fraction", ha="center", fontsize=8, color=palette.ink_muted, ) return ax