upxo.pxtal.twinned_simple_3d package

Subpackages

Submodules

Module contents

twinned_simple_3d

3D representative grain structure generation for simple twinned FCC polycrystals (e.g. OFHC copper, Cu-Cr-Zr).

Hierarchy: host grain -> primary twin -> secondary twin (2-3 levels). This is the 3D counterpart of twinned_simple_2d, and the FCC analogue of pxtal.fm_steel_3d for FM steels.

Public API

TwinnedSimple3DBase – core grain structure, host allocation TwinGenerator3D – primary and secondary twin introduction OrientationAssigner3D – conflict-free EBSD-sampled orientations StructureCleaner3D – voxel spike removal and lobe splitting RepresentativenessValidator3D – multi-axis 2D slice MDF validation

class upxo.pxtal.twinned_simple_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

class upxo.pxtal.twinned_simple_3d.TwinGenerator3D(base, n_lamellae_per_host: int = 2, twin_nucleation_site: str = 'random_gb', tvf_tolerance: float = 0.05, twin_orient_scatter_deg: float = 1.5, meshing_route: str = 'conformal', min_lamella_thickness_conformal: int = 2, min_lamella_thickness_non_conformal: int = 1, min_host_vox_for_lamella: int = 8, prob_lamella_separated: float = 0.5, prob_lamella_contacting: float = 0.5, prob_secondary_outward_twinNucleation: float = 0.3, prob_secondary_inward_twinNucleation: float = 0.7, twin_thick_scale_factor: float = 1.0, tvf_2d_to_3d_scale_factor: float = 1.0, max_lamella_thickness_um: float | None = None, max_lamella_vf_per_host: float = 0.4, schmid_loading_direction: tuple = (0.0, 0.0, 1.0), host_schmid_weight: float = 0.0, use_schmid_for_variant_selection: bool = True, rng_seed: int | None = None)[source]

Bases: object

Introduces Sigma3 twin lamellae into a 3D grain structure.

Primary lamellae

Each host grain receives up to n_lamellae_per_host lamella carvings. The first lamella (k=0) always uses twin_nucleation_site to determine its plane origin. Subsequent lamellae (k >= 1) are placed according to prob_lamella_separated:

  • Separated (prob_lamella_separated): independent placement using twin_nucleation_site on the remaining host voxels.

  • Contacting (prob_lamella_contacting): geometric offset from lamella-0’s outer face, guaranteeing face-adjacency and hence Sigma9 misorientation between the two primary lamellae. twin_nucleation_site is overridden for this path.

Different {111} variants are pre-selected without replacement per grain so that contacting lamellae always use different variants, producing Sigma9 (not Sigma1/Sigma3) at the twin-twin boundary.

Secondary lamellae

Secondary twins nucleate from the primary twin’s surface according to prob_secondary_outward_twinNucleation:

  • Outward 2a: carved from the HOST grain at the primary-host interface centroid. Creates a direct planar Sigma9 boundary between secondary and host. twin_nucleation_site is overridden.

  • Inward 2b: carved from INSIDE the primary twin (nested). Creates a Sigma3 boundary with the primary and a Sigma9 boundary with the host if the lamella spans the full primary width. Uses twin_nucleation_site on the primary twin voxel set.

Volume-fraction control

cumulative_twin_vox is tracked across BOTH primary and secondary introduction. Introduction stops when the running twin VF exceeds tvf['overall_twin_frac'] * (1 + tvf_tolerance).

TWIN_TYPE_LABEL = 'Sigma3 -- FCC Annealing Twin (60 deg / <111>)'
DIAG_SHORTFALL_PCT_THRESHOLD = 70.0
DIAG_ABRUPT_FRAC_THRESHOLD = 0.5
DIAG_CLAMPED_FRAC_THRESHOLD = 0.3
DIAG_HOST_SATURATION_THRESHOLD = 0.95
DIAG_ANISOTROPY_RATIO_THRESHOLD = 1.5
base
n_lamellae_per_host
twin_nucleation_site
tvf_tolerance
twin_orient_scatter_deg
meshing_route
min_lamella_thickness_conformal
min_lamella_thickness_non_conformal
min_host_vox_for_lamella
prob_lamella_separated
prob_lamella_contacting
prob_secondary_outward_twinNucleation
prob_secondary_inward_twinNucleation
lgi_twinned: numpy.ndarray | None
primary_twin_quats: Dict
secondary_twin_quats: Dict
all_quats: Dict
twin_role: Dict
twin_parent_of: Dict
n_clamped: int
n_abrupt_primary: int
n_hosts_with_primary_twin: int
vf_stopped_early: bool
cumulative_twin_vox: int
twin_halfwidths_vox: Dict
twin_thick_scale_factor: float
tvf_2d_to_3d_scale_factor: float
max_lamella_thickness_um: float | None
max_lamella_vf_per_host: float
tvf_2d_ebsd: float
tvf_target_3d: float
schmid_loading_direction: tuple
host_schmid_weight: float
use_schmid_for_variant_selection: bool
introduce_primary_twins(host_orientations: Dict[int, numpy.ndarray], twin_thickness: Dict, tvf: Dict | None = None, tvf_stage1: float | None = None)[source]

