Source code for upxo.pxtal.twinned_simple_3d.steps.steps_visualization_export

"""Visualization & Export -- Part O of the Twinned FCC walkthrough.

Covers a 3D PyVista render, a raw npy/pickle dump, and an Abaqus mesh +
elsets/nodesets export. Elset/nodeset configuration is pure data (no
compute step of its own) -- it is all consumed by export_abaqus_mesh
below via its role_enabled/role_prefix/nset_config arguments. Finishes
by rendering the accumulated upxo.reporting.ReportSession to report.html.
"""
from pathlib import Path

from .steps_start import DEFAULT_OUTPUT_DIR

# Per-role-level elset defaults (every level enabled, prefix "es_<level>_").
DEFAULT_ROLE_ENABLED = {
    'non_host': True, 'host': True, 'primary_twin': True,
    'stwin_a': True, 'stwin_b': True,
}
DEFAULT_ROLE_PREFIX = {
    'non_host': 'es_nonhost_', 'host': 'es_host_', 'primary_twin': 'es_primary_',
    'stwin_a': 'es_seca_', 'stwin_b': 'es_secb_',
}
# Per-face nodeset defaults -- origins/rve_edges nodesets are not
# implemented and are omitted here.
DEFAULT_NSET_CONFIG = {
    'XMIN': {'enabled': True, 'prefix': 'ns_x-'}, 'XMAX': {'enabled': True, 'prefix': 'ns_x+'},
    'YMIN': {'enabled': True, 'prefix': 'ns_y-'}, 'YMAX': {'enabled': True, 'prefix': 'ns_y+'},
    'ZMIN': {'enabled': True, 'prefix': 'ns_z-'}, 'ZMAX': {'enabled': True, 'prefix': 'ns_z+'},
}


