upxo.pxtal.twinned_simple_3d.base_3d module

base_3d.py

Foundation class for the twinned simple 3D grain structure pipeline.

class upxo.pxtal.twinned_simple_3d.base_3d.TwinnedSimple3DBase(lgi: numpy.ndarray, voxel_size: float, units: str = 'microns', rng_seed: int | None = None)[source]

Bases: object

Foundation 3D grain structure for the twinned simple FCC pipeline.

Wraps a 3D labelled grain image (lgi) from an SGC simulation, attaches physical dimensions, characterises grain morphology, and allocates twin host grains driven by the EBSD-measured twin hosting fraction. Entry point to the full pipeline.

lgi
voxel_size
units
n_grains
grain_ids
mprop: Dict
host_grain_ids: set | None
non_host_grain_ids: set | None
eligible_grain_ids: set | None
target_hosting_fraction: float | None
actual_hosting_fraction: float | None
host_fraction_2d_to_3d_scale_factor: float
host_ranking_volume_weight: float
n_pool_a: int | None
n_pool_b: int | None
allocation_summary: Dict | None
classmethod from_mcgs(pxt, tslice_key: int, voxel_size: float | None = None, units: str = 'microns', rng_seed: int | None = None)[source]

Construct from an mcgs simulation object at a chosen time slice.

Parameters:
  • pxt (mcgs) – UPXO Synthetic Grain Structure (SGC) generator object (after pxt.simulate()).

  • tslice_key (int) – Key into pxt.gs (i.e. one of the values in pxt.m).

  • voxel_size (float or None) – Physical voxel edge length. Reads pxt.vox_size if None.

  • units (str) – Physical units, default ‘microns’.

  • rng_seed (int or None)

char_morphology(volnv: bool = True, eqdia: bool = True, sanv: bool = False, force_compute: bool = True)[source]

Compute per-grain morphological properties and populate self.mprop.

Properties are stored as {property_key: {grain_id: value}}.

Parameters:
  • volnv (bool) – Compute voxel count (volume in voxels).

  • eqdia (bool) – Compute equivalent spherical diameter (in self.units).

  • sanv (bool) – Compute surface area in voxels (expensive; deferred by default).

  • force_compute (bool) – Recompute even if already populated.

compute_aspect_ratio_bbox(force_compute: bool = True)[source]

Per-grain 3D aspect ratio from each grain’s axis-aligned bounding box: max(extent) / min(extent) across the three voxel-count axes. Populates self.mprop['aspect_ratio'] ({grain_id: float}).

Bounding-box extents scale identically on every axis for an isotropic (cubic-voxel) structure, so the ratio itself is dimensionless and doesn’t need voxel_size.

This class has no other 3D shape-descriptor beyond volnv/eqdia/ sanv – the only other working 3D aspect-ratio logic anywhere in the codebase lives on the unrelated mcgs3_grain_structure class (set_mprop_arbbox); this reimplements the same bounding-box idea directly against self.lgi, using scipy.ndimage.find_objects for a single vectorised pass over every grain ID at once rather than one np.where per grain.

Parameters:

force_compute (bool) – Recompute even if already populated.

compute_2d_slice_properties_by_axis(selected_props, comparison_axes: list | None = None, n_slices_per_axis: int | dict | None = None, connectivity_2d: int = 4) → dict[source]

Same evenly-spaced-cross-section sampling as compute_2d_slice_properties(), but returns per-axis, per-individual-slice breakdowns instead of one pooled array per property – needed by callers that need the individual slice populations, not just their union (e.g. a min/max KDE envelope band showing slice-to-slice spread, alongside the combined/ pooled distribution).

compute_2d_slice_properties() is a thin wrapper around this method that concatenates everything back into flat pooled arrays, so both share exactly the same underlying sampling/ measurement code – see that method’s docstring for the shared parameter semantics.

Returns:

dict {axis – One ndarray per sampled slice position along that axis (in slice-position order), each holding that slice’s per-region values for that property (may be an empty array if no regions were found in that slice). Axes not present in comparison_axes are absent from the returned dict.

Return type:

{prop_name: [ndarray, ndarray, …]}}

compute_2d_slice_properties(selected_props, comparison_axes: list | None = None, n_slices_per_axis: int | dict | None = None, connectivity_2d: int = 4) → dict[source]

Pool 2D grain-morphology properties from evenly-spaced cross-sections of self.lgi, independent of any twin-host allocation (contrast with assess_hosting_representativeness_2d(), which reads self.host_grain_ids/self.eligible_grain_ids and requires allocate_twin_hosts/allocate_twin_hosts_spatial to have already run – this method needs neither).