Introduce primary Sigma3 twin lamellae into all designated host grains.

Parameters:
  • host_orientations (dict {gid: ndarray(4,)})

  • twin_thickness (dict) – Output of rg.compute_mc_twin_thickness(parent_info).

  • tvf (dict or None) – Output of rg.compute_ebsd_tvf(parent_info). Provides overall_twin_frac (VF stopping target) and csl_label (for Path A CSL warning).

introduce_secondary_twins(tvf: Dict, twin_thickness: Dict, tvf_2a: float | None = None, tvf_2b: float | None = None)[source]

Introduce secondary Sigma3 twins nucleated from the primary twin surface.

Nucleation mode (2a vs 2b) is chosen per event using prob_secondary_outward_twinNucleation.

2a (outward): carved from HOST grain at primary-host interface;

twin_nucleation_site overridden. Creates planar Sigma9 boundary between secondary and host.

2b (inward): carved inside the primary twin (nested);

twin_nucleation_site applied to primary twin voxels. Creates Sigma3 with primary and Sigma9 with host if it spans the full primary width.

compute_achieved_2d_tvf(n_slices_per_axis: int = 10, axes=('x', 'y', 'z'), return_raw: bool = False) → Dict[source]

Cross-sectional (2D) measurement of the achieved twin area fraction in lgi_twinned, for direct comparability with the EBSD 2D twin area fraction – EBSD data is inherently 2D, but tvf_achieved_3d is measured over the whole 3D structure, so neither is directly comparable to what a real 2D EBSD cross-section of the synthetic structure would show.

For each of n_slices_per_axis evenly-spaced 2D cross-sections along each axis in axes, computes twin pixel count / indexed pixel count (role in {'primary_twin', 'secondary_twin'} vs. any assigned role), then averages (mean and std) across every sampled slice – matching TwinnedSimple3DBase.assess_hosting_representativeness_2d’s convention of not filtering down to whichever slices happen to already be close to target. No cc3d re-labelling is needed here (unlike that method) since classification is by pixel value against twin_role, not by reassociating disconnected 2D regions back to an original 3D grain ID.

Must be called after introduce_primary_twins() (reads lgi_twinned / twin_role).

Parameters:
  • n_slices_per_axis (int) – Evenly-spaced 2D cross-sections sampled per axis.

  • axes (iterable of str) – Subset of ('x', 'y', 'z') (array axes 0/1/2 respectively – same convention as TwinnedSimple3DBase).

Returns:

  • dict with keys mean, std, n_slices_used, and (only when

  • return_raw=True) ratios – the raw per-slice twin area

  • fraction values, for callers needing the full distribution rather

  • than its mean/std (e.g. Summary Report’s EBSD-vs-MC comparison).

twin_volume_fraction() → float[source]

Return current twin VF in lgi_twinned.

summary() → Dict[source]

Return a rich summary dict of twin introduction results.

diagnose(tvf: Dict | None = None) → List[Dict][source]

Return advisory notes explaining surprising values in summary(), in a suggestive (not asserted) tone – these are heuristic hypotheses about why a number looks the way it does, not a pass/fail verdict. They never gate progression to the next GUI page.

Parameters:

tvf (dict or None) – The tvf dict passed to introduce_primary_twins() (output of rg.compute_ebsd_tvf). When provided, enables the CSL-label mismatch check. Not stored on the instance, so callers must pass it again here (kept out of __slots__ deliberately – it’s EBSD-side provenance, not twin-generation state).

Returns:

Each entry: {'anchor_fields': [...], 'message': str}. anchor_fields names the summary() keys the note explains (a GUI can attach “(Ref N)” to those fields, N = this note’s 1-based position in the returned list).

Return type:

list of dict

print_summary() → None[source]

Print a formatted, detailed summary of twin introduction results.

