"""
vizDistr.py — Distribution visualisation for UPXO grain structure analyses.
Provides the DistrViz class for plotting scalar grain property distributions
(area, perimeter, aspect ratio, …) and angular misorientation distributions
(MDF). Designed to complement ebsdviz.plot_mdf — use DistrViz.plot_mdf when
peaks are not yet computed; use ebsdviz.plot_mdf for fully annotated MDF with
peak labels and KDE from the peaks dict.
Typical usage
-------------
Grain size:
dv = DistrViz(areas, label='Grain area', units='µm²')
fig, ax = dv.plot_hist(bins=40, show_kde=True, step_size=rdr.step_size)
plt.show()
dv.print_stats()
MDF (lightweight, no peaks dict required):
dv = DistrViz.from_mdf(mdf)
fig, ax = dv.plot_mdf(mdf)
plt.show()
Multiple properties:
fig, axes = DistrViz.multi(
{'Grain area': areas, 'Aspect ratio': ar, 'Perimeter': perim},
units_dict={'Grain area': 'µm²', 'Aspect ratio': '', 'Perimeter': 'µm'},
step_size=rdr.step_size,
)
plt.show()
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats as sp_stats
# Physical units for common grain morphological properties.
# Imported by EBSDReader.see_distr and repgen2d.see_distr to avoid duplication.
PROP_UNITS = {
'area': 'µm²',
'perimeter': 'µm',
'eq_diameter': 'µm',
'major_axis_length': 'µm',
'minor_axis_length': 'µm',
'aspect_ratio': '',
'eccentricity': '',
'solidity': '',
'npixels': 'px',
# selfrepr_morphology.py's parameter set (Self Repr.-1 page)
'circle_eq_dia': 'µm',
'gb_segment_length': 'µm',
'circularity': '',
'coord_number': '',
'n_tjp': '',
}
# CSL reference angles for cubic symmetry (Σ label → disorientation angle °)
_CSL_ANGLES = {
'S3': 60.00,
'S5': 36.87,
'S7': 38.21,
'S9': 38.94,
'S11': 50.48,
'S13a': 22.62,
'S13b': 27.80,
}
[docs]
class DistrViz:
"""
Distribution visualiser for scalar grain properties and MDF data.
Parameters
----------
data : array-like
1-D array of values. NaN/Inf are stripped automatically.
label : str
Property name — used in axis labels and titles.
units : str
Unit string (e.g. 'µm²', '°'). Appended to x-label when non-empty.
"""
def __init__(self, data, label='value', units=''):
"""Initialise the instance."""
arr = np.asarray(data, dtype=float).ravel()
self.data = arr[np.isfinite(arr)]
self.label = label
self.units = units
# ── Alternate constructors ─────────────────────────────────────────────────
[docs]
@classmethod
def from_mdf(cls, mdf):
"""Build from an mdf dict (output of compute_mdf_from_quats)."""
return cls(mdf['miso_deg'], label='Misorientation angle', units='°')
# ── Statistics ─────────────────────────────────────────────────────────────
@property
def stats(self):
"""Dict of descriptive statistics computed from self.data."""
d = self.data
return {
'n': len(d),
'min': float(d.min()),
'max': float(d.max()),
'mean': float(d.mean()),
'median': float(np.median(d)),
'std': float(d.std()),
'skew': float(sp_stats.skew(d)),
'kurt': float(sp_stats.kurtosis(d)),
'p10': float(np.percentile(d, 10)),
'p90': float(np.percentile(d, 90)),
}
[docs]
def print_stats(self):
"""Print a compact statistics summary to stdout."""
s = self.stats
u = f' ({self.units})' if self.units else ''
print(f"{self.label}{u} [n={s['n']}]")
print(f" min={s['min']:.3f} max={s['max']:.3f} "
f"mean={s['mean']:.3f} median={s['median']:.3f}")
print(f" std={s['std']:.3f} skew={s['skew']:.3f} "
f"kurt={s['kurt']:.3f}")
print(f" P10={s['p10']:.3f} P90={s['p90']:.3f}")
# ── Internal helpers ───────────────────────────────────────────────────────
def _xlabel(self, step_size=None):
""" xlabel."""
parts = [self.label]
if self.units or step_size is not None:
inner = self.units
if step_size is not None:
sep = ', ' if inner else ''
inner += f'{sep}step={step_size} µm'
parts.append(f'({inner})')
return ' '.join(parts)
def _stat_title(self):
""" stat title."""
s = self.stats
return (f'{self.label} distribution '
f'(n={s["n"]}, mean={s["mean"]:.2f}, std={s["std"]:.2f})')
def _draw_stat_lines(self, ax):
""" draw stat lines."""
s = self.stats
ax.axvline(s['mean'], color='k', ls='--', lw=1.2,
label=f'mean = {s["mean"]:.2f}')
ax.axvline(s['median'], color='darkorange', ls=':', lw=1.2,
label=f'median = {s["median"]:.2f}')
# ── Unified dispatcher ─────────────────────────────────────────────────────
[docs]
def plot(self, vis='hist', bins=40, show_kde=True, show_stats=True,
color='steelblue', figsize=(7, 4), log_scale=False,
step_size=None, bw_method='scott', fill=True, ax=None):
"""
Unified plot dispatcher — routes to plot_hist, plot_kde, or
plot_hist_kde based on *vis*.
Parameters
----------
vis : str
``'hist'``, ``'kde'``, or ``'hist_kde'``.
bins : int
Histogram bin count (used by ``'hist'`` and ``'hist_kde'``).
show_kde : bool
KDE overlay on histogram (``'hist'`` only).
show_stats : bool
Annotate mean / median lines.
color : str
figsize : tuple
log_scale : bool
Log x-axis (``'hist'`` only).
step_size : float or None
Appended to x-label when provided.
bw_method : str or float
KDE bandwidth selector (``'kde'`` only).
fill : bool
Fill KDE area (``'kde'`` only).
ax : Axes or None
Returns
-------
fig, ax
"""
if vis == 'hist':
return self.plot_hist(bins=bins, show_kde=show_kde,
show_stats=show_stats, color=color,
figsize=figsize, log_scale=log_scale,
step_size=step_size, ax=ax)
elif vis == 'kde':
return self.plot_kde(bw_method=bw_method, fill=fill,
color=color, show_stats=show_stats,
figsize=figsize, step_size=step_size, ax=ax)
elif vis == 'hist_kde':
return self.plot_hist_kde(bins=bins, color=color,
show_stats=show_stats, figsize=figsize,
step_size=step_size, ax=ax)
else:
raise ValueError(
f"vis must be 'hist', 'kde', or 'hist_kde'; got '{vis!r}'"
)
# ── Scalar distribution plots ──────────────────────────────────────────────
[docs]
def plot_hist(self, bins=40, show_kde=True, show_stats=True,
color='steelblue', figsize=(7, 4), log_scale=False,
step_size=None, ax=None):
"""
Histogram with optional KDE overlay and mean/median annotations.
Parameters
----------
bins : int
show_kde : bool
KDE curve scaled to match histogram counts.
show_stats : bool
Draw vertical mean and median lines.
color : str
figsize : tuple
log_scale : bool
Log x-axis.
step_size : float or None
EBSD step size — appended to x-label when provided.
ax : Axes or None
Returns
-------
fig, ax
"""
own_fig = ax is None
if own_fig:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
counts, edges, _ = ax.hist(self.data, bins=bins,
color=color, edgecolor='k',
alpha=0.75, label='histogram')
if show_kde:
kde = sp_stats.gaussian_kde(self.data)
x = np.linspace(self.data.min(), self.data.max(), 400)
bw = edges[1] - edges[0]
ax.plot(x, kde(x) * len(self.data) * bw,
color='crimson', lw=1.8, label='KDE')
if show_stats:
self._draw_stat_lines(ax)
ax.legend(fontsize=8, framealpha=0.7)
ax.set_xlabel(self._xlabel(step_size))
ax.set_ylabel('Count')
ax.set_title(self._stat_title())
if log_scale:
ax.set_xscale('log')
if own_fig:
plt.tight_layout()
return fig, ax
[docs]
def plot_kde(self, bw_method='scott', fill=True, color='steelblue',
show_stats=True, figsize=(7, 4), step_size=None, ax=None):
"""
Pure KDE plot (probability density).
Parameters
----------
bw_method : str or float
Bandwidth selector passed to scipy.stats.gaussian_kde.
fill : bool
Fill area under the KDE curve.
color, figsize, step_size, ax
Standard plot options.
Returns
-------
fig, ax
"""
own_fig = ax is None
if own_fig:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
kde = sp_stats.gaussian_kde(self.data, bw_method=bw_method)
x = np.linspace(self.data.min(), self.data.max(), 400)
y = kde(x)
if fill:
ax.fill_between(x, y, alpha=0.3, color=color)
ax.plot(x, y, color=color, lw=2)
if show_stats:
self._draw_stat_lines(ax)
ax.legend(fontsize=8, framealpha=0.7)
ax.set_xlabel(self._xlabel(step_size))
ax.set_ylabel('Density')
ax.set_title(self._stat_title())
if own_fig:
plt.tight_layout()
return fig, ax
[docs]
def plot_hist_kde(self, bins=40, color='steelblue', show_stats=True,
figsize=(7, 4), step_size=None, ax=None):
"""
Density-normalised histogram with KDE overlay.
Returns
-------
fig, ax
"""
own_fig = ax is None
if own_fig:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
ax.hist(self.data, bins=bins, density=True,
color=color, edgecolor='k', alpha=0.5, label='histogram')
kde = sp_stats.gaussian_kde(self.data)
x = np.linspace(self.data.min(), self.data.max(), 400)
ax.plot(x, kde(x), color='crimson', lw=2, label='KDE')
if show_stats:
self._draw_stat_lines(ax)
ax.legend(fontsize=8, framealpha=0.7)
ax.set_xlabel(self._xlabel(step_size))
ax.set_ylabel('Density')
ax.set_title(self._stat_title())
if own_fig:
plt.tight_layout()
return fig, ax
# ── MDF plot ───────────────────────────────────────────────────────────────
[docs]
def plot_mdf(self, mdf, show_csl=True, show_stats=True,
angle_max=65.0, figsize=(8, 4), ax=None):
"""
Bar-chart MDF from a pre-computed mdf dict with optional CSL markers.
Lighter alternative to ebsdviz.plot_mdf — does not require the peaks
dict. Use ebsdviz.plot_mdf when peak labels and KDE are needed.
Parameters
----------
mdf : dict
Output of compute_mdf_from_quats. Required keys:
'hist_bin_centers', 'hist_density', 'hist_bin_edges',
'n_pairs', 'mean_angle', 'std_angle'.
show_csl : bool
Draw dashed vertical lines at common cubic CSL angles.
show_stats : bool
Annotate mean ± std in the legend.
angle_max : float
X-axis upper limit (degrees).
figsize : tuple
ax : Axes or None
Returns
-------
fig, ax
"""
own_fig = ax is None
if own_fig:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
bw = float(mdf['hist_bin_edges'][1] - mdf['hist_bin_edges'][0])
ax.bar(mdf['hist_bin_centers'], mdf['hist_density'],
width=bw, color='steelblue', edgecolor='k',
linewidth=0.4, alpha=0.85)
if show_csl:
ymax = float(np.max(mdf['hist_density']))
for lbl, angle in _CSL_ANGLES.items():
if angle <= angle_max:
ax.axvline(angle, color='firebrick',
lw=0.9, ls='--', alpha=0.75)
ax.text(angle + 0.3, ymax * 0.93, lbl,
color='firebrick', fontsize=7,
va='top', rotation=90)
if show_stats:
mean_a = mdf['mean_angle']
std_a = mdf['std_angle']
ax.axvline(mean_a, color='k', ls='--', lw=1.2,
label=f'mean = {mean_a:.1f}° (σ = {std_a:.1f}°)')
ax.legend(fontsize=8, framealpha=0.7)
ax.set_xlabel('Misorientation angle (°)')
ax.set_ylabel('Probability density (°⁻¹)')
ax.set_title(f'Grain-boundary MDF '
f'(n={mdf["n_pairs"]} pairs, cubic symmetry)')
ax.set_xlim(0, angle_max)
if own_fig:
plt.tight_layout()
return fig, ax
# ── Multi-property grid ────────────────────────────────────────────────────
[docs]
@classmethod
def multi(cls, data_dict, units_dict=None, step_size=None,
bins=40, show_kde=True, show_stats=True,
ncolumns=2, figsize_per=(5, 3.5), color='steelblue',
log_scale=False):
"""
Plot distributions for multiple grain properties in a subplot grid.
Parameters
----------
data_dict : dict
{label: array-like} of grain properties to plot.
units_dict : dict or None
{label: units_str}. Missing keys default to no units.
step_size : float or None
Passed to each subplot for x-label annotation.
bins : int
show_kde : bool
show_stats : bool
ncolumns : int
figsize_per : tuple
(width, height) per panel in inches.
color : str
log_scale : bool
Returns
-------
fig, axes (axes is a flat ndarray)
"""
labels = list(data_dict.keys())
n = len(labels)
nrows = (n + ncolumns - 1) // ncolumns
figsize = (figsize_per[0] * ncolumns, figsize_per[1] * nrows)
fig, axes = plt.subplots(nrows, ncolumns, figsize=figsize)
axes_flat = np.array(axes).flatten()
units_dict = units_dict or {}
for ax, label in zip(axes_flat, labels):
dv = cls(data_dict[label], label=label,
units=units_dict.get(label, ''))
dv.plot_hist(bins=bins, show_kde=show_kde, show_stats=show_stats,
color=color, log_scale=log_scale,
step_size=step_size, ax=ax)
for ax in axes_flat[n:]:
ax.set_visible(False)
plt.tight_layout()
return fig, axes_flat
# ── Multi-group overlaid distribution plot ─────────────────────────────────────
[docs]
def plot_grouped_distributions(
data,
prop_labels=None,
group_colors=None,
group_labels=None,
bins=40,
bw_method='scott',
peak_prominence=0.01,
figsize_per=(5, 4),
dpi=110,
suptitle='Property distributions by group',
ncols=None,
fontsize=9.0,
show_hist=True,
show_peaks=True,
show_legend=True,
show_line=True,
kde_shaded=False,
x_margin=0.03,
do_tight_layout=True,
):
"""
Overlaid histogram + KDE + peak markers for multiple properties and groups.
Generic plotting function — no knowledge of grain structures or UPXO data
formats. Data must be pre-extracted into plain arrays before calling.
Parameters
----------
data : dict
``{prop_name: {group_name: array-like}}`` — one entry per property,
each containing one array per group. Arrays may be empty; empty/size-1
groups are silently skipped.
prop_labels : dict or None
``{prop_name: display_label}`` for axis / title text. Missing keys
fall back to the prop_name itself.
group_colors : dict or None
``{group_name: colour_string}``. Missing keys cycle through a default
palette.
group_labels : dict or None
``{group_name: display_label}`` for legend entries. Missing keys fall
back to the group_name itself.
bins : int
Number of histogram bins (shared x-range across groups per property).
bw_method : str or float
Bandwidth selector passed to ``scipy.stats.gaussian_kde``.
peak_prominence : float
Fraction of KDE maximum used as minimum prominence for ``find_peaks``.
figsize_per : tuple
``(width, height)`` in inches per subplot panel.
dpi : int
Figure resolution.
suptitle : str
Figure-level title.
ncols : int or None
Subplot grid columns. ``None`` places all panels in a single row.
fontsize : float
Base font size; tick labels use ``fontsize-2``, legend ``fontsize-2``,
peak annotations ``fontsize-3``, suptitle ``fontsize+1``.
show_hist : bool
Draw histogram bars behind the KDE curves. Default ``True``.
show_peaks : bool
Draw vertical dashed lines and value annotations at KDE peaks.
Default ``True``.
show_legend : bool
Draw a per-group legend on each subplot. Default ``True``.
show_line : bool
Draw the KDE density curve. Default ``True``.
kde_shaded : bool
Fill the area under each group's KDE curve -- ignored whenever
`show_hist` is also True (see `plot_property_distribution_on_axes`
for why). Default ``False``.
x_margin : float
Fractional padding added to both sides of the x-axis so that tick
labels are never clipped at the axis boundary. Default ``0.03``.
do_tight_layout : bool
Call ``plt.tight_layout()`` before returning. Set to ``False`` when
the caller needs to adjust the figure (e.g. to add a colorbar) before
finalising the layout. Default ``True``.
Returns
-------
fig, axes : Figure and 2-D axes array (shape ``(nrows, ncols_used)``).
"""
_DEFAULT_PALETTE = [
'#4878CF', '#D65F5F', '#59A14F', '#888888',
'#F28E2B', '#76B7B2', '#E15759', '#B07AA1',
]
prop_labels = prop_labels or {}
group_colors = group_colors or {}
group_labels = group_labels or {}
prop_names = list(data.keys())
n_props = len(prop_names)
# Assign default colours to any group not in group_colors
all_groups = []
for gd in data.values():
for g in gd:
if g not in all_groups:
all_groups.append(g)
for i, g in enumerate(all_groups):
group_colors.setdefault(g, _DEFAULT_PALETTE[i % len(_DEFAULT_PALETTE)])
_ncols = n_props if ncols is None else max(1, min(ncols, n_props))
_nrows = int(np.ceil(n_props / _ncols))
fig, axes = plt.subplots(
_nrows, _ncols,
figsize=(_ncols * figsize_per[0], _nrows * figsize_per[1]),
dpi=dpi, squeeze=False,
)
for spare in range(n_props, _nrows * _ncols):
axes[spare // _ncols, spare % _ncols].set_visible(False)
for idx, pname in enumerate(prop_names):
ax = axes[idx // _ncols, idx % _ncols]
xlabel = prop_labels.get(pname, pname)
plot_property_distribution_on_axes(
ax, data[pname], xlabel,
group_colors=group_colors, group_labels=group_labels,
bins=bins, bw_method=bw_method, peak_prominence=peak_prominence,
title_fontsize=fontsize, axis_fontsize=fontsize,
tick_fontsize=fontsize - 2, legend_fontsize=fontsize - 2,
show_hist=show_hist, show_peaks=show_peaks, show_legend=show_legend,
show_line=show_line, kde_shaded=kde_shaded,
x_margin=x_margin,
)
# y kept under 1.0 and tight_layout given an explicit rect so the
# reserved top margin actually accounts for the suptitle -- y>1.0
# combined with an un-reserved tight_layout() clips the top of the
# text against the canvas/window edge (the axes are per-property
# subplots; this suptitle is figure-wide, one level up).
fig.suptitle(suptitle, fontsize=fontsize + 1, y=0.98)
if do_tight_layout:
plt.tight_layout(rect=[0, 0, 1, 0.94])
return fig, axes
[docs]
def plot_property_distribution_on_axes(
ax,
groups,
xlabel,
group_colors=None,
group_labels=None,
group_linestyles=None,
group_linewidths=None,
bins=40,
bw_method='scott',
peak_prominence=0.01,
title_fontsize=12.0,
axis_fontsize=10.0,
tick_fontsize=9.0,
legend_fontsize=9.0,
show_hist=True,
show_peaks=True,
show_legend=True,
show_line=True,
show_rug=False,
kde_shaded=False,
legend_loc='upper right',
rug_max_points=200,
x_margin=0.03,
title=None,
ylabel=None,
xlim=None,
ylim=None,
):
"""
Overlaid histogram + KDE + peak markers for ONE property, drawn onto
a caller-supplied `ax` -- the single-axis unit `plot_grouped_distributions`
builds a subplot grid of; extracted so a caller embedding a single
persistent, in-place-redrawn Axes (e.g. a live GUI plot) can reuse the
exact same drawing logic without going through `plt.subplots()` (which
registers a new Figure with pyplot's global state on every call) and
without a multi-panel grid it doesn't need.
Parameters
----------
ax : matplotlib Axes
Drawn onto directly (not cleared first -- callers that redraw in
place should call `ax.clear()` themselves before calling this).
groups : dict
``{group_name: array-like}`` -- one array per overlaid group.
Arrays may be empty; empty/size-1 groups are silently skipped.
xlabel : str
Property display label -- used for both the x-axis label and the
axes title (this function draws exactly one title; a caller
wanting a figure-level title too, e.g. `plot_grouped_distributions`,
adds its own `fig.suptitle` separately).
group_colors, group_labels : dict or None
Same shape/fallback convention as `plot_grouped_distributions`.
group_linestyles : dict or None
``{group_name: matplotlib linestyle}`` (e.g. '-', '--', ':', '-.').
Missing keys fall back to '-'.
group_linewidths : dict or None
``{group_name: float}``. Missing keys fall back to 1.8.
title_fontsize, axis_fontsize, tick_fontsize, legend_fontsize : float
Independent font sizes (previously a single `fontsize` with fixed
offsets for tick/legend/peak-annotation text).
show_line : bool
Draw the KDE density curve. Default True (matches this function's
historical always-on behaviour before this parameter existed).
show_rug : bool
Draw a rug plot -- one short tick per raw observation along the
x-axis, coloured to match that group's line -- the standard way
to show individual data points under a density curve. Ticks are
drawn in a blended transform (x in data coordinates, y as a
fixed fraction of the axes height) rather than data-scaled y
coordinates, so they sit at a consistent, small height near the
bottom of the plot regardless of the density curve's own y-scale
(a fixed data-unit offset here would either be invisible or
dominate the plot, since "density" and the property's own value
range are unrelated scales). Default False.
legend_loc : str
Passed straight to `ax.legend(loc=...)` -- any matplotlib
legend location string (e.g. 'best', 'upper right', 'lower
left', 'center'). Default 'upper right' (this function's
historical fixed placement).
rug_max_points : int
A group with more than this many observations has its rug
thinned to (approximately) this many ticks, evenly sampled
across the SORTED values (not just the first N) so the rug
still reads as a fair representation of the full distribution's
spread -- large grain counts would otherwise render as a single
solid smear rather than a legible rug. Default 200.
kde_shaded : bool
Fill the area under each group's KDE curve. Only takes effect
when `show_line` is True AND `show_hist` is False -- a shaded
KDE fill drawn on top of histogram bars visually competes with
them for the same area, so a caller asking for both gets the
histogram (the more literal/less processed view) and the shading
is silently dropped rather than erroring. Default False.
title, ylabel : str or None
Override this axes' title / y-axis label. `None` (default)
keeps the historical auto behaviour (title = `xlabel`, ylabel =
"Density") -- an override is used verbatim, including an empty
string (a caller wanting no title/ylabel at all can pass "").
xlim, ylim : (float, float) or None
Explicit axis limits, applied AFTER this function's own
data-driven autoscaling (so they override it, not compete with
it). `None` (default) keeps the auto behaviour.
Returns
-------
(vmin, vmax) or None
The property's data range if anything was plotted (useful for a
caller that wants to also know the x-limits actually used), or
None if every group was empty/degenerate and nothing was drawn
(ax is hidden via `ax.set_visible(False)` in that case).
"""
from scipy.stats import gaussian_kde
from scipy.signal import find_peaks
group_colors = group_colors or {}
group_labels = group_labels or {}
group_linestyles = group_linestyles or {}
group_linewidths = group_linewidths or {}
peak_fontsize = max(1.0, axis_fontsize - 3)
arrays = {g: np.asarray(v, dtype=float) for g, v in groups.items()}
arrays = {g: a[np.isfinite(a)] for g, a in arrays.items() if len(a) > 1}
if not arrays:
ax.set_visible(False)
return None
combined = np.concatenate(list(arrays.values()))
vmin, vmax = combined.min(), combined.max()
if vmin == vmax:
ax.set_visible(False)
return None
rng = vmax - vmin
pad = x_margin * rng
bin_edges = np.linspace(vmin, vmax, bins + 1)
bin_w = bin_edges[1] - bin_edges[0]
xs = np.linspace(vmin, vmax, 600)
for grp, vals in arrays.items():
colour = group_colors.get(grp, '#333333')
linestyle = group_linestyles.get(grp, '-')
linewidth = group_linewidths.get(grp, 1.8)
if show_hist:
counts, _ = np.histogram(vals, bins=bin_edges, density=True)
ax.bar(bin_edges[:-1], counts, width=bin_w,
color=colour, alpha=0.28, edgecolor='none', align='edge')
ys = None
if show_line:
try:
kde = gaussian_kde(vals, bw_method=bw_method)
ys = kde(xs)
ax.plot(xs, ys, color=colour, linewidth=linewidth, linestyle=linestyle)
if kde_shaded and not show_hist:
ax.fill_between(xs, ys, color=colour, alpha=0.25, linewidth=0)
except np.linalg.LinAlgError:
# Near-zero-variance group (e.g. a role level with very few
# grains sharing nearly identical values) -- gaussian_kde
# needs a non-singular covariance, so fall back to marking
# the mean directly rather than crashing the whole plot.
ax.axvline(vals.mean(), color=colour, linewidth=linewidth, linestyle=linestyle)
if show_rug:
# Blended transform (x: data, y: axes-fraction) -- NOT data-unit
# offsets, since "density" (this axes' y-scale) and the
# property's own value range (rng) are unrelated magnitudes; a
# data-unit offset sized off one or the other either vanishes
# or swamps the whole plot depending on which property/group.
# Thinned to rug_max_points, evenly sampled across the SORTED
# values, for groups with many observations (a rug of a few
# hundred to a few thousand grains would otherwise render as
# one solid smear rather than a legible sparse rug).
rug_vals = np.sort(vals)
if rug_vals.size > rug_max_points:
idx = np.linspace(0, rug_vals.size - 1, rug_max_points).round().astype(int)
rug_vals = rug_vals[idx]
ax.plot(rug_vals, np.full_like(rug_vals, 0.03), '|',
color=colour, markersize=9, markeredgewidth=1.2,
transform=ax.get_xaxis_transform())
if show_peaks and ys is not None:
peak_idx, _ = find_peaks(ys, prominence=peak_prominence * ys.max())
for pi in peak_idx:
ax.axvline(xs[pi], color=colour, linewidth=0.8,
linestyle='--', alpha=0.7)
ax.text(xs[pi], ys[pi] * 1.03, f'{xs[pi]:.3g}',
fontsize=peak_fontsize, color=colour,
ha='center', va='bottom', rotation=90)
if show_legend:
mn, mx = vals.min(), vals.max()
mu, sd = vals.mean(), vals.std()
disp = group_labels.get(grp, grp)
lbl = (f'{disp} (n={len(vals)})\n'
f' µ={mu:.3g} σ={sd:.3g} [{mn:.3g}, {mx:.3g}]')
ax.plot([], [], color=colour, linewidth=2.5, linestyle=linestyle, label=lbl)
ax.set_xlabel(xlabel, fontsize=axis_fontsize)
ax.set_ylabel('Density' if ylabel is None else ylabel, fontsize=axis_fontsize)
ax.set_title(xlabel if title is None else title, fontsize=title_fontsize)
ax.set_xlim(vmin - pad, vmax + pad)
if show_legend:
ax.legend(fontsize=legend_fontsize, loc=legend_loc, framealpha=0.85,
handlelength=1.2)
ax.tick_params(labelsize=tick_fontsize)
# Explicit overrides applied last, AFTER the data-driven autoscale
# above, so they win rather than compete with it.
if xlim is not None:
ax.set_xlim(*xlim)
if ylim is not None:
ax.set_ylim(*ylim)
return vmin, vmax
# ── Pooled distribution with a per-slice KDE spread band ────────────────────
[docs]
def plot_hist_with_slice_band(
pooled_data,
slice_arrays,
label='value',
units='',
bins=40,
show_stats=True,
color='steelblue',
band_color='steelblue',
band_alpha=0.20,
figsize=(7, 4),
ax=None,
):
"""
Histogram + KDE of a pooled distribution (same presentation as
``DistrViz.plot_hist``), with a shaded min/max envelope band overlaid
around the KDE curve -- at each x, the band spans the [min, max]
across each individual slice's own KDE (evaluated on the same grid,
scaled to match the pooled KDE curve's histogram-count scale), making
slice-to-slice spread visible alongside the combined/pooled curve.
Typical use: ``pooled_data`` is the union of a property's values from
several 2D cross-sections of a 3D structure (e.g.
``TwinnedSimple3DBase.compute_2d_slice_properties``'s pooled output),
and ``slice_arrays`` is the same property's per-slice breakdown
(``compute_2d_slice_properties_by_axis``'s per-axis lists) -- the
main curve shows the combined distribution, the band shows how much
an individual slice can vary from it.
Parameters
----------
pooled_data : array-like
The combined (all slices) distribution -- drives the histogram
bars and the main KDE curve, exactly as ``DistrViz.plot_hist``
does with its own ``self.data``.
slice_arrays : list of array-like
One array per individual slice; each contributes one KDE curve
to the shaded envelope. A slice with fewer than 2 finite,
non-degenerate values is skipped (``gaussian_kde`` needs a
non-singular sample).
label, units : str
Property name / unit string for axis labels and title.
bins : int
Histogram bin count.
show_stats : bool
Draw vertical mean/median lines (pooled data).
color : str
Histogram bar color.
band_color, band_alpha : str, float
Fill color/opacity for the shaded envelope.
figsize : tuple
Only used when ``ax`` is None.
ax : Axes or None
Plots into an existing axes if given; otherwise creates its own
standalone figure.
Returns
-------
fig, ax
"""
pooled = np.asarray(pooled_data, dtype=float)
pooled = pooled[np.isfinite(pooled)]
own_fig = ax is None
if own_fig:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.figure
if pooled.size == 0:
ax.text(0.5, 0.5, 'No data', ha='center', va='center', transform=ax.transAxes)
ax.set_xlabel(label)
return fig, ax
counts, edges, _ = ax.hist(pooled, bins=bins, color=color, edgecolor='k',
alpha=0.75, label='histogram')
bw = edges[1] - edges[0]
x = np.linspace(pooled.min(), pooled.max(), 400)
if pooled.size > 1 and pooled.min() != pooled.max():
try:
main_kde = sp_stats.gaussian_kde(pooled)
ax.plot(x, main_kde(x) * len(pooled) * bw, color='crimson', lw=1.8, label='KDE (pooled)')
except np.linalg.LinAlgError:
pass
curves = []
for arr in slice_arrays:
a = np.asarray(arr, dtype=float)
a = a[np.isfinite(a)]
if a.size < 2 or a.min() == a.max():
continue
try:
kde = sp_stats.gaussian_kde(a)
curves.append(kde(x) * len(pooled) * bw)
except np.linalg.LinAlgError:
continue
if curves:
stacked = np.vstack(curves)
y_min = stacked.min(axis=0)
y_max = stacked.max(axis=0)
ax.fill_between(x, y_min, y_max, color=band_color, alpha=band_alpha,
label=f'slice-to-slice spread (n={len(curves)})')
if show_stats:
mean, median = float(np.mean(pooled)), float(np.median(pooled))
ax.axvline(mean, color='k', ls='--', lw=1.2, label=f'mean = {mean:.2f}')
ax.axvline(median, color='darkorange', ls=':', lw=1.2, label=f'median = {median:.2f}')
xlabel = label + (f' ({units})' if units else '')
ax.set_xlabel(xlabel)
ax.set_ylabel('Count')
ax.set_title(f'{label} distribution (n={len(pooled)}, slices={len(slice_arrays)})')
ax.legend(fontsize=8, framealpha=0.7)
if own_fig:
plt.tight_layout()
return fig, ax
[docs]
def plot_repr_rank(
repr_rank_ng: dict,
figsize=None,
dpi: int = 100,
fontsize_annot: float = 8.0,
fontsize_tick: float = 9.0,
fontsize_title: float = 9.0,
fontsize_suptitle: float = 11.0,
) -> None:
"""
Five vertically stacked heatmaps showing the per-property rank of every
MC time slice under each representativeness metric (ratio, Wasserstein,
energy distance, KS statistic, Anderson–Darling statistic).
Colour encodes rank within each column independently:
green = best (rank 1), red = worst (rank N). Cell text shows the raw
numeric score. Rows are ordered best-to-worst by the aggregate score
(inherited from the DataFrame sort order in ``repr_rank_ng``).
Ranking rule per column:
- ratio, property columns : rank by ``|value − 1|`` ascending
(closest to 1.0 = best)
- ratio, aggregate column : rank by value ascending (lowest = best)
- wasserstein / energy : rank by value ascending (lowest = best)
Parameters
----------
repr_rank_ng : dict
``{'ratio': df, 'wasserstein': df, 'energy': df}`` — as stored in
``repgen2d.repr_rank_ng`` after calling ``find_repr_mcgs_props``.
figsize : tuple or None
Override default figure size. Default auto-computes from data shape.
dpi : int
Figure resolution.
fontsize_annot : float
Font size for the numeric value printed in each cell.
fontsize_tick : float
Font size for axis tick labels (slice keys on y-axis, column names
on x-axis).
fontsize_title : float
Font size for each panel title.
fontsize_suptitle : float
Font size for the overall figure title.
"""
metrics = ('ratio', 'wasserstein', 'energy', 'ks', 'ad')
titles = {
'ratio': 'Ratio (mean offset)\n1.0 = perfect | green = closest to 1.0',
'wasserstein': 'Wasserstein (shape distance)\n0 = identical | green = smallest',
'energy': 'Energy (shape distance)\n0 = identical | green = smallest',
'ks': 'KS statistic (max CDF gap)\n0 = identical | green = smallest',
'ad': 'Anderson–Darling (tail-sensitive CDF)\n0 = identical | green = smallest',
}
fmt = {'ratio': '{:.3f}', 'wasserstein': '{:.4f}', 'energy': '{:.4f}',
'ks': '{:.4f}', 'ad': '{:.4f}'}
sample_df = repr_rank_ng['wasserstein']
n_slices, n_cols = sample_df.shape
if figsize is None:
figsize = (max(10, n_cols * 1.8), max(20, n_slices * 0.65 * 5))
fig, axes = plt.subplots(5, 1, figsize=figsize, dpi=dpi)
for ax, metric in zip(axes, metrics):
df = repr_rank_ng[metric]
vals = df.values.astype(float)
cols = list(df.columns)
rows = [str(k) for k in df.index]
nr, nc = vals.shape
rank_mat = np.empty_like(vals)
for j, col in enumerate(cols):
col_vals = vals[:, j]
if metric == 'ratio' and col != 'aggregate':
order = np.argsort(np.abs(col_vals - 1.0))
else:
order = np.argsort(col_vals)
ranks = np.empty(nr, dtype=float)
ranks[order] = np.arange(nr)
rank_mat[:, j] = ranks
norm_rank = rank_mat / max(nr - 1, 1) # 0 = best, 1 = worst
ax.imshow(norm_rank, cmap='RdYlGn_r', vmin=0, vmax=1,
aspect='auto', interpolation='nearest')
for i in range(nr):
for j in range(nc):
ax.text(j, i, fmt[metric].format(vals[i, j]),
ha='center', va='center',
fontsize=fontsize_annot, color='black')
ax.set_xticks(range(nc))
ax.set_xticklabels(cols, rotation=30, ha='right', fontsize=fontsize_tick)
ax.set_yticks(range(nr))
ax.set_yticklabels(rows, fontsize=fontsize_tick)
ax.set_ylabel('MC time slice (top = best aggregate)',
fontsize=fontsize_tick)
ax.set_title(titles[metric], fontsize=fontsize_title, pad=8)
ax.axvline(nc - 1.5, color='white', linewidth=2)
fig.suptitle('MC–EBSD representativeness ranking',
fontsize=fontsize_suptitle, y=0.98)
plt.tight_layout(rect=[0, 0, 1, 0.94])
plt.show()
[docs]
def plot_normalized_prop_distributions(
ebsd_data: dict,
mc_data: dict,
props: list,
scores: dict | None = None,
prop_labels: dict | None = None,
bins: int = 40,
bw_method='scott',
figsize_per: tuple = (5, 4),
dpi: int = 100,
ncols: int | None = None,
fontsize: float = 9.0,
show_hist: bool = True,
show_peaks: bool = True,
legend_loc: str = 'upper right',
legend_ncol: int = 1,
legend_fontsize: float | None = None,
) -> None:
"""
Overlaid normalised property distributions for EBSD (merged) and MC slices.
Each distribution is normalised by its own mean before plotting, matching
the normalisation used in ``find_repr_mcgs_props``. All curves are therefore
centred near 1.0 on the x-axis and are directly shape-comparable.
Wasserstein and energy distances are annotated in each subplot legend when
``scores`` is provided.
Parameters
----------
ebsd_data : dict
``{prop: array}`` of EBSD-merged property values, each already divided
by its own mean.
mc_data : dict
``{slice_key: {prop: array}}`` of MC property values, each already
divided by its own mean.
props : list of str
Ordered list of property names to plot.
scores : dict or None
``{slice_key: {prop: {'wasserstein': v, 'energy': v}}}`` extracted from
``repr_rank_ng``. When supplied, each MC curve's legend entry is
annotated with ``W=... E=...`` for the per-property distance.
prop_labels : dict or None
``{prop: display_label}``. Defaults to ``f'{prop} (mean normalized)'``.
bins, bw_method, figsize_per, dpi, ncols, fontsize, show_hist, show_peaks
Forwarded to :func:`plot_grouped_distributions`.
legend_loc : str
Legend location string passed to ``ax.legend(loc=...)``.
Examples: ``'upper right'``, ``'upper left'``, ``'lower right'``,
``'center left'``, ``'best'``. Default ``'upper right'``.
legend_ncol : int
Number of columns in the legend. Values > 1 split entries side-by-side,
reducing legend height and — when entries are uniform in width — the
overall legend footprint. Default ``1`` (single column).
legend_fontsize : float or None
Font size for legend text. Reducing this is the most direct way to
shrink the legend box since box width is driven by label text length.
Defaults to ``fontsize - 2`` when None.
"""
_MC_PALETTE = [
'#4878CF', '#D65F5F', '#59A14F', '#F28E2B',
'#76B7B2', '#E15759', '#B07AA1', '#FF9DA7',
]
if prop_labels is None:
prop_labels = {p: f'{p} (mean normalized)' for p in props}
group_colors = {'EBSD (merged)': '#222222'}
for i, k in enumerate(mc_data):
group_colors[f'MC t={k}'] = _MC_PALETTE[i % len(_MC_PALETTE)]
data = {}
for p in props:
groups = {'EBSD (merged)': ebsd_data[p]}
for k, mc_props in mc_data.items():
groups[f'MC t={k}'] = mc_props[p]
data[p] = groups
# Always defer layout so we can post-process legends uniformly.
fig, axes = plot_grouped_distributions(
data,
prop_labels=prop_labels,
group_colors=group_colors,
bins=bins, bw_method=bw_method,
figsize_per=figsize_per, dpi=dpi, ncols=ncols, fontsize=fontsize,
show_hist=show_hist, show_peaks=show_peaks,
suptitle='Normalised property distributions — EBSD (merged) vs MC slices',
do_tight_layout=False,
)
# Append score annotations and re-apply legend with user-controlled style.
_MC_PALETTE_LIST = list(_MC_PALETTE)
for idx, p in enumerate(props):
ax = axes.flat[idx]
if scores is not None:
for i, k in enumerate(mc_data):
if k in scores and p in scores[k]:
sc = scores[k][p]
w = sc.get('wasserstein', float('nan'))
e = sc.get('energy', float('nan'))
colour = _MC_PALETTE_LIST[i % len(_MC_PALETTE_LIST)]
ax.plot([], [], color=colour, lw=0,
label=f' → W={w:.4f} E={e:.4f}')
ax.legend(fontsize=legend_fontsize if legend_fontsize is not None else fontsize - 2,
loc=legend_loc, framealpha=0.85,
ncol=legend_ncol)
plt.tight_layout()
plt.show()
[docs]
def plot_qq_comparison(
ebsd_data: dict,
mc_data: dict,
props: list,
prop_labels: dict | None = None,
figsize_per: tuple = (4, 4),
dpi: int = 100,
ncols: int | None = None,
fontsize: float = 9.0,
) -> None:
"""
Quantile–Quantile (Q-Q) comparison of EBSD vs MC grain property distributions.
A Q-Q plot maps the quantiles of one distribution against the quantiles of
another at the same probability levels (0 % to 100 %). Both distributions
are normalised by their own mean before comparison, so the x- and y-axes
share the same dimensionless scale centred near 1.0.
Interpretation
--------------
- Points on the diagonal (y = x) — the two distributions have identical
shape at that quantile. Perfect agreement.
- Points **above** the diagonal — the MC distribution has *larger* values
than EBSD at that quantile (heavier upper tail or higher spread in MC).
- Points **below** the diagonal — the MC distribution has *smaller* values
than EBSD at that quantile.
- Deviations concentrated in the **lower-left** — fine/small grains differ.
- Deviations concentrated in the **upper-right** — large/coarse grains differ.
One subplot is drawn per property; each MC slice is a separate line.
The dashed black diagonal marks perfect distributional agreement.
Parameters
----------
ebsd_data : dict
``{prop: array}`` of EBSD-merged values, each normalised by own mean.
mc_data : dict
``{slice_key: {prop: array}}`` of MC values, each normalised by own mean.
props : list of str
Properties to plot.
prop_labels : dict or None
``{prop: display_label}``. Defaults to ``f'{prop} (mean normalized)'``.
figsize_per : tuple
``(width, height)`` per subplot in inches.
dpi : int
ncols : int or None
Subplot grid columns. ``None`` places all panels in a single row.
fontsize : float
"""
_MC_PALETTE = [
'#4878CF', '#D65F5F', '#59A14F', '#F28E2B',
'#76B7B2', '#E15759', '#B07AA1', '#FF9DA7',
]
if prop_labels is None:
prop_labels = {p: f'{p} (mean normalized)' for p in props}
n_props = len(props)
_ncols = n_props if ncols is None else max(1, min(ncols, n_props))
_nrows = int(np.ceil(n_props / _ncols))
fig, axes = plt.subplots(
_nrows, _ncols,
figsize=(_ncols * figsize_per[0], _nrows * figsize_per[1]),
dpi=dpi, squeeze=False,
)
q = np.linspace(0, 100, 300)
for idx, p in enumerate(props):
ax = axes[idx // _ncols, idx % _ncols]
ebsd_q = np.percentile(ebsd_data[p], q)
all_vals = list(ebsd_q)
for i, (k, mc_props) in enumerate(mc_data.items()):
mc_q = np.percentile(mc_props[p], q)
all_vals.extend(mc_q)
colour = _MC_PALETTE[i % len(_MC_PALETTE)]
ax.plot(ebsd_q, mc_q, color=colour, lw=1.5, label=f'MC t={k}')
vmin, vmax = min(all_vals), max(all_vals)
ax.plot([vmin, vmax], [vmin, vmax], 'k--', lw=1.0, label='perfect match')
ax.set_xlabel(f'EBSD {prop_labels[p]}', fontsize=fontsize)
ax.set_ylabel(f'MC {prop_labels[p]}', fontsize=fontsize)
ax.set_title(prop_labels[p], fontsize=fontsize)
ax.tick_params(labelsize=fontsize - 1)
ax.legend(fontsize=fontsize - 2, framealpha=0.8)
for spare in range(n_props, _nrows * _ncols):
axes[spare // _ncols, spare % _ncols].set_visible(False)
fig.suptitle('Q-Q plots — EBSD (merged) vs MC slices (mean-normalised)',
fontsize=fontsize + 1, y=0.98)
plt.tight_layout(rect=[0, 0, 1, 0.94])
plt.show()
[docs]
def plot_ebsd_tvf(
tvf_result: dict,
figsize: tuple = (7, 4),
dpi: int = 100,
fontsize: float = 9.0,
title: str = 'EBSD grain-role area fractions',
) -> None:
"""
Horizontal bar chart of EBSD twin area fraction broken down by grain role.
Bars are drawn for each of the four grain-role categories:
- **Pure parents** — matrix grains; never a twin of any grain.
- **Primary twins** — first-generation twins whose parent is a pure parent.
- **Secondary twins** — twins whose parent is itself an intermediate
(twin-of-a-twin, 2nd generation).
- **Intermediate twins** — grains that are simultaneously a twin of one
grain and a parent of another (twin chains).
The overall twin area fraction (primary + secondary + intermediate) is
annotated on the figure.
Parameters
----------
tvf_result : dict
Output of ``repgen2d.compute_ebsd_tvf``. Must contain keys
``'pure_parent_frac'``, ``'primary_twin_frac'``,
``'secondary_twin_frac'``, ``'intermediate_frac'``,
``'overall_twin_frac'``.
figsize : tuple
Figure size ``(width, height)`` in inches.
dpi : int
Figure resolution.
fontsize : float
Base font size for labels and tick marks.
title : str
Figure title.
"""
categories = [
('Pure parents', tvf_result['pure_parent_frac'], '#555555'),
('Primary twins', tvf_result['primary_twin_frac'], '#4878CF'),
('Secondary twins', tvf_result['secondary_twin_frac'], '#F28E2B'),
('Intermediate twins', tvf_result['intermediate_frac'], '#59A14F'),
]
labels = [c[0] for c in categories]
values = [c[1] for c in categories]
colors = [c[2] for c in categories]
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
bars = ax.barh(labels, values, color=colors, edgecolor='white', height=0.5)
for bar, val in zip(bars, values):
ax.text(val + 0.002, bar.get_y() + bar.get_height() / 2,
f'{val:.4f}', va='center', ha='left', fontsize=fontsize - 1)
ax.set_xlabel('Area fraction', fontsize=fontsize)
ax.tick_params(labelsize=fontsize)
ax.set_title(title, fontsize=fontsize + 1)
ax.set_xlim(0, max(values) * 1.25 if max(values) > 0 else 1)
ax.invert_yaxis()
overall = tvf_result['overall_twin_frac']
ax.text(0.98, 0.04, f'Overall TVF = {overall:.4f}',
transform=ax.transAxes, ha='right', va='bottom',
fontsize=fontsize, color='#222222',
bbox=dict(boxstyle='round,pad=0.3', facecolor='#f5f5f5',
edgecolor='#cccccc'))
plt.tight_layout()
plt.show()