Reuses the same evenly-spaced-cross-section / cc3d relabelling skeleton as assess_hosting_representativeness_2d(), but pools skimage.measure.regionprops-derived scalar properties per 2D region directly instead of tallying host/non-host ratios. A thin wrapper around compute_2d_slice_properties_by_axis() that flattens its per-axis/per-slice breakdown back into one pooled array per property.

Parameters:
  • selected_props (iterable of str) – Subset of {'area', 'eqdia', 'perimeter', 'aspect_ratio', 'solidity'}.

  • comparison_axes (list of str or None) – Subset of ['x', 'y', 'z'] (array axes 2/1/0 respectively – same convention as assess_hosting_representativeness_2d). Defaults to all three.

  • n_slices_per_axis (int or dict or None) – Evenly-spaced 2D cross-sections sampled per axis – same int-or-{'x': int, 'y': int, 'z': int} convention as assess_hosting_representativeness_2d’s n_comparison_slices. Defaults to 5 per axis.

  • connectivity_2d (int) – cc3d connectivity for the 2D re-labelling (4 or 8).

Returns:

dict {prop_name – Every sampled region’s value for that property, pooled across every sampled slice on every requested axis.

Return type:

1-D ndarray}

allocate_twin_hosts(target_hosting_fraction: float, min_host_voxels: int = 4, host_fraction_2d_to_3d_scale_factor: float = 1.0, host_ranking_volume_weight: float = 0.5)[source]

Designate twin host grains and populate self.host_grain_ids.

Parameters:
  • target_hosting_fraction (float) – EBSD 2D hosting fraction from rg.merge_info['twin_hosting_fraction'].

  • min_host_voxels (int) – Grains smaller than this are ineligible to host (but still receive the ‘non_host’ role for visualisation).

  • host_fraction_2d_to_3d_scale_factor (float) – Stereological correction: the EBSD hosting fraction is a 2D apparent value; set > 1.0 to designate more hosts than the 2D fraction suggests. Effective target is capped at 1.0.

  • host_ranking_volume_weight (float (0.0 -- 1.0)) – Weight given to grain volume in the composite host-selection rank. 1.0 = pure volume sort (largest first). 0.5 = equal weight between volume and number of face-adjacent neighbours (higher coordination = more grain-boundary area = more twin nucleation sites).

allocate_twin_hosts_spatial(target_hosting_fraction: float, min_host_voxels: int = 4, host_fraction_2d_to_3d_scale_factor: float = 1.0, host_ranking_volume_weight: float = 0.5, mis_fraction: float = 0.8, pool_b_size_measure: str = 'vol_vox', pool_b_band_method: str = 'std', pool_b_band_shape: str = 'between', pool_b_n_std: float = 1.0, pool_b_percentile_lo: float = 25.0, pool_b_percentile_hi: float = 75.0, neighbour_frac: float = 100.0, mis_runs: int = 20, seed: int = 0)[source]

Spatial-dispersal-constrained twin host selection.

Selects n_target host grains in two pools:

Pool A — 80% (mis_fraction) from a maximal independent set (MIS): Grains in a MIS are non-adjacent, guaranteeing that no two selected hosts share a face. This prevents twins from different hosts from clustering and touching each other, which would create spurious twin-twin (Sigma9) boundaries and dilute the Sigma3 MDF peak. maximal_independent_set (networkx) is called mis_runs times with different seeds; the largest result is used.

Pool B — 20% (1 - mis_fraction) from a size band: The remaining fraction is drawn from non-MIS grains whose size falls in a selected band of the eligible-grain size distribution AND that have at least one neighbour also in that band. This allows physically realistic, limited host-host adjacency between similarly-positioned-in-size grains, confined to whichever part of the size distribution pool_b_band_shape selects (by default, the middle – avoiding abnormally large or small grain pairs – but 'below'/'above' deliberately target the small/large tails instead, e.g. to stop large grains being systematically under-hosted).