class upxo.pxtal.twinned_simple_3d.OrientationAssigner3D(base, orientation_assignment_mode: str = 'conflict_free', pair_similarity_deg: float = 10.0, max_retries: int = 50, mrf_max_sweeps: int = 20, mrf_eps_convergence: float = 0.3, mrf_eps_quality: float = 1.5, mrf_init_mode: str = 'mdf_analytical', mrf_kde_threshold: int | None = None, mrf_bilateral_symmetry: bool = False, mrf_sa_t_start: float = 5.0, mrf_sa_t_end: float = 0.05, rng_seed: int | None = None, cancel_event: Event | None = None)[source]

Bases: object

Conflict-aware crystallographic orientation assignment for 3D SGC grains.

Assigns per-grain unit quaternions from EBSD-derived pools, controlled by orientation_assignment_mode (increasing physical fidelity):

  • 'conflict_free' — independent sampling, no adjacent duplicate

  • 'paired_pool' — EBSD-observed neighbour orientation pairs

  • 'mdf_conditioned_pairs' — pairs conditioned on parent MDF bins

  • 'mdf_analytical' — MDF-sampled misorientation + pool snap

  • 'mrf_gibbs' / 'mrf_map' — full MRF (Gibbs / SA MAP)

See the module docstring for full level descriptions. Outputs all_grain_orientations and fallback / conflict counters.

base
orientation_assignment_mode
pair_similarity_deg
max_retries
neigh_graph: Dict | None
parent_pool: numpy.ndarray | None
full_pool: numpy.ndarray | None
fallback_quats: numpy.ndarray | None
all_grain_orientations: Dict
n_fallback_host: int
n_fallback_nonhost: int
n_conflicts: int
mrf_max_sweeps: int
mrf_eps_convergence: float
mrf_eps_quality: float
mrf_init_mode: str
mrf_kde_threshold: int | None
mrf_bilateral_symmetry: bool
mrf_sa_t_start: float
mrf_sa_t_end: float
mrf_history: list
cancel_event
build_neighbour_graph(connectivity: int = 6)[source]

Build the face-connected neighbour graph for the SGC grain structure.

build_ebsd_pools(ebsd_lfi: numpy.ndarray, ebsd_quat: numpy.ndarray, parent_info: Dict, csl_label: str, n_fallback: int = 500, fallback_tc_info: Dict | None = None, fallback_apply_symmetry: Dict[str, bool] | None = None)[source]

Build parent-only and full-EBSD quaternion pools.

fallback_tc_info / fallback_apply_symmetry : optional overrides for the synthetic fallback pool’s texture-component recipe (name -> [volume_fraction, [phi1, Phi, phi2], …] / name -> bool). Both default to None, which reproduces the prior unconditional behaviour unchanged: tops.synth_fcc_quats’ own hardcoded default recipe (Copper/Brass/Goss/Rotated-Cube), fully symmetry-expanded.

build_adjacency_model(rg, parent_info: Dict, csl_label: str, mdf_ref: dict | None = None)[source]

Level 1 – build the EBSD orientation adjacency model.

Extracts every adjacent pure-parent grain pair from the EBSD neighbour graph and records their orientation pair (q_A, q_B). Also builds a full-grain pair pool (all adjacent EBSD grain pairs) for non-host orientation assignment.

Must be called after build_ebsd_pools().

Parameters:
  • rg (repgen2d) – EBSD analysis object (provides lfi_ebsd, quat_ebsd, neigh_gid_ebsd).

  • parent_info (dict) – Output of rg.identify_parent_grains().

  • csl_label (str) – CSL label key into parent_info.

assign_host_orientations()[source]

Assign orientations to host grains.

Dispatches to the active orientation_assignment_mode.

assign_nonhost_orientations()[source]

Assign orientations to non-host grains.

Dispatches to the active orientation_assignment_mode.

check_conflicts() → int[source]

Count adjacent grain pairs sharing identical orientations.

compute_mdf(lgi: numpy.ndarray | None = None, n_bins: int = 65, angle_range: Tuple[float, float] = (0.0, 65.0)) → Dict[source]

Compute MDF for the current grain structure.

class upxo.pxtal.twinned_simple_3d.StructureCleaner3D(upscale_fallback: bool = False, split_jitter_deg: float = 0.0, rng_seed: int | None = None, min_clean_voxels: int = 0, do_spike_removal: bool = True, do_lobe_split: bool = True)[source]

Bases: object

Post-twin voxel topology cleaner for the twinned simple 3D pipeline.

Stage 1 – Vertex spike removal (one global atomic pass, vectorised). Stage 2 – Lobe splitting via a single cc3d pass on the full array.

If defects persist and upscale_fallback is True the structure is doubled in each spatial dimension (~8x voxel count) and both stages are re-applied.

