|
| 1 | +""" |
| 2 | +Full HEDM workflow integration test. |
| 3 | +
|
| 4 | +Exercises the complete pipeline: |
| 5 | + 1. Load state file (roi_dexelas_hedm.h5) |
| 6 | + 2. Load NPZ images |
| 7 | + 3. Run indexing (generate eta-omega maps, find orientations, cluster) |
| 8 | + 4. Verify indexing results (3 grains) |
| 9 | + 5. Run fit grains with min structure factor filtering |
| 10 | + 6. Verify fit grains results |
| 11 | + 7. Export grains table and workflow |
| 12 | + 8. Run CLI hexrd fit-grains on exported workflow |
| 13 | + 9. Compare GUI and CLI results |
| 14 | +
|
| 15 | +Run with: |
| 16 | + cd hexrdgui/tests && python -m pytest test_hedm_workflow.py -v -s |
| 17 | +""" |
| 18 | + |
| 19 | +import subprocess |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | +import numpy as np |
| 23 | +import pytest |
| 24 | +import yaml |
| 25 | + |
| 26 | +from PySide6.QtCore import Qt, QTimer |
| 27 | +from PySide6.QtWidgets import QApplication, QMessageBox |
| 28 | + |
| 29 | +from hexrdgui.hexrd_config import HexrdConfig |
| 30 | + |
| 31 | +from utils import select_files_when_asked |
| 32 | + |
| 33 | + |
| 34 | +@pytest.fixture |
| 35 | +def dexelas_hedm_path(example_repo_path): |
| 36 | + return example_repo_path / 'state_examples' / 'Dexelas_HEDM' |
| 37 | + |
| 38 | + |
| 39 | +def test_hedm_full_workflow(qtbot, main_window, dexelas_hedm_path, tmp_path): |
| 40 | + # ── paths ────────────────────────────────────────────────────────── |
| 41 | + state_file = dexelas_hedm_path / 'roi_dexelas_hedm.h5' |
| 42 | + npz1 = dexelas_hedm_path / 'mruby-0129_000004_ff1_000012-cachefile.npz' |
| 43 | + npz2 = dexelas_hedm_path / 'mruby-0129_000004_ff2_000012-cachefile.npz' |
| 44 | + |
| 45 | + for p in (state_file, npz1, npz2): |
| 46 | + assert p.exists(), f'Missing test file: {p}' |
| 47 | + |
| 48 | + # ── Step A: load state file ──────────────────────────────────────── |
| 49 | + main_window.load_state_file(state_file) |
| 50 | + QApplication.processEvents() |
| 51 | + |
| 52 | + # Verify detectors were loaded (ROI config has 8 sub-panel detectors) |
| 53 | + detectors = HexrdConfig().detectors |
| 54 | + assert len(detectors) == 8 |
| 55 | + |
| 56 | + # Override working_dir so it exists in CI |
| 57 | + HexrdConfig().working_dir = str(tmp_path) |
| 58 | + HexrdConfig().indexing_config['working_dir'] = str(tmp_path) |
| 59 | + |
| 60 | + # ── Step B: load NPZ images ──────────────────────────────────────── |
| 61 | + def is_dummy_data(): |
| 62 | + for ims in HexrdConfig().imageseries_dict.values(): |
| 63 | + if len(ims) != 1 or not np.all(ims[0] == 1): |
| 64 | + return False |
| 65 | + return True |
| 66 | + |
| 67 | + assert is_dummy_data() |
| 68 | + |
| 69 | + load_panel = main_window.simple_image_series_dialog |
| 70 | + with select_files_when_asked([str(npz1), str(npz2)]): |
| 71 | + qtbot.mouseClick(load_panel.ui.image_files, Qt.MouseButton.LeftButton) |
| 72 | + |
| 73 | + qtbot.mouseClick(load_panel.ui.read, Qt.MouseButton.LeftButton) |
| 74 | + QApplication.processEvents() |
| 75 | + |
| 76 | + assert not is_dummy_data() |
| 77 | + |
| 78 | + # ── Step C: trigger indexing ─────────────────────────────────────── |
| 79 | + main_window.on_action_run_indexing_triggered() |
| 80 | + runner = main_window._indexing_runner |
| 81 | + |
| 82 | + # ── Step D: accept OmeMapsSelectDialog (generate maps) ───────────── |
| 83 | + dialog = runner.ome_maps_select_dialog |
| 84 | + assert dialog is not None |
| 85 | + # The state file should have set method to 'generate' |
| 86 | + assert dialog.method_name == 'generate' |
| 87 | + dialog.ui.accept() |
| 88 | + |
| 89 | + # After accept, ome maps are generated asynchronously via |
| 90 | + # progress_dialog.exec() which runs a nested event loop. |
| 91 | + # When the worker finishes, the progress dialog closes, and |
| 92 | + # view_ome_maps() is called, creating OmeMapsViewerDialog. |
| 93 | + QApplication.processEvents() |
| 94 | + |
| 95 | + # ── Step E: accept OmeMapsViewerDialog (run find-orientations) ───── |
| 96 | + dialog = runner.ome_maps_viewer_dialog |
| 97 | + assert dialog is not None |
| 98 | + dialog.ui.accept() |
| 99 | + |
| 100 | + # This triggers fiber generation, paintGrid, clustering via |
| 101 | + # progress_dialog.exec(). When done, confirm_indexing_results() |
| 102 | + # creates IndexingResultsDialog. |
| 103 | + QApplication.processEvents() |
| 104 | + |
| 105 | + # ── Step F: verify indexing results ──────────────────────────────── |
| 106 | + assert runner.grains_table is not None |
| 107 | + num_grains = runner.grains_table.shape[0] |
| 108 | + print(f'\nIndexing found {num_grains} grains') |
| 109 | + assert num_grains == 3, f'Expected 3 grains, got {num_grains}' |
| 110 | + |
| 111 | + # Print grain orientations for reference |
| 112 | + for grain in runner.grains_table: |
| 113 | + print(f' grain {int(grain[0])}: exp_map_c = {grain[3:6]}') |
| 114 | + |
| 115 | + # ── Step G: accept IndexingResultsDialog → start fit grains ──────── |
| 116 | + indexing_dialog = runner.indexing_results_dialog |
| 117 | + assert indexing_dialog is not None |
| 118 | + indexing_dialog.ui.accept() |
| 119 | + QApplication.processEvents() |
| 120 | + |
| 121 | + fit_runner = runner._fit_grains_runner |
| 122 | + assert fit_runner is not None |
| 123 | + |
| 124 | + # ── Step H: configure FitGrainsOptionsDialog ─────────────────────── |
| 125 | + options_dialog = fit_runner.fit_grains_options_dialog |
| 126 | + assert options_dialog is not None |
| 127 | + |
| 128 | + # Apply minimum structure factor threshold of 5 |
| 129 | + options_dialog.ui.min_sfac_value.setValue(5.0) |
| 130 | + options_dialog.apply_min_sfac_to_hkls() |
| 131 | + QApplication.processEvents() |
| 132 | + |
| 133 | + # ── Step I: click "Fit Grains" (accept options dialog) ───────────── |
| 134 | + # Applying min sfac excludes [0,0,6] which was an active HKL. |
| 135 | + # validate() will show a QMessageBox.critical() informing the user |
| 136 | + # that it will re-enable those HKLs. Auto-close it. |
| 137 | + def close_active_hkl_warning(): |
| 138 | + for w in QApplication.topLevelWidgets(): |
| 139 | + if isinstance(w, QMessageBox): |
| 140 | + w.accept() |
| 141 | + return |
| 142 | + # If not found yet, try again shortly |
| 143 | + QTimer.singleShot(50, close_active_hkl_warning) |
| 144 | + |
| 145 | + QTimer.singleShot(0, close_active_hkl_warning) |
| 146 | + options_dialog.ui.accept() |
| 147 | + |
| 148 | + # fit_grains runs asynchronously via progress_dialog.exec(). |
| 149 | + # On completion, fit_grains_finished() → view_fit_grains_results() |
| 150 | + QApplication.processEvents() |
| 151 | + |
| 152 | + # ── Step J: verify fit grains results ────────────────────────────── |
| 153 | + gui_grains_table = fit_runner.result_grains_table |
| 154 | + assert gui_grains_table is not None |
| 155 | + assert gui_grains_table.shape[0] == 3 |
| 156 | + assert gui_grains_table.shape[1] == 21 |
| 157 | + |
| 158 | + print('\nFit grains results:') |
| 159 | + for grain in gui_grains_table: |
| 160 | + print(f' grain {int(grain[0])}:') |
| 161 | + print(f' completeness = {grain[1]:.4f}') |
| 162 | + print(f' chi2 = {grain[2]:.6f}') |
| 163 | + print(f' exp_map_c = {grain[3:6]}') |
| 164 | + print(f' tvec = {grain[6:9]}') |
| 165 | + |
| 166 | + # Basic sanity checks |
| 167 | + completeness = gui_grains_table[:, 1] |
| 168 | + assert np.all(completeness > 0), 'All completeness values should be > 0' |
| 169 | + |
| 170 | + chi_squared = gui_grains_table[:, 2] |
| 171 | + assert np.all(chi_squared >= 0), 'All chi2 values should be >= 0' |
| 172 | + |
| 173 | + # ── Step K: export grains table ──────────────────────────────────── |
| 174 | + results_dialog = fit_runner.fit_grains_results_dialog |
| 175 | + assert results_dialog is not None |
| 176 | + |
| 177 | + grains_out_path = tmp_path / 'gui_grains.out' |
| 178 | + with select_files_when_asked(str(grains_out_path)): |
| 179 | + results_dialog.on_export_button_pressed() |
| 180 | + |
| 181 | + assert grains_out_path.exists(), 'Grains export file was not created' |
| 182 | + gui_grains_from_file = np.loadtxt(str(grains_out_path), ndmin=2) |
| 183 | + # Text serialization introduces small rounding (up to ~1e-6) |
| 184 | + np.testing.assert_allclose(gui_grains_from_file, gui_grains_table, atol=1e-6) |
| 185 | + |
| 186 | + # ── Step L: export full workflow ─────────────────────────────────── |
| 187 | + workflow_dir = tmp_path / 'workflow' |
| 188 | + workflow_dir.mkdir() |
| 189 | + with select_files_when_asked(str(workflow_dir)): |
| 190 | + results_dialog.on_export_workflow_clicked() |
| 191 | + |
| 192 | + QApplication.processEvents() |
| 193 | + |
| 194 | + # Verify expected workflow files exist |
| 195 | + expected_files = ['workflow.yml', 'materials.h5', 'instrument.hexrd'] |
| 196 | + for f in expected_files: |
| 197 | + assert (workflow_dir / f).exists(), f'Missing workflow file: {f}' |
| 198 | + |
| 199 | + # At least some detector NPZ files should be exported |
| 200 | + det_npz_files = list(workflow_dir.glob('*.npz')) |
| 201 | + assert len(det_npz_files) > 0, 'No detector NPZ files were exported' |
| 202 | + |
| 203 | + # ── Step M: modify workflow.yml to point to original images ──────── |
| 204 | + # The exported workflow already uses group-level entries (ff1, ff2) |
| 205 | + # with ROIs preserved in the instrument config. Just update the |
| 206 | + # file paths from the exported NPZs to the original monolith files. |
| 207 | + with open(workflow_dir / 'workflow.yml') as f: |
| 208 | + config = yaml.safe_load(f) |
| 209 | + |
| 210 | + group_to_npz = { |
| 211 | + 'ff1': str(npz1), |
| 212 | + 'ff2': str(npz2), |
| 213 | + } |
| 214 | + for entry in config['image_series']['data']: |
| 215 | + panel = entry['panel'] |
| 216 | + entry['file'] = group_to_npz[panel] |
| 217 | + |
| 218 | + with open(workflow_dir / 'workflow.yml', 'w') as f: |
| 219 | + yaml.dump(config, f) |
| 220 | + |
| 221 | + # ── Step N: run CLI hexrd fit-grains ─────────────────────────────── |
| 222 | + result = subprocess.run( |
| 223 | + ['hexrd', 'fit-grains', str(workflow_dir / 'workflow.yml')], |
| 224 | + cwd=str(workflow_dir), |
| 225 | + capture_output=True, |
| 226 | + text=True, |
| 227 | + timeout=600, |
| 228 | + ) |
| 229 | + |
| 230 | + print('\nhexrd fit-grains stdout:') |
| 231 | + print(result.stdout[-2000:] if len(result.stdout) > 2000 else result.stdout) |
| 232 | + if result.returncode != 0: |
| 233 | + print('hexrd fit-grains stderr:') |
| 234 | + print(result.stderr[-2000:] if len(result.stderr) > 2000 else result.stderr) |
| 235 | + assert result.returncode == 0, ( |
| 236 | + f'hexrd fit-grains failed with return code {result.returncode}' |
| 237 | + ) |
| 238 | + |
| 239 | + # ── Step O: parse CLI output and compare with GUI export ─────────── |
| 240 | + # Find the grains.out produced by the CLI |
| 241 | + cli_grains_out = find_grains_out(workflow_dir) |
| 242 | + assert cli_grains_out is not None, f'Could not find grains.out in {workflow_dir}' |
| 243 | + |
| 244 | + cli_grains = np.loadtxt(str(cli_grains_out), ndmin=2) |
| 245 | + assert cli_grains.shape[0] == gui_grains_from_file.shape[0], ( |
| 246 | + f'CLI found {cli_grains.shape[0]} grains, ' |
| 247 | + f'GUI found {gui_grains_from_file.shape[0]}' |
| 248 | + ) |
| 249 | + |
| 250 | + # Sort both by grain ID for stable comparison |
| 251 | + gui_sorted = gui_grains_from_file[gui_grains_from_file[:, 0].argsort()] |
| 252 | + cli_sorted = cli_grains[cli_grains[:, 0].argsort()] |
| 253 | + |
| 254 | + # Compare all columns except grain ID (col 0) |
| 255 | + np.testing.assert_allclose( |
| 256 | + cli_sorted[:, 1:], |
| 257 | + gui_sorted[:, 1:], |
| 258 | + atol=1e-4, |
| 259 | + err_msg='CLI and GUI fit-grains results differ beyond tolerance', |
| 260 | + ) |
| 261 | + print('\nCLI and GUI results match within tolerance!') |
| 262 | + |
| 263 | + |
| 264 | +def find_grains_out(base_dir): |
| 265 | + """Search for grains.out file produced by hexrd CLI.""" |
| 266 | + # hexrd writes to working_dir/analysis_name/grains.out |
| 267 | + for grains_file in Path(base_dir).rglob('grains.out'): |
| 268 | + return grains_file |
| 269 | + |
| 270 | + return None |
0 commit comments