Parameters:
  • target_hosting_fraction (float) – EBSD 2D hosting fraction.

  • min_host_voxels (int) – Minimum voxel count for eligibility.

  • host_fraction_2d_to_3d_scale_factor (float) – Multiplier on the 2D fraction for 3D target.

  • host_ranking_volume_weight (float) – 0 = rank by adjacency count, 1 = rank by volume, 0.5 = equal.

  • mis_fraction (float) – Fraction of n_target drawn from the MIS (default 0.80).

  • pool_b_size_measure (str) –

    Size metric used to define the Pool B band. Options:

    'vol_vox' – grain volume in voxels (integer count). 'vol_um3' – grain volume in μm³ (vol_vox × voxel_size³). 'eqdia_um' – sphere-equivalent diameter in μm

    = (6 × vol_um3 / π)^(1/3).

  • pool_b_band_method (str) –

    How the band’s cutoff(s) are computed from the chosen size measure’s distribution over eligible grains:

    'std' – cutoffs are mean ± pool_b_n_std × std. 'percentile' – cutoffs are pool_b_percentile_lo/

    pool_b_percentile_hi percentiles of the distribution.

    Default 'std' (unchanged prior behaviour).

  • pool_b_band_shape (str) –

    Which side(s) of the distribution the band covers:

    'below' – (-inf, hi] (near-smaller grains only). 'between' – [lo, hi] (near-mean; default, unchanged

    prior behaviour).

    'above' – [lo, +inf) (near-larger grains only,

    uncapped).

    lo/hi are whichever of the method’s two cutoffs apply to the selected shape – e.g. 'below' only ever uses the upper cutoff, 'above' only the lower one.

  • pool_b_n_std (float) – Used when pool_b_band_method == 'std'. Half-band width (for 'between') or single-sided offset (for 'below'/ 'above') in units of the standard deviation of the chosen size measure. Default 1.0.

  • pool_b_percentile_lo (float) – Used when pool_b_band_method == 'percentile' (0-100). pool_b_percentile_lo is the band’s lower cutoff (used by 'between' and 'above'); pool_b_percentile_hi is the upper cutoff (used by 'between' and 'below'). Defaults 25.0 / 75.0.

  • pool_b_percentile_hi (float) – Used when pool_b_band_method == 'percentile' (0-100). pool_b_percentile_lo is the band’s lower cutoff (used by 'between' and 'above'); pool_b_percentile_hi is the upper cutoff (used by 'between' and 'below'). Defaults 25.0 / 75.0.

  • neighbour_frac (float) – Percentage (0-100) of Pool B drawn from in-band grains that DO have an in-band neighbour, vs. in-band grains that do NOT. 100 (default) reproduces the original, neighbour-only behaviour exactly. 0 draws entirely from non-neighbouring in-band grains. Any other value mixes the two proportionally. Whichever sub-pool is short of its share falls back to drawing the remainder from the other sub-pool (so the total Pool B count is unaffected by this split, only its do/do-not composition is).

  • mis_runs (int) – Number of random MIS calls; the largest result is kept.

  • seed (int) – Base seed for the MIS random runs.

assess_hosting_representativeness_2d(n_comparison_slices: int | dict = 5, comparison_axes: list | None = None, connectivity_2d: int = 4) → dict[source]

Cross-sectional (2D) reassessment of the host/non-host allocation already computed by allocate_twin_hosts_spatial, for direct comparability with a 2D EBSD-measured hosting fraction – EBSD data is inherently 2D, but host selection above operates on the whole 3D structure, so neither actual_hosting_fraction (a 3D volume fraction) nor a raw 3D grain-count fraction is directly comparable to it.

Must be called AFTER allocate_twin_hosts_spatial (reads self.host_grain_ids / self.eligible_grain_ids).

For each of n_comparison_slices evenly-spaced 2D cross-sections of self.lgi along each axis in comparison_axes:

  1. Re-run cc3d.connected_components on the 2D slice (connectivity_2d) – a single 3D-connected grain can appear as multiple spatially disconnected pieces within a given cross-section (Monte-Carlo grains can be highly convoluted), so tallying by the ORIGINAL 3D grain ID directly would misrepresent what a real 2D cross-section actually shows. This reassociation runs unconditionally on every slice – the disconnection is invisible from raw ID counts alone, and its extent depends on connectivity_2d (a mismatch between whatever connectivity produced self.lgi and this 2D re-slicing connectivity can throw the count off).

  2. Reassociate each freshly 2D-labelled region back to its underlying original 3D grain ID (a single lookup is safe – a 2D-connected region can never straddle two different original IDs, since cc3d only ever splits same-ID regions further, never merges differing ones) and thereby to its host / eligible-non-host / ineligible classification.

  3. Tally, per slice: the COUNT-based ratio (number of discrete 2D host regions / number of discrete 2D eligible [host + non-host] regions) and the AREA-based ratio (host pixel count / eligible pixel count).

Both ratios are then averaged (mean and std) across every sampled slice – deliberately NOT filtered down to whichever slices happen to already be close to target, which would make the reported “achieved” value circular. The std is itself a useful representativeness diagnostic: a large spread means the host distribution is spatially inhomogeneous, so any single real EBSD cross-section would be a noisy comparison point regardless of how well this mean matches.