upscale_fallback
split_jitter_deg
do_spike_removal: bool
do_lobe_split: bool
min_clean_voxels: int
lgi_clean: numpy.ndarray | None
all_quats_clean: Dict | None
twin_role_clean: Dict | None
twin_parent_of_clean: Dict | None
split_events: Dict
spike_count: int
n_splits: int
remaining_spikes: int
remaining_multi: int
upscale_applied: bool
clean(lgi: numpy.ndarray, all_quats: Dict, twin_role: Dict, twin_parent_of: Dict)[source]

Run Stage 1 (spike removal) + Stage 2 (lobe splitting) on the post-twin grain structure using fast vectorised local methods.

Grains with fewer than self.min_clean_voxels voxels are skipped in Stage 2. Set via the min_clean_voxels constructor argument.

check_remaining_defects() → Dict[source]
apply_upscale(lgi: numpy.ndarray, factor: int = 2) → numpy.ndarray[source]
split_events_report() → str[source]
jitter_report() → str[source]

Report focused specifically on the orientation jitter applied to split-off lobes – split_events_report() covers every split event (parent, voxel size, jitter all together in one line); this isolates just the jitter part with summary statistics (min/max/ mean applied jitter), since that’s the piece someone tracking orientation-noise/meshing concerns wants on its own, not mixed in with lobe size/parent bookkeeping.

classmethod clean_recursive(lgi: numpy.ndarray, all_quats: Dict, twin_role: Dict, twin_parent_of: Dict, n_passes: int = 1, upscale_fallback: bool = False, split_jitter_deg: float = 0.0, rng_seed: int | None = None, min_clean_voxels: int = 0, do_spike_removal: bool = True, do_lobe_split: bool = True, verbose: bool = True) → Tuple[StructureCleaner3D, int][source]

Repeats clean() up to n_passes times, feeding each pass’s cleaned output (lgi_clean/all_quats_clean/twin_role_clean/ twin_parent_of_clean) into the next pass’s input, stopping early the moment a pass finds nothing left to fix (no spikes removed, no lobes split) – a single pass can occasionally leave a spike that only appears after that pass’s own lobe-splitting step, which a subsequent pass then catches.

Constructs a fresh StructureCleaner3D for every pass (same constructor arguments each time). The returned cleaner’s spike_count/n_splits/split_events are overwritten with the cumulative totals/merged history across every pass actually run, so callers see the full picture regardless of how many passes it took. Merging split_events dicts across passes is safe – each pass’s lobe-splitting numbering starts from that pass’s own already-higher array max (it inherits the previous pass’s new grain IDs baked into the array), so passes never reuse the same new grain ID for a different lobe.

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

  • parameters (Other)

Returns:

(cleaner, n_passes_run) – cleaner : the final pass’s cleaner, with cumulative spike_count/n_splits/split_events across every pass run. n_passes_run : how many passes actually ran (<= n_passes; less if convergence was reached early).

Return type:

(StructureCleaner3D, int)

class upxo.pxtal.twinned_simple_3d.RepresentativenessValidator3D(n_slices_x: int = 10, n_slices_y: int = 10, n_slices_z: int = 10, test_along_x: bool = True, test_along_y: bool = True, test_along_z: bool = True, p_percentage: float = 60.0, p_percentage_x: float | None = None, p_percentage_y: float | None = None, p_percentage_z: float | None = None, wasserstein_threshold: float = 0.5)[source]

Bases: object

Multi-axis 2D slice representativeness validator.

Two modes: - morphological (pre-twin): Wasserstein distance on normalised

grain-size distributions.

  • crystallographic (post-twin): Wasserstein distance on misorientation angle distributions.

A 3D structure is accepted when at least p_percentage percent of slices pass on every tested axis.

n_slices_x
n_slices_y
n_slices_z
test_along_x
test_along_y
test_along_z
p_percentage
p_percentage_x
p_percentage_y
p_percentage_z
wasserstein_threshold
slice_results: Dict
axis_acceptance: Dict
overall_accepted: bool | None
validate_morphological(lgi_3d: numpy.ndarray, ebsd_areas: numpy.ndarray)[source]

Pre-twin morphological validation against EBSD grain-size distribution.

Calls section_from_3d and get_grain_size_distribution_from_slice per slice.

validate_crystallographic(lgi_3d: numpy.ndarray, quat_3d: numpy.ndarray, ebsd_miso_deg: numpy.ndarray)[source]

Post-twin crystallographic validation against EBSD MDF.

Calls extract_2d_slice_pair, find_neighs2d, and compute_mdf_from_quats per slice.

aggregate()[source]

Aggregate slice results into per-axis acceptance.

report() → str[source]

Return a formatted text summary of the validation results.