Skip to content

Commit dbf06df

Browse files
authored
Merge pull request #1979 from HEXRD/test-full-hedm-workflow
Add test for full HEDM workflow
2 parents f91b62d + f060b0d commit dbf06df

File tree

4 files changed

+288
-2
lines changed

4 files changed

+288
-2
lines changed

hexrdgui/calibration/raw_iviewer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ def create_roi_overlay_data(self, overlay: Overlay) -> dict[str, Any]:
139139
ret: dict[str, Any] = {}
140140
for det_key, data in overlay.data.items():
141141
panel = self.instr.detectors[det_key]
142+
if panel.roi is None:
143+
continue
142144

143145
def raw_to_stitched(x: np.ndarray) -> None:
144146
# x is in "ji" coordinates

hexrdgui/messages_widget.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ def write(self, text: str) -> None:
226226
i = self.call_stack.index(self)
227227
self.call_stack[i - 1].write(text)
228228

229-
self.text_received.emit(text)
229+
try:
230+
self.text_received.emit(text)
231+
except RuntimeError:
232+
# The underlying C++ object may have been deleted
233+
pass
230234

231235
def flush(self) -> None:
232236
if self in self.call_stack:

tests/conftest.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,17 @@ def main_window(qtbot):
4242
window = MainWindow()
4343
window.confirm_application_close = False
4444
qtbot.addWidget(window.ui)
45-
return window
45+
yield window
46+
47+
# Release messages widget Writers from stdout/stderr call stacks
48+
# before Qt destroys the underlying C++ objects.
49+
window.progress_dialog.messages_widget.release_output()
50+
window.messages_widget.release_output()
51+
52+
# Destroy the MainWindow QObject so Qt auto-disconnects all signal
53+
# connections (e.g. HexrdConfig signals → this window's slots).
54+
window.deleteLater()
55+
QApplication.processEvents()
4656

4757

4858
# This next fixture is necessary starting in Qt 6.8, to ensure

tests/test_hedm_workflow.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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

Comments
 (0)