Parameters:
  • n_comparison_slices (int or dict) – Evenly-spaced 2D cross-sections sampled per axis. Either one int applied uniformly to every axis in comparison_axes (unchanged from before), or a {'x': int, 'y': int, 'z': int} dict for a different count per axis – axes missing from the dict are not sampled, same as omitting them from comparison_axes.

  • comparison_axes (list of str or None) – Subset of ['x', 'y', 'z'] (array axes 0/1/2 respectively – same convention as rank_temporal_slices_by_n). Defaults to all three.

  • connectivity_2d (int) – cc3d connectivity for the 2D re-labelling (4 or 8).

Returns:

  • dict with keys count_ratio_mean, count_ratio_std,

  • area_ratio_mean, area_ratio_std (None if no slice

  • yielded a usable ratio), n_slices_used, and per_slice

  • (list of ``{‘axis’, ‘location’, ‘count_ratio’, ‘area_ratio’,

  • ’n_host_2d’, ‘n_eligible_2d’}`` dicts, one per sampled

  • cross-section, for diagnostic inspection).

domain_shape()[source]

Return (nx, ny, nz) voxel dimensions of the domain.

self.lgi is stored in the raw MC array’s native (nz, ny, nx) axis order (see plot_temporal_slice_3d()’s P/R/C convention comment) – reversed here so the RETURNED tuple is genuinely (nx, ny, nz) as documented.

physical_size()[source]

Return physical domain size (Lx, Ly, Lz) in self.units.

See domain_shape() – self.lgi.shape is reversed first so this is genuinely (Lx, Ly, Lz), not (Lz, Ly, Lx).

classmethod rank_temporal_slices_by_n(pxt, start: int = 0, step: int = 5, ebsd_n_parents: int | None = None, n_comparison_slices: int = 5, comparison_axes: list | None = None, selected_props: list | None = None, voxel_size: float | None = None, verbose: bool = True) → list[source]

Rank MC temporal slices by their 2D-equivalent grain count.

The 3D total grain count is not directly comparable to a 2D EBSD cross-section count. Instead, this method extracts n_comparison_slices evenly-spaced 2D slices along each axis in comparison_axes, counts grains per slice, and reports the mean across all sampled slices as n_grains_2d_avg. This is the correct quantity to compare against the EBSD parent grain count.

Reads the persisted LFI (pxt.gs[t].lgi, from calculate_lfi()) directly rather than re-deriving a fresh connected-component labelling from the raw spin-state array (pxt.gs[t].s) – a fresh relabelling of .s is not guaranteed to reproduce the same grain identity as the persisted, possibly-cleaned LFI once true grain count exceeds the number of Monte-Carlo states (see clean_temporal_slices()’s docstring). Requires calculate_lfi() to have already been run for every ranked slice.

Parameters:
  • pxt (mcgs) – Simulated mcgs object (after pxt.simulate()).

  • start (int) – Starting positional index into pxt.m.

  • step (int) – Positional increment – every step-th slice is sampled.

  • ebsd_n_parents (int or None) – EBSD pure-parent grain count (2D); if provided, a ratio n_grains_2d_avg / ebsd_n_parents is computed and the default widget selection is set to the closest match.

  • n_comparison_slices (int) – Number of evenly-spaced 2D slices to extract per axis. Default 5.

  • comparison_axes (list of str or None) – Axes to extract slices along. Any subset of ['x', 'y', 'z']. Defaults to all three if None.

  • selected_props (list of str or None) – Optional – subset of upxo.viz.ebsdviz.GRAIN_ROLE_ALL_PROPS ('area', 'aspect_ratio', 'perimeter', 'solidity', 'n_neighbours'). When given, pools these per-grain morphological properties from the SAME already-2D -relabelled slices used for the grain-count ranking above (via regionprops/adjacency_from_labels), fusing in what compute_slice_grain_properties() would otherwise compute from a SECOND, independent cc3d.connected_components pass over the identical slices – for a caller (Synthetic GS Assessment’s “Rank Temporal Slices”) that always wants both, this halves the per-slice relabelling cost. Each returned row then also carries 'prop_stats_raw'. Leave None (the default) for a plain ranking with no property pooling.

  • voxel_size (float or None) – Only used when selected_props is given – forwarded to the same area/perimeter scaling compute_slice_grain_properties() applies. Defaults to mean(pxt.vox_size) if None.

Returns:

