batplot 1.8.0__py3-none-any.whl → 1.8.2__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.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +5 -3
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +96 -3
  5. batplot/electrochem_interactive.py +28 -0
  6. batplot/interactive.py +18 -2
  7. batplot/modes.py +12 -12
  8. batplot/operando.py +2 -0
  9. batplot/operando_ec_interactive.py +112 -11
  10. batplot/session.py +35 -1
  11. batplot/utils.py +40 -0
  12. batplot/version_check.py +85 -6
  13. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  14. batplot-1.8.2.dist-info/RECORD +75 -0
  15. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  16. batplot_backup_20251221_101150/__init__.py +5 -0
  17. batplot_backup_20251221_101150/args.py +625 -0
  18. batplot_backup_20251221_101150/batch.py +1176 -0
  19. batplot_backup_20251221_101150/batplot.py +3589 -0
  20. batplot_backup_20251221_101150/cif.py +823 -0
  21. batplot_backup_20251221_101150/cli.py +149 -0
  22. batplot_backup_20251221_101150/color_utils.py +547 -0
  23. batplot_backup_20251221_101150/config.py +198 -0
  24. batplot_backup_20251221_101150/converters.py +204 -0
  25. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  26. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  27. batplot_backup_20251221_101150/interactive.py +3894 -0
  28. batplot_backup_20251221_101150/manual.py +323 -0
  29. batplot_backup_20251221_101150/modes.py +799 -0
  30. batplot_backup_20251221_101150/operando.py +603 -0
  31. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  32. batplot_backup_20251221_101150/plotting.py +228 -0
  33. batplot_backup_20251221_101150/readers.py +2607 -0
  34. batplot_backup_20251221_101150/session.py +2951 -0
  35. batplot_backup_20251221_101150/style.py +1441 -0
  36. batplot_backup_20251221_101150/ui.py +790 -0
  37. batplot_backup_20251221_101150/utils.py +1046 -0
  38. batplot_backup_20251221_101150/version_check.py +253 -0
  39. batplot-1.8.0.dist-info/RECORD +0 -52
  40. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,823 @@
