"""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