[docs] def render_3d_structure(cleaner, role_opacity=None, role_color=None, nonhost_cmap=None): """Interactive PyVista 3D render (renders inline if called from a Jupyter notebook with PyVista's Jupyter backend enabled; opens a separate window otherwise). No return value -- this is a plotting side effect. role_opacity/role_color default to None, which lets render_3d apply its own tuned defaults -- notably primary_twin at partial opacity (0.6, not fully solid), so secondary twins nucleated at or inside the primary-twin/host interface stay visible instead of being hidden behind an opaque primary-twin surface. Pass your own dicts only if you deliberately want different opacities/colours. """ import numpy as np from upxo.pxtal.twinned_simple_3d.viz_3d import render_3d lgi_xyz = np.transpose(cleaner.lgi_clean, (2, 1, 0)) render_3d(lgi_xyz, cleaner.twin_role_clean, role_opacity=role_opacity, role_color=role_color, nonhost_cmap=nonhost_cmap)
[docs] def render_ipf_slice(cleaner, axis=2, slice_idx=None, sample_direction=(0., 0., 1.)): """Plots an IPF-coloured 2D slice through the cleaned structure -- plain matplotlib (unlike render_3d_structure above, this has no interactive-window step and runs fine in a headless/automated execution). `axis`: 0=X, 1=Y, 2=Z. `slice_idx`: None (default) uses the domain's mid-slice. Returns ------- (fig, ax) """ from upxo.pxtal.twinned_simple_3d.viz_3d import plot_ipf_slice return plot_ipf_slice(cleaner.lgi_clean, cleaner.all_quats_clean, axis=axis, slice_idx=slice_idx, sample_direction=sample_direction)
[docs] def sgs_pole_figure_stages(cleaner): """Per-grain orientations for the five SGS (synthetic structure) pole-figure populations, matching steps_ebsd_analysis_2.ebsd_pole_figure_stages()'s shape exactly for direct pairing in steps_distribution_viewer.plot_texture_residual(): 'full' (every grain), 'host' (every non-twin grain -- both allocated hosts and untouched non-hosts, the synthetic analogue of EBSD's 'parents'), 'primary_twins', 'secondary_twins', and 'all_twins'. Returns ------- dict : {'full', 'host', 'primary_twins', 'secondary_twins', 'all_twins'} -- each a {'gids': ndarray, 'quats': ndarray}. """ import numpy as np role_filters = { 'full': None, 'host': ('host', 'non_host'), 'primary_twins': ('primary_twin',), 'secondary_twins': ('secondary_twin',), 'all_twins': ('primary_twin', 'secondary_twin'), } stages = {} for stage_key, wanted_roles in role_filters.items(): if wanted_roles is None: gids = list(cleaner.all_quats_clean.keys()) else: gids = [g for g in cleaner.all_quats_clean.keys() if cleaner.twin_role_clean.get(g) in wanted_roles] quats = np.array([cleaner.all_quats_clean[g] for g in gids], dtype=np.float64) stages[stage_key] = {'gids': np.array(gids), 'quats': quats} return stages
[docs] def sample_frame_quats(stage_data, apply_sample_symmetry=True, use_rd=True, use_td=True, use_nd=True): """Pole-figure-ready orientations of one population. stage_data: {'gids', 'quats'} with quats in DefDAP's passive convention (as from ebsd_pole_figure_stages()/sgs_pole_figure_stages()). The vector part is negated (crystal->sample, defdap_passive_to_active), then, with apply_sample_symmetry, every orientation is replicated through the sample-symmetry group of 180 deg rotations about the selected RD/TD/ND axes -- default: all three on (4 operations). Returns ------- (quats, gids) : gids tiled to match the replicated quats. """ import numpy as np from upxo.viz.xphy.pole_figure import compute_sample_symmetry_group, apply_sample_symmetry as _apply_ss quats = np.asarray(stage_data['quats'], dtype=np.float64).copy() quats[:, 1:] *= -1 gids = np.asarray(stage_data['gids']) group_ops = compute_sample_symmetry_group(apply_sample_symmetry, use_rd, use_td, use_nd) if len(group_ops) > 1 and len(quats): quats = _apply_ss(quats, 'quaternion', group_ops) gids = np.tile(gids, len(group_ops)) return quats, gids
[docs] def plot_pole_figure(stage_data, pole_family='100', plot_mode='density', half_width_deg=7.5, title=None, ax=None, apply_sample_symmetry=True, use_rd=True, use_td=True, use_nd=True, projection='stereographic', hemisphere='upper', cmap='viridis', grid_points='auto', mud_clip_min=None, mud_clip_max=None, colorbar_decimals=2, x_label='X', y_label='Y', z_label='Z'): """Plots one population's pole figure -- a {'gids', 'quats'} dict from ebsd_pole_figure_stages()/sgs_pole_figure_stages(). plot_mode: 'density' (MUD contour), 'scatter', or 'hybrid' (density with an IPF-coloured scatter overlay). half_width_deg: the density kernel's angular half-width (degrees) -- ignored for plot_mode='scatter'. apply_sample_symmetry/use_rd/use_td/use_nd: sample symmetry (default on, RD+TD+ND). Crystal (cubic) symmetry is always applied through the pole family's symmetric equivalents. projection: 'stereographic' (default) or 'equal_area'. hemisphere: 'upper', 'lower' or 'both_mapped' (scatter and hybrid only). Returns ------- (fig, ax) """ from upxo.viz.xphy.pole_figure import PoleFigure if plot_mode not in ('density', 'scatter', 'hybrid'): raise ValueError("plot_mode must be 'density', 'scatter' or 'hybrid'") quats, gids = sample_frame_quats(stage_data, apply_sample_symmetry, use_rd, use_td, use_nd) pf = PoleFigure(quats, convention='quaternion', gids=gids) labels = dict(x_label=x_label, y_label=y_label, z_label=z_label) if plot_mode == 'scatter': return pf.plot_scatter(pole_family=pole_family, ax=ax, title=title, projection=projection, hemisphere=hemisphere, **labels) density = dict(pole_family=pole_family, ax=ax, half_width_deg=half_width_deg, title=title, projection=projection, cmap=cmap, grid_points=grid_points, mud_clip_min=mud_clip_min, mud_clip_max=mud_clip_max, colorbar_decimals=colorbar_decimals, **labels) if plot_mode == 'hybrid': return pf.plot_density(overlay_scatter=True, scatter_color_by='ipf', scatter_hemisphere=hemisphere, **density) return pf.plot_density(**density)
[docs] def plot_pole_figure_comparison(ebsd_stage, synth_stage, ebsd_label='EBSD', synth_label='Synthetic', pole_family='111', plot_mode='scatter', apply_sample_symmetry=True, use_rd=True, use_td=True, use_nd=True, projection='stereographic', hemisphere='upper', density_cmap='viridis', diff_cmap='RdBu_r', grid_points='auto', unit_normalize=False, colorbar_decimals=2, x_label='X', y_label='Y', z_label='Z'): """EBSD-against-synthetic pole-figure comparison (orientation assignment and post-twin validation): three panels side by side. plot_mode 'scatter' (default): EBSD | Synthetic | overlay. plot_mode 'density': EBSD | Synthetic MUD density on one shared colour scale, then their difference. grid_points 'auto' resolves to 150 so both sides share one grid. Both stages are {'gids', 'quats'} in DefDAP's passive convention; the same sample symmetry is applied to both, so their counts stay comparable. Panel titles give (grain count, plotted orientation count). Returns ------- (fig, (ax_ebsd, ax_synth, ax_third)) """ import numpy as np import matplotlib.pyplot as plt from upxo.viz.xphy.pole_figure import PoleFigure if plot_mode not in ('scatter', 'density'): raise ValueError("plot_mode must be 'scatter' or 'density'") ebsd_q, _ = sample_frame_quats(ebsd_stage, apply_sample_symmetry, use_rd, use_td, use_nd) synth_q, _ = sample_frame_quats(synth_stage, apply_sample_symmetry, use_rd, use_td, use_nd) pf_ebsd = PoleFigure(ebsd_q, convention='quaternion') pf_synth = PoleFigure(synth_q, convention='quaternion') labels = dict(x_label=x_label, y_label=y_label, z_label=z_label) ebsd_title = f"{ebsd_label} ({len(ebsd_stage['gids'])}, {len(ebsd_q)})" synth_title = f"{synth_label} ({len(synth_stage['gids'])}, {len(synth_q)})" fig, (ax_ebsd, ax_synth, ax_third) = plt.subplots(1, 3, figsize=(18, 6)) if plot_mode == 'scatter': view = dict(pole_family=pole_family, color_by='fixed', projection=projection, hemisphere=hemisphere) pf_ebsd.plot_scatter(ax=ax_ebsd, marker='o', s=16, alpha=0.8, color='#4878CF', title=ebsd_title, **view, **labels) pf_synth.plot_scatter(ax=ax_synth, marker='x', s=30, linewidths=1.2, alpha=0.8, color='#D65F5F', title=synth_title, **view, **labels) pf_ebsd.plot_scatter(ax=ax_third, marker='o', s=16, alpha=0.6, color='#4878CF', label=ebsd_label, **view) pf_synth.plot_scatter(ax=ax_third, marker='x', s=30, linewidths=1.2, alpha=0.8, color='#D65F5F', edgecolors=None, label=synth_label, title='Overlay', **view, **labels) ax_third.legend(loc='upper right') else: grid = 150 if grid_points == 'auto' else grid_points _, _, zi_ebsd, _ = pf_ebsd._compute_mud_grid(pf_ebsd._get_symmetric_poles(pole_family), grid) _, _, zi_synth, _ = pf_synth._compute_mud_grid(pf_synth._get_symmetric_poles(pole_family), grid) lo = float(min(np.nanmin(zi_ebsd), np.nanmin(zi_synth))) hi = float(max(np.nanmax(zi_ebsd), np.nanmax(zi_synth))) shared = dict(pole_family=pole_family, grid_points=grid, mud_clip_min=lo, mud_clip_max=hi, cmap=density_cmap, colorbar_decimals=colorbar_decimals) pf_ebsd.plot_density(ax=ax_ebsd, title=ebsd_title, **shared, **labels) pf_synth.plot_density(ax=ax_synth, title=synth_title, **shared, **labels) pf_ebsd.plot_density_difference(pf_synth, pole_family=pole_family, ax=ax_third, grid_points=grid, unit_normalize=unit_normalize, cmap=diff_cmap, title='Difference', colorbar_decimals=colorbar_decimals, **labels) fig.suptitle(f"{ebsd_label} vs {synth_label} — {{{pole_family}}}", fontsize=13, fontweight='bold') fig.tight_layout() return fig, (ax_ebsd, ax_synth, ax_third)
[docs] def pretwin_pole_figure_populations(rg, parent_info, base, assigner, csl_label): """Populations for the pre-twin comparison (orientation assignment): EBSD 'pure_parents' (grains that host a twin of csl_label and are never one) and synthetic 'hosts' (assigned twin-host grains) and 'all' (every assigned grain). Each {'gids', 'quats'} in DefDAP's passive convention, ready for plot_pole_figure_comparison(). """ import numpy as np from upxo.pxtal.twinned_simple_3d.orientation_3d import compute_ebsd_pure_parents_for_csl q, g = compute_ebsd_pure_parents_for_csl(rg, parent_info, csl_label) out = {} if q is not None: q = np.asarray(q, dtype=np.float64).copy() q[:, 1:] *= -1 # back to passive; plotting converts once out['pure_parents'] = {'gids': np.asarray(g), 'quats': q} orientations = assigner.all_grain_orientations all_gids = sorted(orientations) host_gids = sorted(gid for gid in base.host_grain_ids if gid in orientations) for key, gids in (('hosts', host_gids), ('all', all_gids)): out[key] = {'gids': np.asarray(gids), 'quats': np.array([orientations[gid] for gid in gids], dtype=np.float64).reshape(-1, 4)} return out
[docs] def plot_pole_figure_overlay(stage_a, label_a, stage_b, label_b, pole_family='100', marker_a='o', marker_b='^', color_a='steelblue', color_b='darkorange', title=None, apply_sample_symmetry=True, use_rd=True, use_td=True, use_nd=True, projection='stereographic', hemisphere='upper'): """Overlays two populations' scatter pole figures on one axes, distinguished by marker shape (not just colour) -- e.g. parents vs. twins on the same plot. Always scatter -- density contours from two different-sized populations can't be meaningfully overlaid the same way; use plot_pole_figure() per population for density instead. Returns ------- (fig, ax) """ import matplotlib.pyplot as plt from upxo.viz.xphy.pole_figure import PoleFigure fig, ax = plt.subplots(figsize=(6, 6)) for stage_data, label, marker, color in ( (stage_a, label_a, marker_a, color_a), (stage_b, label_b, marker_b, color_b)): quats, gids = sample_frame_quats(stage_data, apply_sample_symmetry, use_rd, use_td, use_nd) pf = PoleFigure(quats, convention='quaternion', gids=gids) pf.plot_scatter(pole_family=pole_family, ax=ax, color_by='fixed', projection=projection, hemisphere=hemisphere, color=color, marker=marker, label=f'{label} (n={len(stage_data["gids"])})') ax.legend(loc='upper right') default_title = f"{{{pole_family}}} Pole Figure -- {label_a} vs. {label_b}" ax.set_title(title or default_title, fontsize=12, fontweight='bold') return fig, ax
[docs] def next_master_folder(output_dir=DEFAULT_OUTPUT_DIR, base_filename="grain_structure"): """Picks the next unused "<base_filename><N>" folder name under <output_dir>/TwinnedFCC/Grain Structures -- the collision-avoidance convention export_raw() uses by default, and the one steps_temporal_slice_sweep.sweep_temporal_slices() uses once per sweep (not once per slice) to give every slice in that sweep a shared master folder. Creates the "Grain Structures" directory if it does not already exist (needed to check what's already there), but NOT the returned folder itself -- that happens when something is actually written into it. Returns ------- str : the chosen folder name (not a full path), e.g. "grain_structure3". """ out_base = Path(output_dir) / "TwinnedFCC" / "Grain Structures" out_base.mkdir(parents=True, exist_ok=True) idx = 1 while (out_base / f"{base_filename}{idx}").exists(): idx += 1 return f"{base_filename}{idx}"
[docs] def export_raw(cleaner, output_dir=DEFAULT_OUTPUT_DIR, base_filename="grain_structure", master_folder=None): """Dumps the cleaned structure's raw arrays (label field, orientations, twin role/parent maps) as .npy/.pkl -- the lightest- weight, format-agnostic save, useful for reloading straight back into Python later without re-running the pipeline. Files are written directly under <output_dir>/TwinnedFCC/Grain Structures/<master_folder>/. When `master_folder` is None (the default), a fresh, auto-numbered "<base_filename><N>" folder is picked via next_master_folder(), so repeated exports never overwrite each other. Pass an explicit `master_folder` (e.g. "<sweep_folder>/tslice_<key>", as sweep_temporal_slices() does) to nest this export inside a folder shared across a whole sweep instead. Returns ------- dict : {'out_dir', 'master_folder', 'files' (name -> byte size), 'n_grains', 'timestamp'} """ import numpy as np import pickle import datetime if master_folder is None: master_folder = next_master_folder(output_dir, base_filename) out_dir = Path(output_dir) / "TwinnedFCC" / "Grain Structures" / master_folder out_dir.mkdir(parents=True, exist_ok=True) np.save(out_dir / "lgi_twinned_clean.npy", cleaner.lgi_clean) with open(out_dir / "orientations_all.pkl", "wb") as f: pickle.dump(cleaner.all_quats_clean, f) with open(out_dir / "twin_role.pkl", "wb") as f: pickle.dump(cleaner.twin_role_clean, f) with open(out_dir / "twin_parent_of.pkl", "wb") as f: pickle.dump(cleaner.twin_parent_of_clean, f) file_names = ["lgi_twinned_clean.npy", "orientations_all.pkl", "twin_role.pkl", "twin_parent_of.pkl"] return { "out_dir": str(out_dir), "master_folder": master_folder, "files": {name: (out_dir / name).stat().st_size for name in file_names}, "n_grains": len(cleaner.twin_role_clean), "timestamp": datetime.datetime.now().isoformat(timespec='seconds'), }
[docs] def build_abaqus_index(cleaner, verbose=True): """Builds the grain-element index the Abaqus export needs (which elements belong to which grain/role/family/variant). Cache this yourself if exporting the same cleaned structure more than once -- it's the expensive part. """ from upxo.pxtal.twinned_simple_3d.abaqus_exporter_3d import AbaqusExporter3D return AbaqusExporter3D.build_index( cleaner.lgi_clean, cleaner.twin_role_clean, cleaner.twin_parent_of_clean, verbose=verbose)
[docs] def export_abaqus_mesh(cleaner, tg, index, output_dir=DEFAULT_OUTPUT_DIR, base_filename="twinned_fcc_mesh", voxel_size_um=1.0, element_type="C3D8", material_format="reference_umat", n_depvar=1, length_scale=1e-3, load_axis="z", applied_strain=0.2, step_time=80.0, initial_inc=0.01, min_inc=1e-8, max_inc=1.0, max_increments=10000, output_interval=0.5, element_outputs=("LE", "NE", "S", "SDV"), node_outputs=("U",), write_role_elsets=True, write_family_elsets=True, write_variant_elsets=True, role_enabled=None, role_prefix=None, family_prefix="es_family_", variant_prefix="es_variant_", nset_config=None, material_level="feature"): """Writes the full Abaqus .inp mesh (voxel-conformal C3D8 elements by default) plus elsets (by role/family/variant) and nodesets (domain faces), into a fresh, auto-numbered subfolder so repeated exports never overwrite each other. `index`: from build_abaqus_index() above. `tg`: the TwinGenerator3D from steps_twin_generation.generate_twins (needed for family/variant elset provenance). Returns ------- dict : {'output_dir', 'n_elements', 'n_elsets', 'n_nsets', 'files' (name -> byte size)} """ from upxo.pxtal.twinned_simple_3d.abaqus_exporter_3d import AbaqusExporter3D out_base = Path(output_dir) / "TwinnedFCC" / "ABQInputFiles" out_base.mkdir(parents=True, exist_ok=True) idx = 1 while (out_base / f"{base_filename}{idx}").exists(): idx += 1 out_dir = out_base / f"{base_filename}{idx}" exporter = AbaqusExporter3D( twin_role=cleaner.twin_role_clean, twin_parent_of=cleaner.twin_parent_of_clean, all_quats=cleaner.all_quats_clean, twinmake=tg, voxel_size_um=voxel_size_um, element_type=element_type, material_format=material_format, n_depvar=n_depvar, length_scale=length_scale, load_axis=load_axis, applied_strain=applied_strain, step_time=step_time, initial_inc=initial_inc, min_inc=min_inc, max_inc=max_inc, max_increments=max_increments, output_interval=output_interval, element_outputs=element_outputs, node_outputs=node_outputs, write_role_elsets=write_role_elsets, write_family_elsets=write_family_elsets, write_variant_elsets=write_variant_elsets, role_enabled=role_enabled or DEFAULT_ROLE_ENABLED, role_prefix=role_prefix or DEFAULT_ROLE_PREFIX, family_prefix=family_prefix, variant_prefix=variant_prefix, nset_config=nset_config or DEFAULT_NSET_CONFIG, material_level=material_level, index=index, ) exporter.write(str(out_dir)) file_sizes = {f.name: f.stat().st_size for f in sorted(Path(out_dir).glob("*.inp"))} return { "output_dir": str(out_dir), "n_elements": exporter.n_elements, "n_elsets": len(exporter._grain_elems) + exporter.n_role_elsets_written, "n_nsets": exporter.n_nsets_written, "files": file_sizes, }
[docs] def finish_report(report): """Renders the accumulated report (every add_image/add_table call made throughout this notebook) to report.html. Returns ------- pathlib.Path : path to the written report.html. """ from upxo.reporting.render_html import render_html return render_html(report)