Source code for upxo.gsContainters.mcgs2Cont

"""
mcgs2Cont.py — Container for 2-D Monte-Carlo grain-structure temporal datasets.

Default import::

    import upxo.gsContainters.mcgs2Cont as gs_cntnr2_mc

Typical usage::

    cntr = gs_cntnr2_mc.MC_GS_Container2d.by_upxoMCSIM_gsset_GEN(
        indb='path/to/dashboard.xls',
        mctimeStart=2, mctimeStep=1, mctimeEnd=-1,
    )
    fig, axes = cntr.plot_temporal_distributions(props=['area', 'aspect_ratio'])
    plt.show()
"""

from __future__ import annotations

import numpy as np
from copy import deepcopy


[docs] class MC_GS_Container2d: """ Container for a set of characterised 2-D MC grain structures spanning a range of Monte-Carlo time slices. Attributes ---------- indb : str Path to the UPXO input-dashboard Excel file. gsset : dict ``{tslice: mcgs2_grain_structure}`` — characterised grain structures keyed by MC time-step index. mctimeStart : int First MC time-slice index included. mctimeStep : int Stride between included time slices. mctimeEnd : int Last MC time-slice index (exclusive). ``-1`` collects to the penultimate slice. char_kwargs : dict Keyword arguments passed to :meth:`char_morph_2d` for every slice. Notes ----- Do **not** call ``__init__`` directly. Use the class-method constructor :meth:`by_upxoMCSIM_gsset_GEN`. """ __slots__ = ( 'indb', 'gsset', 'mctimeStart', 'mctimeStep', 'mctimeEnd', 'char_kwargs', 'stats_table', ) @staticmethod def _prop_vals(gs, p: str) -> 'np.ndarray': """Return a 1-D finite array of values for property *p* from *gs.prop*. Handles both DataFrame (rows = grains, columns = properties) and dict-of-dicts (``{grain_id: {prop_name: value}}``) structures. Returns an empty array when the property is absent or gs has no prop. """ prop = getattr(gs, 'prop', None) if prop is None: return np.array([], dtype=float) try: import pandas as _pd if isinstance(prop, _pd.DataFrame): if p not in prop.columns: return np.array([], dtype=float) vals = prop[p].dropna().to_numpy(dtype=float) else: vals = np.array( [prop[gid][p] for gid in prop if isinstance(prop[gid], dict) and p in prop[gid]], dtype=float, ) except Exception: return np.array([], dtype=float) return vals[np.isfinite(vals)] # ── Display ──────────────────────────────────────────────────────────────── def __str__(self) -> str: """Return the string form of this instance.""" return f'UPXO. MCS2D.Container. ID({id(self)})' def __repr__(self) -> str: """Return a string representation of this instance.""" n = len(self.gsset) if self.gsset else 0 return ( f'MC_GS_Container2d(' f'tslices={n}, ' f'mctimeStart={self.mctimeStart}, ' f'mctimeStep={self.mctimeStep}, ' f'mctimeEnd={self.mctimeEnd})' ) # ── Constructor ────────────────────────────────────────────────────────────
[docs] @classmethod def by_upxoMCSIM_gsset_GEN( cls, indb: str, upxoMCSIM_gsset=None, mctimeStart: int = 2, mctimeStep: int = 1, mctimeEnd: int = -1, **kwargs, ) -> 'MC_GS_Container2d': """ Build a container by running (or reusing) a 2-D MC grain-growth simulation and characterising every selected temporal slice. Parameters ---------- indb : str Path to the UPXO input-dashboard Excel file used to drive the MC simulation. upxoMCSIM_gsset : :class:`~upxo.ggrowth.mcgs.mcgs` or None, optional A pre-existing ``mcgs`` object whose simulation has already been run and whose grains have been detected. When ``None`` (default) a fresh simulation is run using *indb*. mctimeStart : int, optional Index into ``pxt.m`` from which to start collecting slices. Default ``2`` (skips the initial, unpopulated slices). mctimeStep : int, optional Stride between collected time slices. Default ``1``. mctimeEnd : int, optional Index into ``pxt.m`` at which to stop (exclusive). ``-1`` (default) collects up to but not including the last slice. **kwargs Overrides for :meth:`char_morph_2d` keyword arguments. Any key not supplied falls back to the defaults listed below. Default ``char_morph_2d`` settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python use_version=2, bbox=True, bbox_ex=False, npixels=True, identify_pixel_locations=True, npixels_gb=False, area=True, aspect_ratio=True, solidity=True, major_axis_length=True, minor_axis_length=True, circularity=False, eccentricity=False, euler_number=False, moments_hu=False, append=False, saa=True, throw=False, char_grain_positions=False, find_neigh=False, char_gb=False, make_skim_prop=True, get_grain_coords=True, Returns ------- MC_GS_Container2d Populated container with :attr:`gsset` keyed by MC time-step index. Notes ----- The underlying ``mcgs`` simulation object is deleted after characterisation to free memory. Examples -------- >>> import upxo.gsContainters.mcgs2Cont as gs_cntnr2_mc >>> cntr = gs_cntnr2_mc.MC_GS_Container2d.by_upxoMCSIM_gsset_GEN( ... indb='path/to/dashboard.xls', ... mctimeStart=2, mctimeStep=2, mctimeEnd=-1, ... circularity=True, ... ) """ from upxo.ggrowth.mcgs import mcgs as _mcgs _CHAR_DEFAULTS: dict = dict( use_version=2, bbox=True, bbox_ex=False, npixels=True, identify_pixel_locations=True, npixels_gb=False, area=True, aspect_ratio=True, solidity=True, major_axis_length=True, minor_axis_length=True, circularity=False, eccentricity=False, euler_number=False, moments_hu=False, append=False, saa=True, throw=False, char_grain_positions=False, find_neigh=False, char_gb=False, make_skim_prop=True, get_grain_coords=True, ) char_kw = {**_CHAR_DEFAULTS, **kwargs} _SCALAR_PROPS = ( 'area', 'aspect_ratio', 'solidity', 'major_axis_length', 'minor_axis_length', 'circularity', 'eccentricity', ) active_props = [p for p in _SCALAR_PROPS if char_kw.get(p, False)] # ── Run or reuse simulation ────────────────────────────────────────── if upxoMCSIM_gsset is None: pxt = _mcgs(input_dashboard=indb) pxt.simulate(verbose=False) pxt.detect_grains() else: pxt = upxoMCSIM_gsset print(f'Temporal slices available: {pxt.m}') # ── Characterise each selected temporal slice ──────────────────────── gsset: dict = {} for tslice in pxt.m[mctimeStart:mctimeEnd:mctimeStep]: gs = deepcopy(pxt.gs[tslice]) gs.char_morph_2d(**char_kw) gsset[tslice] = gs # (min, mean, max) summary per active property triplets = [] for p in active_props: vals = cls._prop_vals(gs, p) if vals.size > 0: triplets.append( f'{p}=({vals.min():.3g}, ' f'{vals.mean():.3g}, ' f'{vals.max():.3g})' ) summary = ' '.join(triplets) print(f' tslice={tslice:4d} grains={gs.n} {summary}') del pxt # free simulation memory obj = cls.__new__(cls) obj.indb = indb obj.gsset = gsset obj.mctimeStart = mctimeStart obj.mctimeStep = mctimeStep obj.mctimeEnd = mctimeEnd obj.char_kwargs = char_kw obj.stats_table = None return obj
# ── Temporal distribution visualisation ───────────────────────────────────
[docs] def plot_temporal_distributions( self, props: list[str] | None = None, ncols: int = 2, figsize_per: tuple[float, float] = (5, 4), dpi: int = 110, bins: int = 40, bw_method: str | float = 'scott', peak_prominence: float = 0.01, fontsize: float = 10.0, suptitle: str = 'Temporal evolution of grain-property distributions', cmap: str = 'nipy_spectral', show_stats_table: bool = True, ): """ Plot KDE distributions — one curve per temporal slice — overlaid for each selected morphological property. No histograms; no on-graph peak markers. A single vertical colorbar maps line colour to MC time-slice index. After the figure a summary statistics table is printed and stored in :attr:`stats_table`. Parameters ---------- props : list of str, optional Property names to plot. Defaults to all properties that were enabled at characterisation time (via :attr:`char_kwargs`). Valid values: ``'area'``, ``'aspect_ratio'``, ``'solidity'``, ``'major_axis_length'``, ``'minor_axis_length'``, ``'circularity'``, ``'eccentricity'``. ncols : int, optional Number of subplot columns. Default ``2``. figsize_per : tuple of float, optional ``(width, height)`` in inches per panel. Default ``(5, 4)``. dpi : int, optional Figure resolution. Default ``110``. bins : int, optional Bin count used only for computing the shared x-range. Default ``40``. bw_method : str or float, optional Bandwidth selector for ``scipy.stats.gaussian_kde``. Default ``'scott'``. peak_prominence : float, optional Fraction of KDE maximum used as minimum peak prominence when computing the dominant-peak value for the statistics table. Default ``0.01``. fontsize : float, optional Base font size. Default ``10.0``. suptitle : str, optional Figure-level title. cmap : str, optional Matplotlib colormap name. Default ``'nipy_spectral'``. show_stats_table : bool, optional If ``True`` (default), display the combined statistics table after the figure using ``IPython.display`` (or ``print`` as fallback). Set to ``False`` to suppress the table; the data is still computed and stored in :attr:`stats_table`. Returns ------- fig : :class:`matplotlib.figure.Figure` axes : ndarray of :class:`matplotlib.axes.Axes` Side-effects ------------ Sets :attr:`stats_table` — ``{prop_name: pandas.DataFrame}`` where each DataFrame has columns ``['tslice', 'n', 'mean', 'std', 'CI_lower_95', 'CI_upper_95', 'peak_max']``. The full combined table is also printed to stdout. Examples -------- >>> fig, axes = cntr.plot_temporal_distributions( ... props=['area', 'aspect_ratio'], ... ncols=2, ... cmap='nipy_spectral', ... ) >>> import matplotlib.pyplot as plt >>> plt.show() """ import matplotlib as mpl import matplotlib.pyplot as plt import matplotlib.cm as cm import pandas as pd from scipy.stats import gaussian_kde from scipy.signal import find_peaks from upxo.viz.vizDistr import plot_grouped_distributions _PROP_LABELS: dict = { 'area': 'Area (px²)', 'aspect_ratio': 'Aspect ratio', 'solidity': 'Solidity', 'major_axis_length': 'Major axis length (px)', 'minor_axis_length': 'Minor axis length (px)', 'circularity': 'Circularity', 'eccentricity': 'Eccentricity', } if props is None: props = [p for p in _PROP_LABELS if self.char_kwargs.get(p, False)] tslices = sorted(self.gsset.keys()) norm = mpl.colors.Normalize(vmin=tslices[0], vmax=tslices[-1]) cmap_obj = cm.get_cmap(cmap) group_colors = {str(t): cmap_obj(norm(t)) for t in tslices} group_labels = {str(t): f't = {t}' for t in tslices} # ── Build data dict ────────────────────────────────────────────────── data: dict = {} for p in props: prop_groups: dict = {} for t in tslices: vals = self._prop_vals(self.gsset[t], p) if vals.size > 1: prop_groups[str(t)] = vals if prop_groups: data[p] = prop_groups if not data: print( 'No data to plot. Verify that props are valid and that ' 'the container was built with those properties enabled.' ) return None, None # ── KDE-only plot (no hist, no peak markers, no per-group legend) ──── fig, axes = plot_grouped_distributions( data = data, prop_labels = _PROP_LABELS, group_colors = group_colors, group_labels = group_labels, bins = bins, bw_method = bw_method, peak_prominence = peak_prominence, figsize_per = figsize_per, dpi = dpi, suptitle = suptitle, ncols = ncols, fontsize = fontsize, show_hist = False, show_peaks = False, show_legend = False, x_margin = 0.04, do_tight_layout = False, ) # ── Single vertical colorbar on the right ──────────────────────────── fig.subplots_adjust(right=0.84) cbar_ax = fig.add_axes([0.87, 0.15, 0.025, 0.70]) sm = mpl.cm.ScalarMappable(cmap=cmap_obj, norm=norm) sm.set_array([]) cb = fig.colorbar(sm, cax=cbar_ax) cb.set_label('MC time slice', fontsize=fontsize) cb.set_ticks(tslices) cb.set_ticklabels([str(t) for t in tslices]) cb.ax.tick_params(labelsize=fontsize - 2) plt.tight_layout(rect=[0, 0, 0.85, 1]) # ── Statistics table ───────────────────────────────────────────────── rows = [] for p in props: label_p = _PROP_LABELS.get(p, p) for t in tslices: vals = self._prop_vals(self.gsset[t], p) if vals.size < 2: continue n = len(vals) mu = float(vals.mean()) sd = float(vals.std()) ci_lo = mu - 1.96 * sd / np.sqrt(n) ci_hi = mu + 1.96 * sd / np.sqrt(n) # Dominant KDE peak (highest peak; fallback to KDE mode) kde = gaussian_kde(vals, bw_method=bw_method) xs = np.linspace(vals.min(), vals.max(), 600) ys = kde(xs) pidx, _ = find_peaks(ys, prominence=peak_prominence * ys.max()) peak_max = float( xs[pidx[np.argmax(ys[pidx])]] if len(pidx) > 0 else xs[np.argmax(ys)] ) rows.append({ 'Property': label_p, 'tslice': t, 'n': n, 'mean': round(mu, 4), 'std': round(sd, 4), 'CI_lower_95': round(ci_lo, 4), 'CI_upper_95': round(ci_hi, 4), 'peak_max': round(peak_max, 4), }) df_all = pd.DataFrame(rows, columns=[ 'Property', 'tslice', 'n', 'mean', 'std', 'CI_lower_95', 'CI_upper_95', 'peak_max', ]) # Store per-property and display self.stats_table = { p: df_all[df_all['Property'] == _PROP_LABELS.get(p, p)] .reset_index(drop=True) for p in props } if show_stats_table: try: from IPython.display import display as _display _display(df_all) except Exception: print(df_all.to_string(index=False)) return fig, axes