1
+ """CIF parsing and simple powder pattern simulation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import re
7
+
8
+
9
+ def _atomic_number_table():
10
+ """
11
+ Create a lookup table mapping element symbols to atomic numbers.
12
+
13
+ HOW IT WORKS:
14
+ ------------
15
+ This function creates a dictionary where:
16
+ - Key: Element symbol (e.g., 'H', 'He', 'Li')
17
+ - Value: Atomic number (e.g., 1, 2, 3)
18
+
19
+ Example:
20
+ {'H': 1, 'He': 2, 'Li': 3, 'Be': 4, ...}
21
+
22
+ WHY NEEDED?
23
+ ----------
24
+ CIF files store atoms by element symbol (e.g., 'Li', 'Fe', 'O').
25
+ Some calculations need atomic numbers instead (e.g., for scattering factors).
26
+ This table lets us convert symbols to numbers quickly.
27
+
28
+ DICTIONARY COMPREHENSION:
29
+ ------------------------
30
+ The return statement uses a dictionary comprehension:
31
+ {el: i + 1 for i, el in enumerate(elements)}
32
+
33
+ This is equivalent to:
34
+ result = {}
35
+ for i, el in enumerate(elements):
36
+ result[el] = i + 1
37
+ return result
38
+
39
+ enumerate() gives us both index (i) and element (el):
40
+ - i = 0, el = 'H' → {'H': 1}
41
+ - i = 1, el = 'He' → {'He': 2}
42
+ - etc.
43
+
44
+ We use i + 1 because atomic numbers start at 1 (not 0).
45
+
46
+ Returns:
47
+ Dictionary mapping element symbols to atomic numbers
48
+ """
49
+ # Periodic table elements in order (by atomic number)
50
+ elements = [
51
+ 'H', 'He', 'Li', 'Be', 'B', 'C', 'N', 'O', 'F', 'Ne',
52
+ 'Na', 'Mg', 'Al', 'Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca',
53
+ 'Sc', 'Ti', 'V', 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn',
54
+ 'Ga', 'Ge', 'As', 'Se', 'Br', 'Kr', 'Rb', 'Sr', 'Y', 'Zr',
55
+ 'Nb', 'Mo', 'Tc', 'Ru', 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn',
56
+ 'Sb', 'Te', 'I', 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd',
57
+ 'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb',
58
+ 'Lu', 'Hf', 'Ta', 'W', 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg',
59
+ 'Tl', 'Pb', 'Bi', 'Po', 'At', 'Rn'
60
+ ]
61
+ # Dictionary comprehension: create dict from list with index as value
62
+ # enumerate() gives (index, element) pairs: (0, 'H'), (1, 'He'), ...
63
+ # i + 1 converts 0-based index to 1-based atomic number
64
+ return {el: i + 1 for i, el in enumerate(elements)}
65
+
66
+
67
+ def _parse_cif_basic(fname):
68
+ """
69
+ Parse a CIF (Crystallographic Information File) to extract crystal structure data.
70
+
71
+ WHAT IS A CIF FILE?
72
+ -------------------
73
+ CIF (Crystallographic Information File) is a standard format for storing
74
+ crystal structure information. It contains:
75
+ - Unit cell parameters (a, b, c, α, β, γ)
76
+ - Space group information
77
+ - Atom positions (fractional coordinates)
78
+ - Symmetry operations
79
+
80
+ HOW PARSING WORKS:
81
+ -----------------
82
+ CIF files are text-based with a specific format:
83
+ - Lines starting with '_' are data names (keys)
84
+ - Lines starting with 'loop_' begin a data loop (table)
85
+ - Values follow on the same line or next lines
86
+ - Comments start with '#'
87
+
88
+ Example CIF structure:
89
+ _cell_length_a 5.0
90
+ _cell_length_b 5.0
91
+ _cell_length_c 5.0
92
+ loop_
93
+ _atom_site_fract_x
94
+ _atom_site_fract_y
95
+ _atom_site_fract_z
96
+ _atom_site_type_symbol
97
+ 0.0 0.0 0.0 Li
98
+ 0.5 0.5 0.5 O
99
+
100
+ This function reads through the file line by line, identifying and
101
+ extracting the relevant information.
102
+
103
+ Args:
104
+ fname: Path to CIF file
105
+
106
+ Returns:
107
+ Dictionary with keys:
108
+ - 'cell': Dictionary with unit cell parameters (a, b, c, alpha, beta, gamma, space_group)
109
+ - 'atoms': List of atom dictionaries (each with x, y, z, symbol, etc.)
110
+ - 'sym_ops': List of symmetry operation strings
111
+ """
112
+ # Initialize data structures to store parsed information
113
+ # cell: Unit cell parameters (lengths and angles)
114
+ cell = {
115
+ 'a': None, 'b': None, 'c': None, # Unit cell lengths (in Angstroms)
116
+ 'alpha': None, 'beta': None, 'gamma': None, # Unit cell angles (in degrees)
117
+ 'space_group': None # Space group symbol (e.g., "Fm-3m")
118
+ }
119
+ atoms = [] # List to store atom information (positions, types, etc.)
120
+ sym_ops = [] # List to store symmetry operations (for generating equivalent positions)
121
+ atom_headers = [] # List of column headers in atom loop (e.g., "_atom_site_fract_x")
122
+ in_atom_loop = False # Flag: are we currently reading atom data rows?
123
+
124
+ def _clean_num(tok: str):
125
+ """
126
+ Clean a numeric token by removing quotes and uncertainty values.
127
+
128
+ CIF files sometimes have numbers like:
129
+ - "5.0" (with quotes)
130
+ - 5.0(2) (with uncertainty in parentheses)
131
+
132
+ This function removes quotes and uncertainty to get just the number.
133
+
134
+ Args:
135
+ tok: String token that should contain a number
136
+
137
+ Returns:
138
+ Cleaned string (ready to convert to float)
139
+ """
140
+ # Remove whitespace and quotes (single or double)
141
+ t = tok.strip().strip("'\"")
142
+ # Remove uncertainty notation: "5.0(2)" → "5.0"
143
+ # r"\([0-9]+\)$" matches parentheses with digits at end of string
144
+ t = re.sub(r"\([0-9]+\)$", "", t)
145
+ return t
146
+
147
+ # Open file and read line by line
148
+ # encoding='utf-8': Handle international characters properly
149
+ # errors='ignore': Skip invalid characters instead of crashing
150
+ with open(fname, 'r', encoding='utf-8', errors='ignore') as f:
151
+ # Process each line in the file
152
+ for raw in f:
153
+ # Remove leading/trailing whitespace
154
+ line = raw.strip()
155
+ # Skip empty lines and comments
156
+ if not line or line.startswith('#'):
157
+ continue
158
+
159
+ # Convert to lowercase for case-insensitive matching
160
+ # CIF keywords are case-insensitive, so we normalize for comparison
161
+ low = line.lower()
162
+
163
+ # Parse space group
164
+ if (low.startswith('_space_group_name_h-m_alt') or
165
+ low.startswith('_symmetry_space_group_name_h-m')):
166
+ parts = line.split()
167
+ if len(parts) >= 2:
168
+ cell['space_group'] = parts[1].strip("'\"")
169
+
170
+ # Parse cell parameters
171
+ if low.startswith('_cell_length_a'):
172
+ try:
173
+ cell['a'] = float(_clean_num(line.split()[1]))
174
+ except Exception:
175
+ pass
176
+ elif low.startswith('_cell_length_b'):
177
+ try:
178
+ cell['b'] = float(_clean_num(line.split()[1]))
179
+ except Exception:
180
+ pass
181
+ elif low.startswith('_cell_length_c'):
182
+ try:
183
+ cell['c'] = float(_clean_num(line.split()[1]))
184
+ except Exception:
185
+ pass
186
+ elif low.startswith('_cell_angle_alpha'):
187
+ try:
188
+ cell['alpha'] = float(_clean_num(line.split()[1]))
189
+ except Exception:
190
+ pass
191
+ elif low.startswith('_cell_angle_beta'):
192
+ try:
193
+ cell['beta'] = float(_clean_num(line.split()[1]))
194
+ except Exception:
195
+ pass
196
+ elif low.startswith('_cell_angle_gamma'):
197
+ try:
198
+ cell['gamma'] = float(_clean_num(line.split()[1]))
199
+ except Exception:
200
+ pass
201
+
202
+ # ====================================================================
203
+ # HANDLE LOOP STRUCTURES
204
+ # ====================================================================
205
+ # CIF files use 'loop_' to indicate the start of a data table.
206
+ # After 'loop_', there are column headers (lines starting with '_'),
207
+ # followed by data rows (values separated by spaces).
208
+ #
209
+ # Example:
210
+ # loop_
211
+ # _atom_site_fract_x
212
+ # _atom_site_fract_y
213
+ # _atom_site_fract_z
214
+ # _atom_site_type_symbol
215
+ # 0.0 0.0 0.0 Li
216
+ # 0.5 0.5 0.5 O
217
+ #
218
+ # When we see 'loop_', we reset our parsing state.
219
+ # ====================================================================
220
+ if line.lower().startswith('loop_'):
221
+ in_atom_loop = False # Reset flag - we're starting a new loop
222
+ atom_headers = [] # Clear previous headers
223
+ continue # Move to next line
224
+
225
+ # Skip symmetry operation header (we'll collect operations separately)
226
+ if line.lower().startswith('_space_group_symop_operation_xyz'):
227
+ continue
228
+
229
+ # ====================================================================
230
+ # COLLECT ATOM SITE HEADERS
231
+ # ====================================================================
232
+ # Atom site headers define what each column in the atom data table means.
233
+ # Common headers:
234
+ # _atom_site_fract_x: Fractional x-coordinate (0.0 to 1.0)
235
+ # _atom_site_fract_y: Fractional y-coordinate (0.0 to 1.0)
236
+ # _atom_site_fract_z: Fractional z-coordinate (0.0 to 1.0)
237
+ # _atom_site_type_symbol: Element symbol (e.g., 'Li', 'O', 'Fe')
238
+ # _atom_site_label: Atom label (e.g., 'Li1', 'O1')
239
+ #
240
+ # We need x, y, z coordinates to know where atoms are located.
241
+ # Once we have all three, we know we're ready to parse atom data rows.
242
+ # ====================================================================
243
+ if line.lower().startswith('_atom_site_'):
244
+ atom_headers.append(line) # Store this header
245
+ # Check if we have all required coordinate columns
246
+ # any() returns True if at least one header matches
247
+ has_x = any(h.lower().startswith('_atom_site_fract_x')
248
+ for h in atom_headers)
249
+ has_y = any(h.lower().startswith('_atom_site_fract_y')
250
+ for h in atom_headers)
251
+ has_z = any(h.lower().startswith('_atom_site_fract_z')
252
+ for h in atom_headers)
253
+ # If we have x, y, z columns, we're ready to parse atom data
254
+ if has_x and has_y and has_z:
255
+ in_atom_loop = True # Set flag: next non-header lines are atom data
256
+ continue # Move to next line
257
+
258
+ # Parse symmetry operations
259
+ if (len(atom_headers) == 1 and
260
+ atom_headers[0].lower().startswith('_space_group_symop_operation_xyz') and
261
+ not line.startswith('_') and ',' in line):
262
+ sym_ops.append(line.strip().strip("'\""))
263
+ continue
264
+
265
+ # ====================================================================
266
+ # PARSE ATOM POSITIONS
267
+ # ====================================================================
268
+ # When we're in an atom loop and the line doesn't start with '_',
269
+ # it's a data row containing atom information.
270
+ #
271
+ # Example data row:
272
+ # 0.0 0.0 0.0 Li 1.0 0.05
273
+ # ↑ ↑ ↑ ↑ ↑ ↑
274
+ # x y z sym occ uiso
275
+ #
276
+ # The order of values matches the order of headers we collected earlier.
277
+ # ====================================================================
278
+ if in_atom_loop and not line.startswith('_'):
279
+ # Split line into tokens (values separated by whitespace)
280
+ toks = line.split()
281
+ # Need at least 4 tokens (x, y, z, symbol)
282
+ if len(toks) < 4:
283
+ continue # Skip malformed lines
284
+
285
+ # ================================================================
286
+ # CREATE HEADER-TO-COLUMN-INDEX MAPPING
287
+ # ================================================================
288
+ # We collected headers in order: ['_atom_site_fract_x', '_atom_site_fract_y', ...]
289
+ # Now we create a dictionary mapping header name → column index
290
+ #
291
+ # Example:
292
+ # headers = ['_atom_site_fract_x', '_atom_site_fract_y', '_atom_site_type_symbol']
293
+ # header_map = {
294
+ # '_atom_site_fract_x': 0,
295
+ # '_atom_site_fract_y': 1,
296
+ # '_atom_site_type_symbol': 2
297
+ # }
298
+ #
299
+ # This lets us find which column contains which data.
300
+ # ================================================================
301
+ header_map = {h.lower(): i for i, h in enumerate(atom_headers)}
302
+
303
+ def gidx(prefix):
304
+ """
305
+ Get column index for a header that starts with given prefix.
306
+
307
+ This helper function searches through headers to find which
308
+ column contains a particular type of data.
309
+
310
+ Args:
311
+ prefix: Header prefix to search for (e.g., '_atom_site_fract_x')
312
+
313
+ Returns:
314
+ Column index (0-based), or None if not found
315
+ """
316
+ for h, i in header_map.items():
317
+ if h.startswith(prefix):
318
+ return i
319
+ return None
320
+
321
+ # Find column indices for each piece of atom data
322
+ ix = gidx('_atom_site_fract_x') # X coordinate column
323
+ iy = gidx('_atom_site_fract_y') # Y coordinate column
324
+ iz = gidx('_atom_site_fract_z') # Z coordinate column
325
+ isym = gidx('_atom_site_type_symbol') # Element symbol column
326
+ ilab = gidx('_atom_site_label') # Atom label column (optional)
327
+ iocc = gidx('_atom_site_occupancy') # Occupancy column (optional, usually 1.0)
328
+ # Thermal displacement parameter (B-factor) - try multiple possible header names
329
+ iuiso = (gidx('_atom_site_u_iso') or
330
+ gidx('_atom_site_u_iso_or_equiv') or
331
+ gidx('_atom_site_u_equiv'))
332
+
333
+ try:
334
+ x = float(_clean_num(toks[ix])) if ix is not None else 0.0
335
+ y = float(_clean_num(toks[iy])) if iy is not None else 0.0
336
+ z = float(_clean_num(toks[iz])) if iz is not None else 0.0
337
+ except Exception:
338
+ continue
339
+
340
+ if isym is not None and isym < len(toks):
341
+ sym = re.sub(r'[^A-Za-z].*', '', toks[isym])
342
+ elif ilab is not None and ilab < len(toks):
343
+ sym = re.sub(r'[^A-Za-z].*', '', toks[ilab])
344
+ else:
345
+ sym = 'X'
346
+
347
+ if iocc is not None and iocc < len(toks):
348
+ try:
349
+ occ = float(_clean_num(toks[iocc]))
350
+ except Exception:
351
+ occ = 1.0
352
+ else:
353
+ occ = 1.0
354
+
355
+ if iuiso is not None and iuiso < len(toks):
356
+ try:
357
+ Uiso = float(_clean_num(toks[iuiso]))
358
+ except Exception:
359
+ Uiso = None
360
+ else:
361
+ Uiso = None
362
+
363
+ atoms.append((sym, x, y, z, occ, Uiso))
364
+
365
+ # Validate parsed data
366
+ if any(v is None for v in cell.values()):
367
+ raise ValueError(f"Incomplete cell parameters in CIF {fname}")
368
+
369
+ if not atoms:
370
+ raise ValueError(f"No atoms parsed from CIF {fname}")
371
+
372
+ # Apply symmetry operations if present
373
+ if sym_ops:
374
+ seen = set()
375
+ expanded = []
376
+ if not any(op.replace(' ', '') in ('x,y,z', 'x,y,z,') for op in sym_ops):
377
+ sym_ops.append('x, y, z')
378
+
379
+ def eval_coord(expr, x, y, z):
380
+ expr = expr.strip().lower().replace(' ', '')
381
+ if not re.match(r'^[xyz0-9+\-*/().,/]*$', expr):
382
+ return x
383
+ try:
384
+ return eval(expr, {"__builtins__": {}}, {'x': x, 'y': y, 'z': z}) % 1.0
385
+ except Exception:
386
+ return x
387
+
388
+ for sym, x, y, z, occ, Uiso in atoms:
389
+ for op in sym_ops:
390
+ parts = op.strip().strip("'\"").split(',')
391
+ if len(parts) != 3:
392
+ continue
393
+
394
+ nx = eval_coord(parts[0], x, y, z)
395
+ ny = eval_coord(parts[1], x, y, z)
396
+ nz = eval_coord(parts[2], x, y, z)
397
+
398
+ key = (round(nx, 4), round(ny, 4), round(nz, 4), sym)
399
+ if key in seen:
400
+ continue
401
+
402
+ seen.add(key)
403
+ expanded.append((sym, nx, ny, nz, occ, Uiso))
404
+
405
+ if expanded:
406
+ atoms = expanded
407
+
408
+ return cell, atoms
409
+
410
+
411
+ def simulate_cif_pattern_Q(fname, Qmax=10.0, dQ=0.002, peak_width=0.01,
412
+ wavelength=1.5406, space_group_hint=None):
413
+ """Simulate powder diffraction pattern from CIF file in Q-space."""
414
+ cell, atoms = _parse_cif_basic(fname)
415
+
416
+ if space_group_hint is None:
417
+ space_group_hint = cell.get('space_group')
418
+
419
+ a, b, c = cell['a'], cell['b'], cell['c']
420
+ alpha = np.deg2rad(cell['alpha'])
421
+ beta = np.deg2rad(cell['beta'])
422
+ gamma = np.deg2rad(cell['gamma'])
423
+
424
+ # Build unit cell vectors
425
+ a_vec = np.array([a, 0, 0], dtype=float)
426
+ b_vec = np.array([b * np.cos(gamma), b * np.sin(gamma), 0], dtype=float)
427
+
428
+ c_x = c * np.cos(beta)
429
+ c_y = c * (np.cos(alpha) - np.cos(beta) * np.cos(gamma)) / np.sin(gamma)
430
+ c_z = np.sqrt(max(c ** 2 - c_x ** 2 - c_y ** 2, 1e-12))
431
+ c_vec = np.array([c_x, c_y, c_z], dtype=float)
432
+
433
+ # Calculate reciprocal lattice
434
+ A = np.column_stack([a_vec, b_vec, c_vec])
435
+ V = np.dot(a_vec, np.cross(b_vec, c_vec))
436
+
437
+ if abs(V) < 1e-10:
438
+ raise ValueError('Invalid cell volume')
439
+
440
+ B = 2 * np.pi * np.linalg.inv(A).T
441
+ b1, b2, b3 = B[:, 0], B[:, 1], B[:, 2]
442
+
443
+ a_star = np.linalg.norm(b1)
444
+ b_star = np.linalg.norm(b2)
445
+ c_star = np.linalg.norm(b3)
446
+
447
+ hmax = max(1, int(np.ceil(Qmax / a_star)))
448
+ kmax = max(1, int(np.ceil(Qmax / b_star)))
449
+ lmax = max(1, int(np.ceil(Qmax / c_star)))
450
+
451
+ Zmap = _atomic_number_table()
452
+
453
+ # Cromer-Mann coefficients for form factors
454
+ CM_COEFFS = {
455
+ 'C': ([2.3100, 1.0200, 1.5886, 0.8650],
456
+ [20.8439, 10.2075, 0.5687, 51.6512], 0.2156),
457
+ 'N': ([12.2126, 3.1322, 2.0125, 1.1663],
458
+ [0.0057, 9.8933, 28.9975, 0.5826], -11.5290),
459
+ 'O': ([3.0485, 2.2868, 1.5463, 0.8670],
460
+ [13.2771, 5.7011, 0.3239, 32.9089], 0.2508),
461
+ 'Si': ([6.2915, 3.0353, 1.9891, 1.5410],
462
+ [2.4386, 32.3337, 0.6785, 81.6937], 1.1407),
463
+ 'Fe': ([11.7695, 7.3573, 3.5222, 2.3045],
464
+ [4.7611, 0.3072, 15.3535, 76.8805], 1.0369),
465
+ 'Ni': ([12.8376, 7.2920, 4.4438, 2.3800],
466
+ [3.8785, 0.2565, 13.5290, 71.1692], 1.0341),
467
+ 'Cu': ([13.3380, 7.1676, 5.6158, 1.6735],
468
+ [3.5828, 0.2470, 11.3966, 64.8126], 1.1910),
469
+ 'Se': ([19.3319, 8.8752, 2.6959, 1.2199],
470
+ [6.4000, 1.4838, 19.9887, 55.4486], 1.1053),
471
+ }
472
+
473
+ def form_factor(sym, Q):
474
+ s2 = (Q / (4 * np.pi)) ** 2
475
+ if sym in CM_COEFFS:
476
+ a, b, c = CM_COEFFS[sym]
477
+ f = c
478
+ for ai, bi in zip(a, b):
479
+ f += ai * np.exp(-bi * s2)
480
+ return max(f, 0.0)
481
+ Z = Zmap.get(sym, 10)
482
+ return Z * np.exp(-0.002 * Q * Q)
483
+
484
+ # Prepare atom data
485
+ atom_data = []
486
+ for sym, x, y, z, occ, Uiso in _parse_cif_basic(fname)[1]:
487
+ sym_cap = sym.capitalize()
488
+ Biso = 8 * np.pi ** 2 * Uiso if Uiso is not None else 0.0
489
+ atom_data.append((sym_cap, x, y, z, occ, Biso))
490
+
491
+ def extinct(h, k, l, sg):
492
+ """Check if reflection is extinct due to space group symmetry."""
493
+ if not sg:
494
+ return False
495
+
496
+ sg0 = sg.lower()[0]
497
+
498
+ if sg0 == 'p':
499
+ return False
500
+ if sg0 == 'i':
501
+ return (h + k + l) % 2 != 0
502
+ if sg0 == 'f':
503
+ all_even = (h % 2 == 0) and (k % 2 == 0) and (l % 2 == 0)
504
+ all_odd = (h % 2 != 0) and (k % 2 != 0) and (l % 2 != 0)
505
+ return not (all_even or all_odd)
506
+ if sg0 == 'c':
507
+ return (h + k) % 2 != 0
508
+ if sg0 == 'r':
509
+ return ((-h + k + l) % 3) != 0
510
+
511
+ return False
512
+
513
+ # Calculate reflections
514
+ refl_map = {}
515
+ lam = wavelength if wavelength else 1.5406
516
+
517
+ for h in range(-hmax, hmax + 1):
518
+ for k in range(-kmax, kmax + 1):
519
+ for l in range(-lmax, lmax + 1):
520
+ if h == 0 and k == 0 and l == 0:
521
+ continue
522
+ if extinct(h, k, l, space_group_hint):
523
+ continue
524
+
525
+ G = h * b1 + k * b2 + l * b3
526
+ Q = np.linalg.norm(G)
527
+
528
+ if Q <= 0 or Q > Qmax:
529
+ continue
530
+
531
+ s = (Q * lam) / (4 * np.pi)
532
+ if s <= 0 or s >= 1:
533
+ continue
534
+
535
+ theta = np.arcsin(s)
536
+ s2 = (Q / (4 * np.pi)) ** 2
537
+
538
+ phases = []
539
+ weights = []
540
+
541
+ for sym_cap, ax, ay, az, occ, Biso in atom_data:
542
+ phase = 2 * np.pi * (h * ax + k * ay + l * az)
543
+ f0 = form_factor(sym_cap, Q)
544
+
545
+ if f0 <= 1e-8:
546
+ continue
547
+
548
+ dw = np.exp(-Biso * s2) if Biso > 0 else 1.0
549
+ w = f0 * occ * dw
550
+
551
+ if w <= 0:
552
+ continue
553
+
554
+ phases.append(phase)
555
+ weights.append(w)
556
+
557
+ if not weights:
558
+ continue
559
+
560
+ weights = np.array(weights)
561
+ phases = np.array(phases)
562
+
563
+ F = np.sum(weights * np.exp(1j * phases))
564
+ I = (F.real ** 2 + F.imag ** 2)
565
+
566
+ if I <= 1e-14:
567
+ continue
568
+
569
+ cos_2theta = np.cos(2 * theta)
570
+ sin_theta_sq = np.sin(theta) ** 2
571
+ sin_2theta = np.sin(2 * theta)
572
+
573
+ if sin_theta_sq <= 0 or sin_2theta <= 1e-12:
574
+ continue
575
+
576
+ lp = (1 + cos_2theta ** 2) / (sin_theta_sq * sin_2theta)
577
+ qkey = round(Q, 5)
578
+ refl_map[qkey] = refl_map.get(qkey, 0.0) + I * lp
579
+
580
+ if not refl_map:
581
+ raise ValueError('No reflections in range')
582
+
583
+ # Convert to arrays and apply peak broadening
584
+ refl_items = sorted(refl_map.items())
585
+ refl_Q = np.array([k for k, _ in refl_items])
586
+ refl_I = np.array([v for _, v in refl_items])
587
+
588
+ Q_grid = np.arange(0, Qmax + dQ * 0.5, dQ)
589
+ intens = np.zeros_like(Q_grid)
590
+
591
+ for q, I in zip(refl_Q, refl_I):
592
+ sigma = peak_width * (0.6 + 0.4 * q / Qmax)
593
+ intens += I * np.exp(-0.5 * ((Q_grid - q) / sigma) ** 2)
594
+
595
+ if intens.max() > 0:
596
+ intens /= intens.max()
597
+
598
+ return Q_grid, intens
599
+
600
+
601
+ def cif_reflection_positions(fname, Qmax=10.0, wavelength=1.5406,
602
+ space_group_hint=None):
603
+ """Get list of reflection Q positions from CIF file."""
604
+ cell, atoms = _parse_cif_basic(fname)
605
+
606
+ if space_group_hint is None:
607
+ space_group_hint = cell.get('space_group')
608
+
609
+ a, b, c = cell['a'], cell['b'], cell['c']
610
+ alpha = np.deg2rad(cell['alpha'])
611
+ beta = np.deg2rad(cell['beta'])
612
+ gamma = np.deg2rad(cell['gamma'])
613
+
614
+ # Build unit cell vectors
615
+ a_vec = np.array([a, 0, 0])
616
+ b_vec = np.array([b * np.cos(gamma), b * np.sin(gamma), 0])
617
+
618
+ c_x = c * np.cos(beta)
619
+ c_y = c * (np.cos(alpha) - np.cos(beta) * np.cos(gamma)) / np.sin(gamma)
620
+ c_z = np.sqrt(max(c ** 2 - c_x ** 2 - c_y ** 2, 1e-12))
621
+ c_vec = np.array([c_x, c_y, c_z])
622
+
623
+ A = np.column_stack([a_vec, b_vec, c_vec])
624
+ V = np.dot(a_vec, np.cross(b_vec, c_vec))
625
+
626
+ if abs(V) < 1e-10:
627
+ return []
628
+
629
+ B = 2 * np.pi * np.linalg.inv(A).T
630
+ b1, b2, b3 = B[:, 0], B[:, 1], B[:, 2]
631
+
632
+ a_star = np.linalg.norm(b1)
633
+ b_star = np.linalg.norm(b2)
634
+ c_star = np.linalg.norm(b3)
635
+
636
+ hmax = max(1, int(np.ceil(Qmax / a_star)))
637
+ kmax = max(1, int(np.ceil(Qmax / b_star)))
638
+ lmax = max(1, int(np.ceil(Qmax / c_star)))
639
+
640
+ def extinct(h, k, l, sg):
641
+ """Check if reflection is extinct due to space group symmetry."""
642
+ if not sg:
643
+ return False
644
+
645
+ c0 = sg.lower()[0]
646
+
647
+ if c0 == 'i':
648
+ return (h + k + l) % 2 != 0
649
+ if c0 == 'f':
650
+ all_even = (h % 2 == 0 and k % 2 == 0 and l % 2 == 0)
651
+ all_odd = (h % 2 != 0 and k % 2 != 0 and l % 2 != 0)
652
+ return not (all_even or all_odd)
653
+ if c0 == 'c':
654
+ return (h + k) % 2 != 0
655
+ if c0 == 'r':
656
+ return ((-h + k + l) % 3) != 0
657
+
658
+ return False
659
+
660
+ lam = wavelength
661
+ refl = set()
662
+
663
+ for h in range(-hmax, hmax + 1):
664
+ for k in range(-kmax, kmax + 1):
665
+ for l in range(-lmax, lmax + 1):
666
+ if h == k == l == 0:
667
+ continue
668
+ if extinct(h, k, l, space_group_hint):
669
+ continue
670
+
671
+ G = h * b1 + k * b2 + l * b3
672
+ Q = np.linalg.norm(G)
673
+
674
+ if Q <= 0 or Q > Qmax:
675
+ continue
676
+
677
+ if lam is not None:
678
+ s = (Q * lam) / (4 * np.pi)
679
+ if s <= 0 or s >= 1:
680
+ continue
681
+
682
+ q_round = round(Q, 6)
683
+ refl.add(q_round)
684
+
685
+ return sorted(refl)
686
+
687
+
688
+ # --- New helpers for hkl labeling ---
689
+
690
+ def list_reflections_with_hkl(fname, Qmax=10.0, wavelength=1.5406,
691
+ space_group_hint=None):
692
+ """Return a list of (Q_rounded, h, k, l) for reflections up to Qmax.
693
+
694
+ When wavelength is None, do not apply Bragg cutoff (enumerate by Q only).
695
+ Q values are rounded to 6 decimals to group symmetrically equivalent sets.
696
+ """
697
+ cell, _atoms = _parse_cif_basic(fname)
698
+
699
+ if space_group_hint is None:
700
+ space_group_hint = cell.get('space_group')
701
+
702
+ a, b, c = cell['a'], cell['b'], cell['c']
703
+ alpha = np.deg2rad(cell['alpha'])
704
+ beta = np.deg2rad(cell['beta'])
705
+ gamma = np.deg2rad(cell['gamma'])
706
+
707
+ # Build unit cell vectors
708
+ a_vec = np.array([a, 0, 0])
709
+ b_vec = np.array([b * np.cos(gamma), b * np.sin(gamma), 0])
710
+
711
+ c_x = c * np.cos(beta)
712
+ c_y = c * (np.cos(alpha) - np.cos(beta) * np.cos(gamma)) / np.sin(gamma)
713
+ c_z = np.sqrt(max(c ** 2 - c_x ** 2 - c_y ** 2, 1e-12))
714
+ c_vec = np.array([c_x, c_y, c_z])
715
+
716
+ A = np.column_stack([a_vec, b_vec, c_vec])
717
+ V = np.dot(a_vec, np.cross(b_vec, c_vec))
718
+
719
+ if abs(V) < 1e-10:
720
+ return []
721
+
722
+ B = 2 * np.pi * np.linalg.inv(A).T
723
+ b1, b2, b3 = B[:, 0], B[:, 1], B[:, 2]
724
+
725
+ a_star = np.linalg.norm(b1)
726
+ b_star = np.linalg.norm(b2)
727
+ c_star = np.linalg.norm(b3)
728
+
729
+ hmax = max(1, int(np.ceil(Qmax / a_star)))
730
+ kmax = max(1, int(np.ceil(Qmax / b_star)))
731
+ lmax = max(1, int(np.ceil(Qmax / c_star)))
732
+
733
+ def extinct(h, k, l, sg):
734
+ """Check if reflection is extinct due to space group symmetry."""
735
+ if not sg:
736
+ return False
737
+
738
+ c0 = sg.lower()[0]
739
+
740
+ if c0 == 'i':
741
+ return (h + k + l) % 2 != 0
742
+ if c0 == 'f':
743
+ all_even = (h % 2 == 0 and k % 2 == 0 and l % 2 == 0)
744
+ all_odd = (h % 2 != 0 and k % 2 != 0 and l % 2 != 0)
745
+ return not (all_even or all_odd)
746
+ if c0 == 'c':
747
+ return (h + k) % 2 != 0
748
+ if c0 == 'r':
749
+ return ((-h + k + l) % 3) != 0
750
+
751
+ return False
752
+
753
+ lam = wavelength
754
+ hkl_list = []
755
+
756
+ for h in range(-hmax, hmax + 1):
757
+ for k in range(-kmax, kmax + 1):
758
+ for l in range(-lmax, lmax + 1):
759
+ if h == k == l == 0:
760
+ continue
761
+ if extinct(h, k, l, space_group_hint):
762
+ continue
763
+
764
+ G = h * b1 + k * b2 + l * b3
765
+ Q = np.linalg.norm(G)
766
+
767
+ if Q <= 0 or Q > Qmax:
768
+ continue
769
+
770
+ if lam is not None:
771
+ s = (Q * lam) / (4 * np.pi)
772
+ if s <= 0 or s >= 1:
773
+ continue
774
+
775
+ q_round = round(Q, 6)
776
+ hkl_list.append((q_round, h, k, l))
777
+
778
+ # De-duplicate identical entries
779
+ seen = set()
780
+ uniq = []
781
+
782
+ for item in hkl_list:
783
+ if item in seen:
784
+ continue
785
+ seen.add(item)
786
+ uniq.append(item)
787
+
788
+ return uniq
789
+
790
+
791
+ def build_hkl_label_map_from_list(hkl_list):
792
+ """Build a dict Q-> "(h k l), (h k l), ..." using canonical positive indices if present.
793
+
794
+ This mirrors the prior UI labeling convention.
795
+ """
796
+ by_q = {}
797
+
798
+ for q, h, k, l in hkl_list:
799
+ # Canonicalize sign: prefer non-negative if possible for readability
800
+ if h < 0 or (h == 0 and k < 0) or (h == 0 and k == 0 and l < 0):
801
+ h, k, l = -h, -k, -l
802
+ by_q.setdefault(q, set()).add((h, k, l))
803
+
804
+ label_map = {}
805
+
806
+ for q, triples in by_q.items():
807
+ ordered = sorted(triples)
808
+ nonneg_all = [t for t in ordered
809
+ if t[0] >= 0 and t[1] >= 0 and t[2] >= 0]
810
+ use_list = nonneg_all if nonneg_all else ordered
811
+ label_map[q] = ", ".join(f"({h} {k} {l})" for h, k, l in use_list)
812
+
813
+ return label_map
814
+
815
+
816
+ def build_hkl_label_map(fname, Qmax=10.0, wavelength=1.5406,
817
+ space_group_hint=None):
818
+ """Convenience: compute label map directly from a CIF file."""
819
+ hkl_list = list_reflections_with_hkl(
820
+ fname, Qmax=Qmax, wavelength=wavelength,
821
+ space_group_hint=space_group_hint
822
+ )
823
+ return build_hkl_label_map_from_list(hkl_list)