advisor-scattering 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. advisor/__init__.py +3 -0
  2. advisor/__main__.py +7 -0
  3. advisor/app.py +40 -0
  4. advisor/controllers/__init__.py +6 -0
  5. advisor/controllers/app_controller.py +69 -0
  6. advisor/controllers/feature_controller.py +25 -0
  7. advisor/domain/__init__.py +23 -0
  8. advisor/domain/core/__init__.py +8 -0
  9. advisor/domain/core/lab.py +121 -0
  10. advisor/domain/core/lattice.py +79 -0
  11. advisor/domain/core/sample.py +101 -0
  12. advisor/domain/geometry.py +212 -0
  13. advisor/domain/unit_converter.py +82 -0
  14. advisor/features/__init__.py +6 -0
  15. advisor/features/scattering_geometry/controllers/__init__.py +5 -0
  16. advisor/features/scattering_geometry/controllers/scattering_geometry_controller.py +26 -0
  17. advisor/features/scattering_geometry/domain/__init__.py +5 -0
  18. advisor/features/scattering_geometry/domain/brillouin_calculator.py +410 -0
  19. advisor/features/scattering_geometry/domain/core.py +516 -0
  20. advisor/features/scattering_geometry/ui/__init__.py +5 -0
  21. advisor/features/scattering_geometry/ui/components/__init__.py +17 -0
  22. advisor/features/scattering_geometry/ui/components/angles_to_hkl_components.py +150 -0
  23. advisor/features/scattering_geometry/ui/components/hk_angles_components.py +430 -0
  24. advisor/features/scattering_geometry/ui/components/hkl_scan_components.py +526 -0
  25. advisor/features/scattering_geometry/ui/components/hkl_to_angles_components.py +315 -0
  26. advisor/features/scattering_geometry/ui/scattering_geometry_tab.py +725 -0
  27. advisor/features/structure_factor/controllers/__init__.py +6 -0
  28. advisor/features/structure_factor/controllers/structure_factor_controller.py +25 -0
  29. advisor/features/structure_factor/domain/__init__.py +6 -0
  30. advisor/features/structure_factor/domain/structure_factor_calculator.py +107 -0
  31. advisor/features/structure_factor/ui/__init__.py +6 -0
  32. advisor/features/structure_factor/ui/components/__init__.py +12 -0
  33. advisor/features/structure_factor/ui/components/customized_plane_components.py +358 -0
  34. advisor/features/structure_factor/ui/components/hkl_plane_components.py +391 -0
  35. advisor/features/structure_factor/ui/structure_factor_tab.py +273 -0
  36. advisor/resources/__init__.py +0 -0
  37. advisor/resources/config/app_config.json +14 -0
  38. advisor/resources/config/tips.json +4 -0
  39. advisor/resources/data/nacl.cif +111 -0
  40. advisor/resources/icons/bz_caculator.jpg +0 -0
  41. advisor/resources/icons/bz_calculator.png +0 -0
  42. advisor/resources/icons/minus.svg +3 -0
  43. advisor/resources/icons/placeholder.png +0 -0
  44. advisor/resources/icons/plus.svg +3 -0
  45. advisor/resources/icons/reset.png +0 -0
  46. advisor/resources/icons/sf_calculator.jpg +0 -0
  47. advisor/resources/icons/sf_calculator.png +0 -0
  48. advisor/resources/icons.qrc +6 -0
  49. advisor/resources/qss/styles.qss +348 -0
  50. advisor/resources/resources_rc.py +83 -0
  51. advisor/ui/__init__.py +7 -0
  52. advisor/ui/init_window.py +566 -0
  53. advisor/ui/main_window.py +174 -0
  54. advisor/ui/tab_interface.py +44 -0
  55. advisor/ui/tips.py +30 -0
  56. advisor/ui/utils/__init__.py +6 -0
  57. advisor/ui/utils/readcif.py +129 -0
  58. advisor/ui/visualizers/HKLScan2DVisualizer.py +224 -0
  59. advisor/ui/visualizers/__init__.py +8 -0
  60. advisor/ui/visualizers/coordinate_visualizer.py +203 -0
  61. advisor/ui/visualizers/scattering_visualizer.py +301 -0
  62. advisor/ui/visualizers/structure_factor_visualizer.py +426 -0
  63. advisor/ui/visualizers/structure_factor_visualizer_2d.py +235 -0
  64. advisor/ui/visualizers/unitcell_visualizer.py +518 -0
  65. advisor_scattering-0.5.0.dist-info/METADATA +122 -0
  66. advisor_scattering-0.5.0.dist-info/RECORD +69 -0
  67. advisor_scattering-0.5.0.dist-info/WHEEL +5 -0
  68. advisor_scattering-0.5.0.dist-info/entry_points.txt +3 -0
  69. advisor_scattering-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,518 @@