tslice_key, n_grains_2d_avg, n_slices_used, ratio (None if no reference), plus the full-3D topology counts derived from the SAME lgi_3d labelling already computed here (no extra labelling pass): n_grains_3d (total 3D grain count), n_face_grains (grains with at least one voxel on any of the 6 RVE boundary faces), n_interior_grains (n_grains_3d - n_face_grains), and pct_ng_in_3d (percentage of n_grains_3d that are completely interior – no voxel on any of the 6 RVE faces – None if n_grains_3d is 0). Also, from the SAME per-slice lgi_2d relabelling already done for n_grains_2d_avg (no extra labelling pass): pct_ng_in_2d_mean / pct_ng_in_2d_std – mean/std, across every sampled 2D cross-section, of the percentage of that slice’s grains with no pixel on any of the slice’s 4 edges (the 2D analogue of pct_ng_in_3d, comparable to a real EBSD map’s own edge-grain fraction – see ebsdviz.compute_pct_interior_grains). Plus, only when selected_props is given: prop_stats_raw – {prop_name: ndarray}, identical in definition to compute_slice_grain_properties()’s return value.

Return type:

list of dict, each with keys

classmethod compute_slice_grain_properties(pxt, tslice_key: int, selected_props: list, comparison_axes: list | None = None, n_comparison_slices: int = 5, voxel_size: float | None = None) → dict[source]

Pool per-grain morphological property values across 2D cross-sections of one MC temporal slice, sampled the same way rank_temporal_slices_by_n() samples slices for grain counting.

At this pipeline stage the synthetic structure has no twin/parent role labels yet (those only exist on the EBSD side, post identify_parent_grains) – every grain found in the sampled cross-sections is pooled, matching the “untwinned whole grain” analogue of an EBSD parent grain.

Property definitions mirror upxo.viz.ebsdviz.compute_grain_role_property_distributions: area and perimeter are scaled once by voxel_size (in the corresponding power), aspect_ratio is major_axis_length / minor_axis_length, solidity is skimage’s own area / convex_area, and n_neighbours comes from face-adjacency on the labelled 2D slice.

Reads the persisted LFI (pxt.gs[t].lgi, from calculate_lfi()) directly rather than re-deriving a fresh connected-component labelling from pxt.gs[t].s – see rank_temporal_slices_by_n()’s docstring for why. Requires calculate_lfi() to have already been run for this slice.

Parameters:
  • pxt (mcgsV1_1 (or mcgs)) – Simulated grain-growth object (after pxt.simulate()).

  • tslice_key (int) – Key into pxt.gs.

  • selected_props (list of str) – Subset of upxo.viz.ebsdviz.GRAIN_ROLE_ALL_PROPS.

  • comparison_axes (list of str or None) – Axes to sample, subset of ['x', 'y', 'z']. Defaults to all three.

  • n_comparison_slices (int) – Evenly-spaced 2D slices to extract per axis. Default 5.

  • voxel_size (float or None) – Physical voxel edge length (assumed isotropic in-plane, same assumption the EBSD side makes with a single step_size). Reads the mean of pxt.vox_size if None.

Returns:

{prop_name: ndarray of pooled values}, one entry per requested property.

Return type:

dict

static percentile_trim(arr: numpy.ndarray, low_pct: float = 0.0, high_pct: float = 100.0) → numpy.ndarray[source]

Keep only values within [low_pct, high_pct] percentiles of arr’s own range – a direct, user-facing percentile-of-range outlier control, distinct from _iqr_trim’s fixed 1.5*IQR Tukey fence (that rule has no percentile knob at all; this one exists specifically for UI controls that ask the user for an explicit low/high percentile pair).