1
+ """visualize the unit cell as done in VESTA."""
2
+
3
+ import os
4
+ import numpy as np
5
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
6
+ from matplotlib.figure import Figure
7
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
8
+ import Dans_Diffraction as dif
9
+ import matplotlib.cm as cm
10
+ import matplotlib.colors as mcolors
11
+ import matplotlib.pyplot as plt
12
+
13
+ # Import Dans_Diffraction internal functions for plotting
14
+ from Dans_Diffraction import functions_general as fg
15
+ from Dans_Diffraction import functions_plotting as fp
16
+ from Dans_Diffraction import functions_crystallography as fc
17
+
18
+ from advisor.domain import angle_to_matrix, get_rotation
19
+
20
+ # Apply the colormap patch for Dans_Diffraction compatibility
21
+ _old_ensure_cmap = cm._ensure_cmap
22
+
23
+ def _ensure_cmap_patched(cmap):
24
+ if isinstance(cmap, np.ndarray):
25
+ return mcolors.ListedColormap(cmap)
26
+ return _old_ensure_cmap(cmap)
27
+
28
+ cm._ensure_cmap = _ensure_cmap_patched
29
+
30
+
31
+ class UnitcellVisualizer(FigureCanvas):
32
+ def __init__(self, width=4, height=4, dpi=100):
33
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
34
+ self.axes = self.fig.add_subplot(111, projection='3d')
35
+ self.cif_file_path = None
36
+ self._is_initialized = False
37
+ self.crystal = None
38
+ super().__init__(self.fig)
39
+
40
+ # Set background color to white
41
+ self.fig.patch.set_facecolor("white")
42
+ self.axes.set_facecolor("white")
43
+
44
+ # Set initial view
45
+ self.axes.view_init(elev=39, azim=75)
46
+
47
+ # Clean axes like Dans_Diffraction
48
+ self.axes.set_axis_off()
49
+
50
+ @property
51
+ def cif_file_path(self):
52
+ return self._cif_file_path
53
+
54
+ @cif_file_path.setter
55
+ def cif_file_path(self, cif_file_path: str):
56
+ # check if the file exists (allow None for initialization)
57
+ if cif_file_path is not None and not os.path.exists(cif_file_path):
58
+ raise FileNotFoundError(f"File {cif_file_path} does not exist.")
59
+ self._cif_file_path = cif_file_path
60
+
61
+ @property
62
+ def is_initialized(self):
63
+ return self._is_initialized
64
+
65
+ @is_initialized.setter
66
+ def is_initialized(self, is_initialized: bool):
67
+ self._is_initialized = is_initialized
68
+
69
+
70
+ def set_parameters(self, params: dict):
71
+ cif_file_path = params["cif_file"]
72
+ self.cif_file_path = cif_file_path
73
+ try:
74
+ self.crystal = dif.Crystal(self.cif_file_path)
75
+ self.is_initialized = True
76
+ except Exception as e:
77
+ print(f"Error loading crystal structure from {cif_file_path}: {e}")
78
+ self.is_initialized = False
79
+
80
+ def visualize_unitcell(self, show_labels=False, is_clear=True):
81
+ """ read the atom position from the cif file and visualize the unit cell using integrated Dans_Diffraction logic
82
+ """
83
+ if not self.is_initialized or self.crystal is None:
84
+ print("Unitcell visualizer not initialized.")
85
+ return
86
+
87
+ # Clear the current plot
88
+ self.axes.clear()
89
+
90
+ # Set background color to white and clean axes like Dans_Diffraction
91
+ self.axes.set_facecolor("white")
92
+ self.axes.set_axis_off() # Ensure clean look after clearing
93
+
94
+ try:
95
+ # Direct implementation of Dans_Diffraction plot_crystal logic
96
+ self._plot_crystal_direct(show_labels=show_labels)
97
+
98
+ # Set title with crystal info
99
+ #title = f"Unit Cell: {os.path.basename(self.cif_file_path)}"
100
+ #if hasattr(self.crystal, 'name') and self.crystal.name:
101
+ # title = f"Unit Cell: {self.crystal.name}"
102
+ #title = f"Unit Cell"
103
+ #self.axes.set_title(title, fontsize=12, pad=20)
104
+
105
+ except Exception as e:
106
+ print(f"Error visualizing unit cell: {e}")
107
+ # Fallback: show error message on plot
108
+ self.axes.text(0.5, 0.5, 0.5, f"Error: {str(e)}",
109
+ transform=self.axes.transAxes,
110
+ ha='center', va='center')
111
+
112
+ # Set initial view
113
+ self.axes.view_init(elev=30, azim=55)
114
+
115
+ # Refresh the canvas
116
+ self.draw()
117
+
118
+ def _plot_crystal_direct(self, show_labels=False):
119
+ """
120
+ Plot crystal structure by rendering atomic positions and unit cell boundaries.
121
+ Based on Dans_Diffraction.classes_plotting.Plotting.plot_crystal method.
122
+
123
+ Args:
124
+ show_labels: If True, display atom labels next to their positions
125
+ """
126
+ # Tolerance for determining if atoms are within unit cell boundaries
127
+ boundary_tolerance = 0.05
128
+
129
+ # Generate atomic positions in fractional coordinates for one unit cell
130
+ fractional_coordinates, element_symbols, atom_labels, occupancies, _, _ = \
131
+ self.crystal.Structure.generate_lattice(1, 1, 1)
132
+
133
+ # Identify unique atom types and create mapping for colors and sizes
134
+ unique_labels, unique_indices, inverse_indices = np.unique(
135
+ atom_labels, return_index=True, return_inverse=True
136
+ )
137
+ unique_element_types = element_symbols[unique_indices]
138
+
139
+ # Assign colors to each unique atom type using rainbow colormap
140
+ atom_colors = plt.cm.rainbow(np.linspace(0, 1, len(unique_element_types)))
141
+
142
+ # Get atomic radii for each element type for visualization
143
+ atom_radii = fc.atom_properties(unique_element_types, 'Radii')
144
+
145
+ # Convert fractional coordinates to Cartesian coordinates
146
+ cartesian_positions = self.crystal.Cell.calculateR(fractional_coordinates)
147
+
148
+ # Center the structure at origin for better visualization
149
+ center_position = np.mean(cartesian_positions, axis=0)
150
+ cartesian_positions = cartesian_positions - center_position
151
+
152
+ # Create mask for atoms that are visible: within unit cell bounds and sufficiently occupied
153
+ visible_atom_mask = np.all(
154
+ np.hstack([
155
+ fractional_coordinates < (1 + boundary_tolerance),
156
+ fractional_coordinates > (0 - boundary_tolerance),
157
+ occupancies.reshape([-1, 1]) > 0.2
158
+ ]),
159
+ axis=1
160
+ )
161
+
162
+ # Get maximum lattice parameter for setting axis limits and arrow positioning
163
+ lim = np.max(self.crystal.Cell.lp()[:3])
164
+
165
+ # Plot atoms grouped by element type
166
+ for atom_type_index in range(len(unique_element_types)):
167
+ # Calculate total occupancy for this atom type
168
+ total_occupancy = np.array([
169
+ occupancies[atom_idx]
170
+ for atom_idx in range(len(cartesian_positions))
171
+ if inverse_indices[atom_idx] == atom_type_index
172
+ ])
173
+
174
+ # Skip atom types with zero occupancy
175
+ if sum(total_occupancy) == 0:
176
+ continue
177
+
178
+ # Get positions for atoms of this type
179
+ atom_positions = np.array([
180
+ cartesian_positions[atom_idx, :]
181
+ for atom_idx in range(len(cartesian_positions))
182
+ if inverse_indices[atom_idx] == atom_type_index
183
+ ])
184
+
185
+ # Filter to only visible atoms (within unit cell and occupied)
186
+ visible_atoms_mask = np.array([
187
+ visible_atom_mask[atom_idx]
188
+ for atom_idx in range(len(cartesian_positions))
189
+ if inverse_indices[atom_idx] == atom_type_index
190
+ ])
191
+
192
+ # Create color array for all atoms of this type
193
+ atom_colors_array = np.tile(
194
+ atom_colors[atom_type_index],
195
+ (len(atom_positions[visible_atoms_mask, :]), 1)
196
+ )
197
+
198
+ # Render atoms as 3D scatter points
199
+ self.axes.scatter(
200
+ atom_positions[visible_atoms_mask, 0],
201
+ atom_positions[visible_atoms_mask, 1],
202
+ atom_positions[visible_atoms_mask, 2],
203
+ s=1 * atom_radii[atom_type_index],
204
+ c=atom_colors_array,
205
+ label=unique_labels[atom_type_index],
206
+ alpha=1,
207
+ edgecolors='white',
208
+ linewidth=0.1
209
+ )
210
+
211
+ # Optionally display atom labels
212
+ if show_labels:
213
+ structure_fractional_coords, _, structure_atom_labels, _, _, _ = \
214
+ self.crystal.Structure.get()
215
+ structure_cartesian_positions = self.crystal.Cell.calculateR(
216
+ structure_fractional_coords
217
+ ) - center_position
218
+
219
+ for atom_index in range(len(structure_cartesian_positions)):
220
+ self.axes.text(
221
+ structure_cartesian_positions[atom_index, 0],
222
+ structure_cartesian_positions[atom_index, 1],
223
+ structure_cartesian_positions[atom_index, 2],
224
+ '%2d: %s' % (atom_index, structure_atom_labels[atom_index]),
225
+ fontsize=8,
226
+ bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.8)
227
+ )
228
+
229
+ # Define unit cell box vertices in fractional coordinates
230
+ unit_cell_box_coords = np.array([
231
+ [0., 0, 0], [1, 0, 0], [1, 0, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0], [0, 1, 1],
232
+ [0, 0, 1], [1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1], [0, 1, 1], [0, 1, 0],
233
+ [0, 0, 0], [0, 0, 1]
234
+ ])
235
+
236
+ # Convert box coordinates to Cartesian and center
237
+ bpos = self.crystal.Cell.calculateR(unit_cell_box_coords) - center_position
238
+
239
+ # Draw unit cell boundary as gray wireframe
240
+ self.axes.plot(bpos[:, 0], bpos[:, 1], bpos[:, 2], c='gray', linewidth=1, alpha=0.85)
241
+
242
+ # Position coordinate system arrows in corner away from structure
243
+ arrow_origin = np.array([-lim * 0.4, -lim * 0.4, -lim * 0.4])
244
+ arrow_scale = lim * 0.15
245
+
246
+ # Get normalized lattice vectors from the crystal
247
+ a_vec = np.array([1, 0, 0])
248
+ b_vec = np.array([0, 1, 0])
249
+ c_vec = np.array([0, 0, 1])
250
+
251
+ # Transform to real space coordinates
252
+ a_real = self.crystal.Cell.calculateR(a_vec.reshape(1, -1))[0]
253
+ b_real = self.crystal.Cell.calculateR(b_vec.reshape(1, -1))[0]
254
+ c_real = self.crystal.Cell.calculateR(c_vec.reshape(1, -1))[0]
255
+
256
+ # Normalize and scale the vectors
257
+ a_norm = a_real / np.linalg.norm(a_real) * arrow_scale
258
+ b_norm = b_real / np.linalg.norm(b_real) * arrow_scale
259
+ c_norm = c_real / np.linalg.norm(c_real) * arrow_scale
260
+
261
+ # Draw coordinate arrows with labels
262
+ self.axes.quiver(arrow_origin[0], arrow_origin[1], arrow_origin[2]+ 1.6*bpos[7, 2],
263
+ a_norm[0], a_norm[1], a_norm[2],
264
+ color='dodgerblue', arrow_length_ratio=0.15, linewidth=2, alpha=0.9)
265
+ self.axes.text(arrow_origin[0] + a_norm[0]*1.2, arrow_origin[1] + a_norm[1]*1,
266
+ arrow_origin[2] + a_norm[2]*1.2 + 1.6*bpos[7, 2], 'a', color='dodgerblue', fontsize=14, fontweight='bold')
267
+
268
+ self.axes.quiver(arrow_origin[0], arrow_origin[1], arrow_origin[2]+ 1.6*bpos[7, 2],
269
+ b_norm[0], b_norm[1], b_norm[2],
270
+ color='dodgerblue', arrow_length_ratio=0.15, linewidth=2, alpha=0.9)
271
+ self.axes.text(arrow_origin[0] + b_norm[0]*1.2, arrow_origin[1] + b_norm[1]*1.2,
272
+ arrow_origin[2] + b_norm[2]*1.2 + 1.6*bpos[7, 2], 'b', color='dodgerblue', fontsize=14, fontweight='bold')
273
+
274
+ self.axes.quiver(arrow_origin[0], arrow_origin[1], arrow_origin[2]+ 1.6*bpos[7, 2],
275
+ c_norm[0], c_norm[1], c_norm[2],
276
+ color='dodgerblue', arrow_length_ratio=0.15, linewidth=2, alpha=0.9)
277
+ self.axes.text(arrow_origin[0] + c_norm[0]*1.2, arrow_origin[1] + c_norm[1]*1.2,
278
+ arrow_origin[2] + c_norm[2]*1.2 + 1.6*bpos[7, 2], 'c', color='dodgerblue', fontsize=14, fontweight='bold')
279
+
280
+ # Set axis limits based on lattice
281
+ self.axes.set_xlim(-lim/2, lim/2)
282
+ self.axes.set_ylim(-lim/2, lim/2)
283
+ self.axes.set_zlim(-lim/2, lim/2)
284
+
285
+ # Turn off axis (removes background, grids, ticks, labels) - like Dans_Diffraction
286
+ #self.axes.set_axis_off()
287
+ # change view angles
288
+ # Add legend positioned closer to the unit cell
289
+ self.axes.legend(fontsize=10, frameon=False, loc='upper left',
290
+ bbox_to_anchor=(0.98, 0.98), handletextpad=0.3,
291
+ handlelength=1.5, columnspacing=0.1,
292
+ fancybox=False, shadow=False)
293
+ self.fig.tight_layout()
294
+
295
+
296
+
297
+
298
+ def visualize_scattering_geometry(self, scattering_angles=None, is_clear=False):
299
+ """ plotting the scattering plane and the beam as done in the ScatteringVisualizer
300
+ """
301
+ if not self.is_initialized or self.crystal is None:
302
+ print("Unitcell visualizer not initialized.")
303
+ return
304
+ if is_clear:
305
+ # Clear previous plot
306
+ self.axes.clear()
307
+ # Plot the x-ray beam
308
+ if scattering_angles is None:
309
+ scattering_angles = {
310
+ "theta": 50,
311
+ "tth": 150,
312
+ "phi": 0,
313
+ "chi": 0,
314
+ }
315
+ tth, theta, phi, chi = scattering_angles.get("tth", 150), scattering_angles.get("theta", 50), scattering_angles.get("phi", 0), scattering_angles.get("chi", 0)
316
+ a_vec = np.array([1, 0, 0])
317
+ b_vec = np.array([0, 1, 0])
318
+ c_vec = np.array([0, 0, 1])
319
+
320
+ # Transform to real space coordinates
321
+ a_real = self.crystal.Cell.calculateR(a_vec.reshape(1, -1))[0]
322
+ b_real = self.crystal.Cell.calculateR(b_vec.reshape(1, -1))[0]
323
+ c_real = self.crystal.Cell.calculateR(c_vec.reshape(1, -1))[0]
324
+
325
+
326
+
327
+ # Get scale factor from unit cell if available
328
+ if self.is_initialized and self.crystal is not None:
329
+ lim = np.max(self.crystal.Cell.lp()[:3])
330
+ scale_factor = lim / 2 * 1.5 # Match the unit cell scale
331
+ else:
332
+ scale_factor = 1.5 # Default scale if no crystal loaded
333
+ # Plot the scattering plane - scale it to match unit cell, adjusted for beam from -y
334
+ plane_width = 0.25 * scale_factor # narrower in x (rotated 90°)
335
+ plane_height_bottom = -0.75 * scale_factor # extends more in y direction
336
+ plane_height_top = 1.25 * scale_factor
337
+
338
+ x_basis = a_real / np.linalg.norm(a_real)
339
+ # y_basis = cross(a,b)
340
+ z_basis = np.cross(a_real, b_real) / np.linalg.norm(np.cross(a_real, b_real))
341
+ y_basis = np.cross(z_basis, x_basis) / np.linalg.norm(np.cross(z_basis, x_basis))
342
+
343
+ # Define vertices in local plane coordinates (x-y plane, z=0)
344
+ local_vertices = np.array([
345
+ [plane_width*4, plane_height_bottom, 0], # bottom right in local coords
346
+ [-plane_width, plane_height_bottom, 0], # bottom left in local coords
347
+ [plane_width*4, plane_height_top, 0], # top right in local coords
348
+ [-plane_width, plane_height_top, 0], # top left in local coords
349
+ ])
350
+
351
+ # Transform vertices to real space using crystal basis vectors
352
+ scatter_plane_vertices = np.zeros_like(local_vertices)
353
+ for i, vertex in enumerate(local_vertices):
354
+ # Transform each vertex using the basis vectors
355
+ # x component uses x_basis, y component uses y_basis, z component uses z_basis (normal to plane)
356
+ scatter_plane_vertices[i] = (vertex[0] * x_basis +
357
+ vertex[1] * y_basis +
358
+ vertex[2] * z_basis)
359
+ # coordinate change matrix: change to scattering plane system
360
+ ccm = np.array([x_basis, y_basis, z_basis]).T
361
+ scatter_plane_vertices = _rotate_vertices_wrt_plane(scatter_plane_vertices, ccm, phi, chi)
362
+ scatter_plane_faces = np.array([[0, 1, 3, 2]]) # single face
363
+ self.axes.add_collection3d(
364
+ Poly3DCollection(
365
+ scatter_plane_vertices[scatter_plane_faces],
366
+ facecolors=[0.3510, 0.7850, 0.9330], # light blue
367
+ edgecolors=[0.7, 0.7, 0.7],
368
+ alpha=0.3,
369
+ )
370
+ )
371
+
372
+
373
+
374
+ # Plot incident beam (k_in) - coming from -y direction in local coords
375
+ offset = 0
376
+ k_in_length = 1.3 * scale_factor
377
+ # In local scattering plane coords: -y_basis direction (rotated 90° from -x)
378
+ k_in_vec = - k_in_length * y_basis
379
+ k_in_vec = rotate_vector(k_in_vec, ccm, theta, phi, chi)
380
+ # Draw colored arrow on top
381
+ self.axes.quiver(
382
+ -k_in_vec[0],
383
+ -k_in_vec[1] - offset,
384
+ -k_in_vec[2],
385
+ k_in_vec[0],
386
+ k_in_vec[1],
387
+ k_in_vec[2],
388
+ color=(191 / 255, 44 / 255, 0),
389
+ alpha=1,
390
+ linewidth=5,
391
+ arrow_length_ratio=0.2,
392
+ zorder=10,
393
+ )
394
+
395
+ # Plot scattered beam (k_out) - in x-y plane, rotated 90° to match -y incident beam
396
+ k_out_length = 1.3 * scale_factor
397
+ # Rotated 90° in local coords: incident from -y, scattered at angle tth
398
+ k_out_vec = ccm @ np.array([np.sin(np.radians(tth)), -np.cos(np.radians(tth)), 0]) * k_out_length
399
+ k_out_vec = rotate_vector(k_out_vec, ccm, theta, phi, chi)
400
+ # Draw colored arrow on top
401
+ self.axes.quiver(
402
+ 0,
403
+ 0 + offset,
404
+ 0,
405
+ k_out_vec[0],
406
+ k_out_vec[1],
407
+ k_out_vec[2],
408
+ color=(2 / 255, 78 / 255, 191 / 255),
409
+ linewidth=5,
410
+ arrow_length_ratio=0.2,
411
+ zorder=10,
412
+ )
413
+
414
+ # Set axis limits to match unit cell scale
415
+ if self.is_initialized and self.crystal is not None:
416
+ self.axes.set_xlim(-lim/2, lim/2)
417
+ self.axes.set_ylim(-lim/2, lim/2)
418
+ self.axes.set_zlim(-lim/2, lim/2)
419
+ else:
420
+ # Fallback to default limits
421
+ self.axes.set_xlim(-1, 1)
422
+ self.axes.set_ylim(-1, 1)
423
+ self.axes.set_zlim(-1, 1)
424
+
425
+ #self.axes.set_axis_off()
426
+ # Update the canvas
427
+ self.draw()
428
+
429
+ def clear_plot(self):
430
+ """ clear the plot """
431
+ self.axes.clear()
432
+ self.draw()
433
+
434
+
435
+ def rotate_vector(vec, ccm, theta, phi, chi):
436
+ """Convert angles theta, phi, chi to rotation matrix. This rotate the beam or the scattering
437
+ plane, not the sample.
438
+ Pay attention to the direction of the rotation.
439
+
440
+ Updated for x-y scattering plane (z-axis is normal to scattering plane).
441
+
442
+ Args:
443
+ vec (np.ndarray): vector to rotate
444
+ ccm (np.ndarray): coordinate change matrix
445
+ theta (float): rotation about the z-axis in degrees, right-hand rule
446
+ phi (float): rotation about the x-axis in degrees, right-hand rule
447
+ chi (float): rotation about the y-axis in degrees, right-hand rule
448
+
449
+ Returns:
450
+ rotated_vec (np.ndarray): Rotated vector
451
+ """
452
+
453
+ matrix = angle_to_matrix(theta, phi, chi)
454
+ matrix = ccm @ matrix.T @ ccm.T
455
+ return matrix @ vec
456
+
457
+ def _rotate_vertices_wrt_plane(vertices, ccm, phi, chi):
458
+ """Rotate the vertices of the sample with respect to the scattering plane
459
+
460
+ Updated for x-y scattering plane (z-axis is normal to scattering plane).
461
+ phi: rotation about y-axis
462
+ chi: rotation about x-axis
463
+ """
464
+
465
+ rotation_matrix = get_rotation(phi, chi)
466
+ rotation_matrix = ccm @ rotation_matrix.T @ ccm.T
467
+ vertices = np.array(vertices)
468
+ for i, vertex in enumerate(vertices):
469
+ vertices[i] = rotation_matrix @ vertex
470
+ return vertices
471
+
472
+
473
+
474
+
475
+
476
+
477
+ if __name__ == "__main__":
478
+ import matplotlib
479
+ import matplotlib.pyplot as plt
480
+
481
+ # Set matplotlib to use a non-interactive backend for testing
482
+ matplotlib.use('Agg') # Use Agg backend for saving files
483
+
484
+ print(f"Using matplotlib backend: {matplotlib.get_backend()}")
485
+ print("Testing UnitcellVisualizer...")
486
+
487
+ # Create visualizer
488
+ viz = UnitcellVisualizer(width=10, height=8)
489
+
490
+ # Test with both available CIF files
491
+ cif_files = ['data/nacl.cif', 'data/LSCO.cif']
492
+
493
+ for cif_file in cif_files:
494
+ if os.path.exists(cif_file):
495
+ print(f"Processing {cif_file}...")
496
+
497
+ # Set CIF file path
498
+ viz.set_parameters({'cif_file': cif_file})
499
+
500
+ # Create visualization
501
+ viz.visualize_unitcell()
502
+
503
+ # Create output filename
504
+ base_name = os.path.splitext(os.path.basename(cif_file))[0]
505
+ output_file = f'figures/unitcell_{base_name}.png'
506
+
507
+ # Ensure figures directory exists
508
+ os.makedirs('figures', exist_ok=True)
509
+
510
+ # Save the plot to file
511
+ viz.fig.savefig(output_file, dpi=150, bbox_inches='tight',
512
+ facecolor='white', edgecolor='none')
513
+ print(f"Saved unit cell visualization to {output_file}")
514
+
515
+ else:
516
+ print(f"CIF file not found: {cif_file}")
517
+
518
+ print("Test completed successfully!")
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: advisor-scattering
3
+ Version: 0.5.0
4
+ Summary: Advisor-Scattering: Advanced Visual Scattering Toolkit for Reciprocal-space
5
+ Author: Xunyang Hong
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://github.com/HongXunyang/advisor
8
+ Project-URL: Documentation, https://advisor-scattering.readthedocs.io/en/latest/
9
+ Keywords: scattering,reciprocal-space,pyqt,visualization
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: License :: Other/Proprietary License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: PyQt5>=5.15.11
18
+ Requires-Dist: matplotlib>=3.9.2
19
+ Requires-Dist: numpy>=2.0.2
20
+ Requires-Dist: scipy>=1.13.1
21
+ Requires-Dist: Dans_Diffraction>=3.3.3
22
+
23
+ # Advisor-Scattering — Advanced Visual Scattering Toolkit for Reciprocal-space
24
+
25
+ ![Python](https://img.shields.io/badge/python-3.9+-blue.svg)
26
+ ![PyQt5](https://img.shields.io/badge/PyQt5-5.15+-green.svg)
27
+
28
+ Advisor-Scattering is a PyQt5 desktop app for X-ray scattering/diffraction experiments. It helps you convert scattering angles ↔ momentum transfer (HKL), explore scattering geometry, and visualize structure factors—all with interactive plots. Full docs on *[Read the Docs](https://advisor-scattering.readthedocs.io/en/latest/)*.
29
+
30
+ ![Demo video](https://raw.githubusercontent.com/HongXunyang/advisor/main/docs/source/_static/showcase.gif)
31
+ or use the link below to view the demo video.
32
+ ▶ [Demo video (MP4)](https://raw.githubusercontent.com/HongXunyang/advisor/main/docs/source/_static/showcase.mp4)
33
+
34
+
35
+ ## Features
36
+ - Convert scattering angles to momentum transfer (HKL) and vice versa.
37
+ - Visualize scattering geometry and unit cells
38
+ - Compute and visualize structure factors in reciprocal space.
39
+ - CIF file drop-in support
40
+
41
+
42
+ ## Install
43
+ - Python 3.8+ with PyQt5, numpy, scipy, matplotlib, Dans_Diffraction (see `requirements.txt`).
44
+
45
+ From PyPI:
46
+ ```bash
47
+ pip install advisor-scattering
48
+ ```
49
+
50
+ From source:
51
+ ```bash
52
+ python -m venv .venv
53
+ source .venv/bin/activate # .venv\Scripts\activate on Windows
54
+ pip install -r requirements.txt
55
+ pip install .
56
+ ```
57
+
58
+ ## Run
59
+ ```bash
60
+ advisor-scattering
61
+ # or
62
+ advisor
63
+ # or
64
+ python -m advisor
65
+ ```
66
+
67
+ *Note: the install command is `pip install advisor-scattering`, and the import is `import advisor`*.
68
+
69
+ ------
70
+
71
+ ## Minimal workflow (60 seconds)
72
+ 1) Launch the app.
73
+ 2) Enter lattice constants/angles and beam energy, or drop a CIF file.
74
+ 3) Click **Initialize**.
75
+ 4) Use the feature tabs (Scattering Geometry / Structure Factor) to calculate and visualize.
76
+
77
+ ![Init flow](https://raw.githubusercontent.com/HongXunyang/advisor/main/docs/source/_static/init.gif)
78
+
79
+ ----
80
+
81
+ ## Using the app
82
+
83
+ ### 1. Initialization window
84
+ - Enter lattice constants (a, b, c) and angles (alpha, beta, gamma); beam energy auto-updates wavelength/|k|.
85
+ - Optional: drop a CIF to autofill lattice parameters and preview the unit cell.
86
+ - Adjust Euler angles (roll, pitch, yaw) to orient the sample relative to the scattering plane;
87
+ - Click **Initialize** to load the main interface and pass parameters to all tabs.
88
+
89
+ ### 2. Scattering Geometry tab
90
+ - Angles → HKL: enter 2θ/θ/χ/φ, compute HKL.
91
+ - HKL → Angles: enter HKL, compute feasible angles.
92
+ - HK to Angles (fixed 2θ) and HKL scan (fixed 2θ) subtabs for trajectory planning.
93
+
94
+ ![Scattering geometry demo](https://raw.githubusercontent.com/HongXunyang/advisor/main/docs/source/_static/scattering_geometry_tab_demo.gif)
95
+
96
+ ### 3. Structure Factor tab
97
+ - Requires a CIF (from init) and an energy in the tab.
98
+ - HKL plane: explore a 3D HKL cube with linked HK/HL/KL slices.
99
+ - Customized plane: choose U/V vectors and a center to sample an arbitrary plane in reciprocal space.
100
+
101
+ ![Structure factor demo](https://raw.githubusercontent.com/HongXunyang/advisor/main/docs/source/_static/structure_factor_tab_demo.gif)
102
+
103
+ ### 4. Resetting
104
+ Use the toolbar button or **File → Reset Parameters** to return to the init window, clear the CIF lock, and re-enter parameters.
105
+
106
+ ------
107
+
108
+ ## Project structure (at a glance)
109
+ ```
110
+ advisor/ application package
111
+ app.py bootstrap
112
+ controllers/ app/feature coordinators
113
+ domain/ math and geometry helpers (no PyQt)
114
+ features/ per-feature domain/controller/ui code
115
+ ui/ shared UI pieces (init window, main window, base tab, visualizers)
116
+ resources/ QSS, icons, config JSON, sample data
117
+ docs/ Sphinx sources and assets
118
+ ```
119
+
120
+ ## Documentation
121
+ - Full Sphinx docs live in `docs/` and on *[Read the Docs](https://advisor-scattering.readthedocs.io/en/latest/)*.
122
+ - Appendix covers scattering angle definitions and HKL conventions.