Parameters:
  • low_pct (float) – 0-100. Values below the low_pct percentile or above the high_pct percentile are dropped. (0, 100) (the default) is a no-op.

  • high_pct (float) – 0-100. Values below the low_pct percentile or above the high_pct percentile are dropped. (0, 100) (the default) is a no-op.

  • define (Returns arr unchanged if too few points (< 2) to)

  • meaningfully (percentiles)

  • drops (or if the resulting mask)

  • returning (everything (keeps the original array rather than)

  • empty

  • behaviour). (matching _iqr_trim's own fallback)

classmethod compare_property_distributions(ebsd_vals, synth_vals, trim_left: bool = True, trim_right: bool = True, score_function: str = 'exp') → dict | None[source]

Compare an EBSD property distribution against a synthetic candidate’s, after removing outliers from each side independently (IQR / Tukey’s rule).

Uses Wasserstein distance and energy distance (both scipy, operating directly on the two 1D sample arrays, in the property’s own units) plus a KS-test-derived similarity score. These three specifically for property-distribution comparison, not (yet) for network-level grain-structure representativeness assessment, which is a separate, later piece of work.

Wasserstein/energy distance are unbounded and expressed in the property’s own units, so they’re neither comparable across properties (Area’s µm² vs Perimeter’s µm) nor usable directly as a [0, 1] “representativeness” score. Both are first made dimensionless by dividing by the EBSD reference’s own trimmed std (its natural scale of variability), then mapped to [0, 1] via _squash_distance. ks_similarity is already bounded in [0, 1] (1 - KS statistic) and used as-is. The mean of the three is representativeness_score – an absolute (not candidate-set-relative) [0, 1] measure, comparable across properties and stable regardless of what other candidates exist.

Parameters:
  • ebsd_vals (array-like) – Raw per-grain property values (not pre-aggregated).

  • synth_vals (array-like) – Raw per-grain property values (not pre-aggregated).

  • trim_left (bool) – Which Tukey-fence side(s) to trim before comparing – applied identically to both the EBSD and synthetic sides. See _iqr_trim.

  • trim_right (bool) – Which Tukey-fence side(s) to trim before comparing – applied identically to both the EBSD and synthetic sides. See _iqr_trim.

  • score_function (str) – 'exp' (default) or 'reciprocal' – see _squash_distance.

Returns:

{'wasserstein': float, 'energy': float, 'ks_similarity': float, 'ratio': float, 'wasserstein_score': float, 'energy_score': float, 'representativeness_score': float} – ratio is (trimmed synthetic mean) / (trimmed EBSD mean), the same quantity the property-column star tolerance check is applied to. None if either side has no values left after trimming.

Return type:

dict or None

static rescale_property_values(prop_name: str, values, scale_factor: float)[source]

Convert one candidate’s raw (voxel-unit) property values into physical units using an already-derived linear scale factor (micrometres per voxel edge length – see calibrate_scale_factor).

Only length-based properties change: area scales as (scale_factor)**2, perimeter as (scale_factor)**1. Aspect ratio, solidity, and n_neighbours are dimensionless/topological and are returned unchanged – rescaling voxel size cannot affect them.

classmethod calibrate_scale_factor(pxt, tslice_key: int, ebsd_area_vals, synth_area_vals, ebsd_perimeter_vals=None, synth_perimeter_vals=None, trim_left: bool = True, trim_right: bool = True, cross_check_tolerance_pct: float = 5.0) → dict | None[source]

Derive the physical voxel edge length (micrometres/voxel) that makes this candidate temporal slice’s mean 2D cross-sectional grain area match the EBSD reference’s mean grain area, then sanity-check it against an independently-derived perimeter-based scale factor.

Method (see the twinned_simple_3d GUI design discussion this implements):

  1. Outlier-trim (IQR/Tukey) both the EBSD and synthetic Area distributions independently, take their means.

  2. Area scales as (length)**2, so scale_factor = sqrt(ebsd_mean_area / synth_mean_area_voxels) is the implied voxel edge length in the EBSD’s physical units. This is computed independently per candidate – deliberately no relationship is assumed across temporal slices, since MC step number here is used purely as a stochastic generator of candidate grain topologies, not a physical growth timeline.

  3. If Perimeter values are also available, repeat with Perimeter (which scales as length**1, no sqrt) as an independent cross-check: if the two scale factors disagree by more than cross_check_tolerance_pct, the candidate’s grains have a different area-to-perimeter relationship (shape family) than the real EBSD grains, so forcing an area match there would not be physically meaningful – cross_check_ok is False.

Caveats callers/UI should surface to the user (see the GUI design notes this implements):

  • The perimeter cross-check is measured on a voxel/pixel grid, so it is biased by discretisation (“staircase”) error – coarser voxel resolution per grain inflates measured perimeter more, which can fail the cross-check for a purely digitisation reason rather than a genuine shape mismatch. n_synth_grains / mean voxels-per-grain should be considered before trusting a failed cross-check as a real shape difference.

  • The scale factor is itself a mean-based estimate – candidates with few grains (e.g. heavily coarsened, late-stage slices) give a statistically noisier estimate. n_ebsd_grains and n_synth_grains (the outlier-trimmed sample sizes actually used) are returned so this can be judged.

  • This calibrates size only. It says nothing about whether grain shape/anisotropy is representative – that is judged separately by the property distribution comparisons (compare_property_distributions).

Parameters:
  • pxt (mcgsV1_1 (or mcgs)) – Simulated grain-growth object (after pxt.simulate()).

  • tslice_key (int) – Key into pxt.gs – used to read the candidate’s voxel grid shape for the implied RVE size.

  • ebsd_area_vals (array-like) – Raw per-grain Area values (EBSD in physical units; synthetic in voxel units).

  • synth_area_vals (array-like) – Raw per-grain Area values (EBSD in physical units; synthetic in voxel units).

  • ebsd_perimeter_vals (array-like or None) – Raw per-grain Perimeter values, for the cross-check. If either is None, the cross-check is skipped (cross_check_ok is None, not False).

  • synth_perimeter_vals (array-like or None) – Raw per-grain Perimeter values, for the cross-check. If either is None, the cross-check is skipped (cross_check_ok is None, not False).

  • trim_left (bool) – Outlier-trim sides, applied identically to area and perimeter (matching whatever the Area/Perimeter columns are configured with elsewhere).

  • trim_right (bool) – Outlier-trim sides, applied identically to area and perimeter (matching whatever the Area/Perimeter columns are configured with elsewhere).

  • cross_check_tolerance_pct (float) – Max allowed relative disagreement between the area- and perimeter-derived scale factors for the cross-check to pass.

Returns:

{'scale_factor': float, 'n_ebsd_grains': int, 'n_synth_grains': int, 'scale_factor_perimeter': float or None, 'cross_check_ok': bool or None, 'cross_check_diff_pct': float or None, 'implied_rve_size_um': (x, y, z)} – None if area data is insufficient (empty after trimming) to derive a scale factor at all.

Return type:

dict or None

classmethod plot_temporal_slice_3d(pxt, tslice_key: int, title: str | None = None)[source]

Render one MC temporal slice’s persisted LFI (pxt.gs[t].lgi, from calculate_lfi()) in a separate PyVista window, each grain shown in a distinct colour via a qualitative colormap.

Reads the persisted LFI directly rather than re-deriving a fresh connected-component labelling from pxt.gs[t].s – see rank_temporal_slices_by_n()’s docstring for why. Requires calculate_lfi() to have already been run for this slice.

Parameters:
  • pxt (mcgsV1_1 (or mcgs)) – Simulated grain-growth object (after pxt.simulate() and calculate_lfi()).

  • tslice_key (int) – Key into pxt.gs.

  • title (str or None) – Window title text; defaults to a slice/grain-count summary.

classmethod calculate_lfi(pxt, tslice_keys: list | None = None, verbose: bool = True) → dict[source]

Compute and persist the Labelled Feature Index (LFI – .lgi, a slight generalisation of “local”/”labelled grain index”) for every saved MC temporal slice at once, via gstslice.char_morphology_of_grains – the same call from_mcgs() uses lazily, run here explicitly and in bulk instead.

Run this once, right after simulation and before clean_temporal_slices() – cleaning then operates directly on this persisted LFI rather than re-deriving a fresh label field from .s on demand, giving every downstream consumer (cleaning, from_mcgs, and anything else that reads gstslice.lgi) a single, explicitly-computed source of truth instead of a lazily-cached value whose freshness relative to .s was previously only guaranteed by GUI click ordering.

Parameters:
  • pxt (mcgsV1_1 (or mcgs)) – Simulated grain-growth object (after pxt.simulate()).

  • tslice_keys (list of int or None) – Which pxt.m entries to compute LFI for. Defaults to all of them.

  • verbose (bool) – Print a per-slice grain-count summary.

Returns:

{tslice_key: {'n_grains': int}}

Return type:

dict

classmethod clean_temporal_slices(pxt, min_grain_size: int = 4, tslice_keys: list | None = None, do_merge_small: bool = True, do_spike_removal: bool = True, verbose: bool = True) → dict[source]

Clean every saved MC temporal slice’s persisted LFI (pxt.gs[t].lgi) in place: merge small grains into their largest neighbour, then remove spike voxels – the same two defect classes upxo.pxtal.twinned_simple_3d.cleaning_3d.StructureCleaner3D already handles for post-twin structures, generalised to the base (pre-twin) MC structure here.

Requires calculate_lfi() to have already been run for every targeted slice (raises if pxt.gs[t].lgi is missing) – cleaning operates directly on the persisted LFI rather than re-deriving a fresh connected-component labelling from pxt.gs[t].s on every call, so there is a single, explicit, trusted source of truth for “the current grain labelling” that every downstream consumer (ranking, property stats, GS Plot, from_mcgs) can rely on without guessing whether it reflects the latest cleaning pass.

The cleaned result is written back to BOTH pxt.gs[t].lgi (the persisted LFI, now updated) and pxt.gs[t].s (as STATE values, not label IDs, so .s keeps meaning “MC state” for any code that still reads it directly) – kept in sync so nothing downstream needs to be aware cleaning happened elsewhere.

Calling this again on an already-cleaned slice cleans the ALREADY-CLEANED LFI a second time (not fresh raw simulation data) – .lgi and .s are already in sync after the first pass, so a second pass with the same min_grain_size is normally a no-op; it only does something new if min_grain_size changed since the first pass. The GUI surfaces this explicitly before re-cleaning.

Small-grain removal is a 3D generalisation of upxo.pxtalops.gssmooth2d._merge_small_grains (2D-only, Shapely + find_neighs2d-based), built instead from the shape-agnostic primitives gid_ops.find_small_fids and netops.neighops.adjacency_from_labels (3D via cc3d.contacts), with every pass’s merge decisions applied via a single vectorised lookup-table relabelling rather than a per-grain gid_ops.merge_label_into call. Spike removal reuses StructureCleaner3D._fast_clean_spikes directly.

Parameters:
  • pxt (mcgsV1_1 (or mcgs)) – Simulated grain-growth object (after pxt.simulate() and calculate_lfi()). pxt.gs[t].lgi and pxt.gs[t].s are both overwritten in place for every cleaned slice.

  • min_grain_size (int) – Grains with fewer voxels than this are merged into their largest (by voxel count) neighbour, repeated until no grain remains below the threshold (or it has no neighbours left to merge into).

  • tslice_keys (list of int or None) – Which pxt.m entries to clean. Defaults to all of them.

  • do_merge_small (bool) – Run Stage 1 (small-grain merging). Disabling it leaves grains below min_grain_size untouched.

  • do_spike_removal (bool) – Run Stage 2 (spike-voxel removal). Disabling it leaves spike voxels untouched.

  • verbose (bool) – Print a per-slice summary.

Returns:

{tslice_key: {'n_small_merged': int, 'n_spikes_removed': int}}

Return type:

dict

classmethod clean_temporal_slices_recursive(pxt, min_grain_size: int = 4, tslice_keys: list | None = None, do_merge_small: bool = True, do_spike_removal: bool = True, n_passes: int = 1, verbose: bool = True) → Tuple[dict, int][source]

Repeats clean_temporal_slices() with the same settings up to n_passes times, stopping early the moment a pass finds nothing left to merge or reassign – a single pass can leave a grain that only drops below min_grain_size after that pass’s own spike removal, which a subsequent pass then catches.

Parameters:
  • n_passes (int) – Maximum number of cleaning passes. 1 reproduces a single clean_temporal_slices() call exactly.

  • parameters (Other)

Returns:

(cumulative, n_passes_run) – cumulative : {tslice_key: {'n_small_merged': int, 'n_spikes_removed': int}} – summed across every pass run. n_passes_run : how many passes actually ran (<= n_passes; less if convergence was reached early).

Return type:

(dict, int)

classmethod select_temporal_slice(rank_info: list, ebsd_n_parents: int | None = None)[source]

Interactive single-select widget for choosing a temporal slice.

Displays RadioButtons with one option per entry in rank_info. The default selection is the slice whose grain count is closest to ebsd_n_parents (if provided), otherwise the first entry.

Parameters:
Returns:

Read .value after running the cell to get the chosen tslice_key.

Return type:

ipywidgets.RadioButtons

upxo.pxtal.twinned_simple_3d.base_3d.compute_dropped_features(original: TwinnedSimple3DBase, current: TwinnedSimple3DBase) → Dict[source]

Grain-loss sanity check between an untouched original structure and a current one derived from it (e.g. by resampling/rescaling) – which grains present in original are absent from current, and what fraction of the original structure’s volume they accounted for.

Parameters:
  • original (TwinnedSimple3DBase) – current.grain_ids is compared against original.grain_ids by set difference; original.mprop['volnv'] is computed (via char_morphology) if not already present, to weight dropped grains by voxel count rather than by plain grain count.

  • current (TwinnedSimple3DBase) – current.grain_ids is compared against original.grain_ids by set difference; original.mprop['volnv'] is computed (via char_morphology) if not already present, to weight dropped grains by voxel count rather than by plain grain count.

Returns:

{'dropped_ids': list of int, 'dropped_vol': int, 'total_vol': int, 'pct': float} – dropped_ids sorted ascending; pct is the dropped volume as a percentage of original’s total grain volume (0.0 if nothing was dropped).

Return type:

dict