xrayscatteringtools 1.1.0__tar.gz → 1.3.0__tar.gz

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 (67) hide show
  1. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/.github/workflows/tests.yml +5 -3
  2. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/PKG-INFO +4 -4
  3. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/README.md +3 -3
  4. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/pyproject.toml +1 -1
  5. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/calib/masking.py +100 -0
  6. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/io.py +4 -1
  7. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/__init__.py +4 -1
  8. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/NBD__CCSD_FC__aug_cc_pVDZ.h5 +0 -0
  9. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/NBD__HF__cc_pVTZ.h5 +0 -0
  10. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/QC__CCSD_FC__aug_cc_pVDZ.h5 +0 -0
  11. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/QC__HF__cc_pVTZ.h5 +0 -0
  12. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/SF6__CCSD_FC__aug_cc_pVDZ.h5 +0 -0
  13. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/SF6__CCSD_FC__aug_cc_pVTZ.h5 +0 -0
  14. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/SF6__HF__cc_pVTZ.h5 +0 -0
  15. xrayscatteringtools-1.3.0/src/xrayscatteringtools/theory/data/patterns/convert_thistle_to_h5.py +67 -0
  16. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/patterns.py +41 -22
  17. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/patterns.pyi +12 -0
  18. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_patterns.py +60 -0
  19. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/.gitattributes +0 -0
  20. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/.gitignore +0 -0
  21. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/LICENSE +0 -0
  22. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/__init__.py +0 -0
  23. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/azimuthal.py +0 -0
  24. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/calib/__init__.py +0 -0
  25. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/calib/geometry_calibration.py +0 -0
  26. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/calib/scattering_corrections.py +0 -0
  27. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/calib/timetool_calibration.py +0 -0
  28. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/data/Jungfrau4M.h5 +0 -0
  29. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/epicsArch.py +0 -0
  30. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/plotting.py +0 -0
  31. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/IAM/Compton_Factors.npy +0 -0
  32. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/IAM/Scattering_Factors.npy +0 -0
  33. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/geometries/SF6__CCSD_T_DHK__aug_cc_pV5Z_DK.h5 +0 -0
  34. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/geometries/make_geometry_h5.py +0 -0
  35. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/Ne__CCSD_X2C__aug_cc_pVDZ_DK.h5 +0 -0
  36. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/Ne__CCSD_X2C__aug_cc_pVQZ_DK.h5 +0 -0
  37. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/Ne__CCSD_X2C__aug_cc_pVTZ_DK.h5 +0 -0
  38. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/SF6__CCSD__aug_cc_pVDZ.h5 +0 -0
  39. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/SF6__HF__aug_cc_pVDZ.h5 +0 -0
  40. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/SF6__MP2__aug_cc_pVDZ.h5 +0 -0
  41. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/data/patterns/convert_ab_initio_to_h5.py +0 -0
  42. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/geometries.py +0 -0
  43. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/geometries.pyi +0 -0
  44. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/theory/iam.py +0 -0
  45. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/src/xrayscatteringtools/utils.py +0 -0
  46. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/__init__.py +0 -0
  47. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/DMP_D.xyz +0 -0
  48. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/DMP_L1.xyz +0 -0
  49. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/DMP_L2.xyz +0 -0
  50. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/DMP_ground.xyz +0 -0
  51. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/azav_test.ipynb +0 -0
  52. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/calib_test.ipynb +0 -0
  53. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/geometries_test.ipynb +0 -0
  54. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/iam_oriented_test.ipynb +0 -0
  55. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/iam_pattern_test.ipynb +0 -0
  56. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/old/patterns_test.ipynb +0 -0
  57. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/sf6_test.xyz +0 -0
  58. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_azimuthal.py +0 -0
  59. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_geometries.py +0 -0
  60. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_geometry_calibration.py +0 -0
  61. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_iam.py +0 -0
  62. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_io.py +0 -0
  63. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_masking.py +0 -0
  64. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_plotting.py +0 -0
  65. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_scattering_corrections.py +0 -0
  66. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_timetool_calibration.py +0 -0
  67. {xrayscatteringtools-1.1.0 → xrayscatteringtools-1.3.0}/tests/test_utils.py +0 -0
@@ -8,16 +8,18 @@ on:
8
8
 
9
9
  jobs:
10
10
  test:
11
- runs-on: ubuntu-latest
11
+ # Pinned: ubuntu-latest is now 26.04, which has no Python 3.9 builds.
12
+ runs-on: ubuntu-24.04
12
13
  strategy:
14
+ fail-fast: false
13
15
  matrix:
14
16
  python-version: ["3.9", "3.10", "3.11", "3.12"]
15
17
 
16
18
  steps:
17
- - uses: actions/checkout@v4
19
+ - uses: actions/checkout@v5
18
20
 
19
21
  - name: Set up Python ${{ matrix.python-version }}
20
- uses: actions/setup-python@v5
22
+ uses: actions/setup-python@v6
21
23
  with:
22
24
  python-version: ${{ matrix.python-version }}
23
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xrayscatteringtools
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: A library for gas phase analysis at the CXI endstation
5
5
  Project-URL: Homepage, https://github.com/Weber-Group/xrayscatteringtools
6
6
  Project-URL: Issues, https://github.com/Weber-Group/xrayscatteringtools/issues
@@ -108,13 +108,13 @@ Calibration and correction tools:
108
108
  Theoretical scattering models:
109
109
  - **`iam`** — Independent Atom Model: isotropic and oriented elastic/inelastic scattering patterns (`iam_elastic_pattern`, `iam_inelastic_pattern`, `iam_total_pattern`, and their `_oriented` variants), `iam_compton_spectrum`, and `iam_inelastic_pattern_from_formula` (Compton intensity from a chemical formula alone, no coordinates needed).
110
110
  - **`geometries`** — Lazy-loaded optimized molecular geometries from bundled HDF5 data (accessed as module-level attributes, e.g. `geometries.SF6__CCSD_T_DHK__aug_cc_pV5Z_DK`).
111
- - **`patterns`** — Lazy-loaded _ab initio_ scattering patterns from bundled HDF5 data (accessed as module-level attributes named `molecule__method__basis_set`, e.g. `patterns.SF6__HF__aug_cc_pVDZ`). Available: SF6 at the HF, MP2, and CCSD levels (aug-cc-pVDZ), and Ne at the CCSD+X2C level (aug-cc-pVDZ/TZ/QZ+DK). Some patterns carry a `notes` attribute with details of the calculation.
111
+ - **`patterns`** — Lazy-loaded _ab initio_ scattering patterns from bundled HDF5 data (accessed as module-level attributes named `molecule__method__basis_set`, e.g. `patterns.SF6__HF__aug_cc_pVDZ`). Available: SF6 at the HF, MP2, and CCSD levels (aug-cc-pVDZ), HF (cc-pVTZ), and frozen-core CCSD (aug-cc-pVDZ/TZ); Ne at the CCSD+X2C level (aug-cc-pVDZ/TZ/QZ+DK); and norbornadiene (`NBD`) and quadricyclane (`QC`) at HF (cc-pVTZ) and frozen-core CCSD (aug-cc-pVDZ). Every pattern has the total `I_q`; many also carry `I_q_elastic`/`I_q_inelastic`, and the newer ones add the `I_q_hartree`/`I_q_exchange`/`I_q_cumulant` decomposition. Some patterns carry a `notes` attribute with details of the calculation.
112
112
 
113
113
  ---
114
114
 
115
115
  ## Testing
116
116
 
117
- The test suite uses [pytest](https://docs.pytest.org/) and covers all modules (460+ tests):
117
+ The test suite uses [pytest](https://docs.pytest.org/) and covers all modules (580+ tests):
118
118
  ```bash
119
119
  pip install -e ".[dev]"
120
120
  python -m pytest tests/
@@ -138,4 +138,4 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
138
138
 
139
139
  ## Acknowledgments
140
140
 
141
- This library was created by [David J. Romano](https://david.lizaanddavid.com) by compiling and standardizing code from previous data analysis pipelines. Maintained by the [Weber Research Group](https://sites.brown.edu/weber-lab/) and developed by collaborators to facilitate X-ray scattering research.
141
+ This library was created by [David J. Romano](https://davidromano.dev) by compiling and standardizing code from previous data analysis pipelines. Maintained by the [Weber Research Group](https://sites.brown.edu/weber-lab/) and developed by collaborators to facilitate X-ray scattering research.
@@ -80,13 +80,13 @@ Calibration and correction tools:
80
80
  Theoretical scattering models:
81
81
  - **`iam`** — Independent Atom Model: isotropic and oriented elastic/inelastic scattering patterns (`iam_elastic_pattern`, `iam_inelastic_pattern`, `iam_total_pattern`, and their `_oriented` variants), `iam_compton_spectrum`, and `iam_inelastic_pattern_from_formula` (Compton intensity from a chemical formula alone, no coordinates needed).
82
82
  - **`geometries`** — Lazy-loaded optimized molecular geometries from bundled HDF5 data (accessed as module-level attributes, e.g. `geometries.SF6__CCSD_T_DHK__aug_cc_pV5Z_DK`).
83
- - **`patterns`** — Lazy-loaded _ab initio_ scattering patterns from bundled HDF5 data (accessed as module-level attributes named `molecule__method__basis_set`, e.g. `patterns.SF6__HF__aug_cc_pVDZ`). Available: SF6 at the HF, MP2, and CCSD levels (aug-cc-pVDZ), and Ne at the CCSD+X2C level (aug-cc-pVDZ/TZ/QZ+DK). Some patterns carry a `notes` attribute with details of the calculation.
83
+ - **`patterns`** — Lazy-loaded _ab initio_ scattering patterns from bundled HDF5 data (accessed as module-level attributes named `molecule__method__basis_set`, e.g. `patterns.SF6__HF__aug_cc_pVDZ`). Available: SF6 at the HF, MP2, and CCSD levels (aug-cc-pVDZ), HF (cc-pVTZ), and frozen-core CCSD (aug-cc-pVDZ/TZ); Ne at the CCSD+X2C level (aug-cc-pVDZ/TZ/QZ+DK); and norbornadiene (`NBD`) and quadricyclane (`QC`) at HF (cc-pVTZ) and frozen-core CCSD (aug-cc-pVDZ). Every pattern has the total `I_q`; many also carry `I_q_elastic`/`I_q_inelastic`, and the newer ones add the `I_q_hartree`/`I_q_exchange`/`I_q_cumulant` decomposition. Some patterns carry a `notes` attribute with details of the calculation.
84
84
 
85
85
  ---
86
86
 
87
87
  ## Testing
88
88
 
89
- The test suite uses [pytest](https://docs.pytest.org/) and covers all modules (460+ tests):
89
+ The test suite uses [pytest](https://docs.pytest.org/) and covers all modules (580+ tests):
90
90
  ```bash
91
91
  pip install -e ".[dev]"
92
92
  python -m pytest tests/
@@ -110,4 +110,4 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
110
110
 
111
111
  ## Acknowledgments
112
112
 
113
- This library was created by [David J. Romano](https://david.lizaanddavid.com) by compiling and standardizing code from previous data analysis pipelines. Maintained by the [Weber Research Group](https://sites.brown.edu/weber-lab/) and developed by collaborators to facilitate X-ray scattering research.
113
+ This library was created by [David J. Romano](https://davidromano.dev) by compiling and standardizing code from previous data analysis pipelines. Maintained by the [Weber Research Group](https://sites.brown.edu/weber-lab/) and developed by collaborators to facilitate X-ray scattering research.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xrayscatteringtools"
7
- version = "1.1.0"
7
+ version = "1.3.0"
8
8
  description = "A library for gas phase analysis at the CXI endstation"
9
9
  authors = [
10
10
  { name = "David Romano", email = "david_j_romano@brown.edu" }
@@ -511,6 +511,9 @@ class MaskMaker:
511
511
  q_map = compute_q_map(J4M.x, J4M.y, x0, y0, z0, tx, ty, keV, z_off)
512
512
  q_bins = np.linspace(np.nanmin(q_map[~np.isnan(sample_avg_masked)]), np.nanmax(q_map[~np.isnan(sample_avg_masked)]), n_q_bins)
513
513
 
514
+ # Per-ring histograms and chosen bounds, for the waterfall summary plot
515
+ ring_records = []
516
+
514
517
  for q_idx, q_lo in tqdm(enumerate(q_bins[:-1]), total=len(q_bins) - 1):
515
518
  q_hi = q_bins[q_idx + 1]
516
519
  ring_sel = (q_map >= q_lo) & (q_map < q_hi)
@@ -534,6 +537,7 @@ class MaskMaker:
534
537
  100 * (1 - kept_frac),
535
538
  )
536
539
  self.sample_mask &= ring_mask
540
+ chosen_lb, chosen_ub, is_manual = lower_bound, upper_bound, False
537
541
  else:
538
542
  # Manual review required — show histogram and detector ring
539
543
  ring_data = q_ring[ring_sel].flatten()
@@ -555,6 +559,7 @@ class MaskMaker:
555
559
  ax_hist.set_xlabel('Intensity')
556
560
  ax_hist.set_ylabel('Counts')
557
561
  ax_hist.grid(True)
562
+ ax_hist.minorticks_on()
558
563
 
559
564
  # Right: pcolormesh of the ring pixels on the detector
560
565
  ring_display = np.where(ring_sel, sample_avg_corrected, np.nan)
@@ -587,10 +592,38 @@ class MaskMaker:
587
592
  manual_mask = np.ones_like(q_ring, dtype=bool)
588
593
  manual_mask[valid] = (q_ring[valid] >= user_lb) & (q_ring[valid] <= user_ub)
589
594
  self.sample_mask &= manual_mask
595
+ chosen_lb, chosen_ub, is_manual = user_lb, user_ub, True
596
+
597
+ if plotting:
598
+ ring_vals = q_ring[ring_sel]
599
+ ring_vals = ring_vals[np.isfinite(ring_vals)]
600
+ if ring_vals.size > 0:
601
+ # Normalise by the ring median so weak high-q rings are as
602
+ # readable as the bright low-q ones on a shared axis
603
+ median = np.median(ring_vals)
604
+ scale = median if median > 0 else 1.0
605
+ ring_vals = ring_vals / scale
606
+ lb_n, ub_n = chosen_lb / scale, chosen_ub / scale
607
+ p01, p05, p995, p999 = np.percentile(ring_vals, [0.1, 0.5, 99.5, 99.9])
608
+ # Widen the range out to the bounds so the cut region is
609
+ # visible, but stop at the 0.1/99.9 percentiles so a few
610
+ # hot pixels (or a very loose manual bound) don't stretch the axis
611
+ counts, edges = np.histogram(
612
+ ring_vals,
613
+ bins=200,
614
+ range=(min(p05, max(lb_n, p01)), max(p995, min(ub_n, p999))),
615
+ )
616
+ cut_frac = np.mean((ring_vals < lb_n) | (ring_vals > ub_n))
617
+ ring_records.append(
618
+ dict(q_lo=q_lo, q_hi=q_hi, counts=counts, edges=edges,
619
+ lb=lb_n, ub=ub_n, manual=is_manual, cut_frac=cut_frac)
620
+ )
590
621
 
591
622
  self.sample_mask = self.sample_mask.astype(bool)
592
623
 
593
624
  if plotting:
625
+ self._plot_q_ring_waterfall(ring_records, n_std)
626
+
594
627
  sample_avg_all_masked = np.where(self.sample_mask, sample_avg_masked, np.nan)
595
628
 
596
629
  fig, axes = plt.subplots(1, 3, figsize=(18, 8))
@@ -602,6 +635,73 @@ class MaskMaker:
602
635
 
603
636
  print(f'Masked percentage from sample: {100 * self._masked_fraction(self.sample_mask):.2f}%')
604
637
 
638
+ def _plot_q_ring_waterfall(self, ring_records: list[dict], n_std: float) -> None:
639
+ """Waterfall of per-ring intensity histograms with the mask bounds shaded.
640
+
641
+ Intensities are divided by each ring's median, and each histogram is
642
+ normalised to unit peak height and offset vertically by its ring index.
643
+ The intensity range that survives the mask is shaded in steel-blue for
644
+ auto-accepted rings and red for rings whose bounds were entered
645
+ manually; the range that is cut is shaded grey. The percentage of
646
+ ring pixels cut is printed to the right of each row.
647
+ """
648
+ if not ring_records:
649
+ return
650
+
651
+ n_rings = len(ring_records)
652
+ spacing = 0.5
653
+ colors = {False: 'steelblue', True: 'tab:red'}
654
+ cut_color = 'tab:gray'
655
+
656
+ fig, ax = plt.subplots(figsize=(18, max(8, 0.12 * n_rings)))
657
+ for i, rec in enumerate(ring_records):
658
+ offset = i * spacing
659
+ centers = 0.5 * (rec['edges'][:-1] + rec['edges'][1:])
660
+ peak = rec['counts'].max()
661
+ height = rec['counts'] / peak if peak > 0 else rec['counts'].astype(float)
662
+ kept = (centers >= rec['lb']) & (centers <= rec['ub'])
663
+ # Lower rings are drawn in front so their peaks occlude the rows above
664
+ base_z = 4 * (n_rings - i)
665
+ ax.fill_between(centers, offset, offset + height, color='white', zorder=base_z)
666
+ ax.fill_between(
667
+ centers, offset, offset + height, where=kept,
668
+ color=colors[rec['manual']], alpha=0.6, linewidth=0, zorder=base_z + 1,
669
+ )
670
+ # Cut tails are usually near-zero counts, so shade at least the full
671
+ # row height there to keep them visible
672
+ ax.fill_between(
673
+ centers, offset, offset + np.maximum(height, spacing), where=~kept,
674
+ color=cut_color, alpha=0.5, linewidth=0, zorder=base_z + 2,
675
+ )
676
+ ax.plot(centers, offset + height, color='k', lw=0.6, zorder=base_z + 3)
677
+ ax.text(
678
+ 1.005, offset, f"{100 * rec['cut_frac']:.1f}%",
679
+ transform=ax.get_yaxis_transform(), fontsize=6, va='bottom',
680
+ color=colors[rec['manual']],
681
+ )
682
+ ax.text(1.005, 1.0, '% cut', transform=ax.transAxes, fontsize=8, va='bottom')
683
+
684
+ # Label a subset of rows with their q-centre
685
+ tick_step = max(1, n_rings // 15)
686
+ tick_idx = np.arange(0, n_rings, tick_step)
687
+ ax.set_yticks(tick_idx * spacing)
688
+ ax.set_yticklabels(
689
+ [f"{0.5 * (ring_records[i]['q_lo'] + ring_records[i]['q_hi']):.3f}" for i in tick_idx]
690
+ )
691
+ ax.set_ylim(-spacing, (n_rings - 1) * spacing + 1.5)
692
+ ax.set_xlabel('Intensity / ring median (Thompson-corrected)')
693
+ ax.set_ylabel(r'$q\;(\AA^{-1})$')
694
+ ax.set_title(f'Run {self.sample_run_number} Q-ring Histograms (n_std={n_std})')
695
+ legend_elements = [
696
+ Patch(facecolor='steelblue', alpha=0.6, edgecolor='k', label='Auto bounds (kept)'),
697
+ Patch(facecolor='tab:red', alpha=0.6, edgecolor='k', label='Manual bounds (kept)'),
698
+ Patch(facecolor=cut_color, alpha=0.5, edgecolor='k', label='Cut by mask'),
699
+ ]
700
+ ax.legend(handles=legend_elements, loc='lower left', bbox_to_anchor=(0, 1.0), ncol=3, frameon=False)
701
+ ax.grid(True, axis='x', alpha=0.3)
702
+ plt.tight_layout()
703
+ plt.show()
704
+
605
705
  def diagnose_q_bins(
606
706
  self,
607
707
  n_std: float = 2,
@@ -155,7 +155,10 @@ def combineRuns(runNumbers, folders, keys_to_combine, keys_to_sum, keys_to_check
155
155
  if isinstance(archPVs, str):
156
156
  archPVs = [archPVs]
157
157
  archive = EpicsArchive()
158
- unixTime = data_combined['unixTime']
158
+ for i, data in enumerate(data_array):
159
+ if 'unixTime' not in data:
160
+ raise KeyError(f"'unixTime' not found in run {runNumbers[i]}; it is required for archPVs.")
161
+ unixTime = np.concatenate([np.squeeze(d['unixTime']) for d in data_array])
159
162
  for pv in tqdm(archPVs, desc="Loading EPICS PVs"):
160
163
  pv_chunks = []
161
164
  for i, runNumber in enumerate(runNumbers):
@@ -4,4 +4,7 @@ from .iam import iam_compton_spectrum, iam_inelastic_pattern_from_formula
4
4
 
5
5
  from .geometries import SF6__CCSD_T_DHK__aug_cc_pV5Z_DK
6
6
 
7
- from .patterns import SF6__CCSD__aug_cc_pVDZ, SF6__MP2__aug_cc_pVDZ, SF6__HF__aug_cc_pVDZ
7
+ from .patterns import SF6__CCSD__aug_cc_pVDZ, SF6__MP2__aug_cc_pVDZ, SF6__HF__aug_cc_pVDZ
8
+ from .patterns import SF6__HF__cc_pVTZ, SF6__CCSD_FC__aug_cc_pVDZ, SF6__CCSD_FC__aug_cc_pVTZ
9
+ from .patterns import Ne__CCSD_X2C__aug_cc_pVDZ_DK, Ne__CCSD_X2C__aug_cc_pVTZ_DK, Ne__CCSD_X2C__aug_cc_pVQZ_DK
10
+ from .patterns import NBD__HF__cc_pVTZ, NBD__CCSD_FC__aug_cc_pVDZ, QC__HF__cc_pVTZ, QC__CCSD_FC__aug_cc_pVDZ
@@ -0,0 +1,67 @@
1
+ import json
2
+ import numpy as np
3
+ import h5py
4
+
5
+ # Converts thistle .csv exports into the .h5 format read by xrayscatteringtools.theory.patterns.
6
+ # Expected .csv layout:
7
+ # line 1: "# <label> q in 1/A {json metadata}"
8
+ # line 2: "q (1/A),total,elastic,inelastic,hartree,exchange[,cumulant]"
9
+ # rest: comma separated data, q in inverse angstroms
10
+ # The total satisfies I_q = n_electrons + hartree + exchange + cumulant (cumulant is zero for HF).
11
+ # Paths are relative to the current working directory.
12
+
13
+ ##################### User Set Parameters #####################
14
+ # (csv file, molecule, method, basis set, n_electrons, notes)
15
+ # h5 file naming convention is molecule__method__basisset.h5
16
+ NBD_NOTES = "NBD is norbornadiene (bicyclo[2.2.1]hepta-2,5-diene, C7H8)."
17
+ QC_NOTES = "QC is quadricyclane (tetracyclo[3.2.0.0^{2,7}.0^{4,6}]heptane, C7H8), the valence isomer of norbornadiene."
18
+ FC_NOTES = "CCSD with frozen core electrons."
19
+ conversions = [
20
+ ("nb_hf_cc-pvtz.csv", "NBD", "HF", "cc_pVTZ", 50, NBD_NOTES),
21
+ ("nb_ccsd_aug-cc-pvdz_fc.csv", "NBD", "CCSD_FC", "aug_cc_pVDZ", 50, f"{NBD_NOTES} {FC_NOTES}"),
22
+ ("qd_hf_cc-pvtz.csv", "QC", "HF", "cc_pVTZ", 50, QC_NOTES),
23
+ ("qd_ccsd_aug-cc-pvdz_fc.csv", "QC", "CCSD_FC", "aug_cc_pVDZ", 50, f"{QC_NOTES} {FC_NOTES}"),
24
+ ("sf6_hf_cc-pvtz.csv", "SF6", "HF", "cc_pVTZ", 70, ""),
25
+ ("sf6_ccsd_aug-cc-pvdz_fc.csv", "SF6", "CCSD_FC", "aug_cc_pVDZ", 70, FC_NOTES),
26
+ ("sf6_ccsd_aug-cc-pvtz_fc.csv", "SF6", "CCSD_FC", "aug_cc_pVTZ", 70, FC_NOTES),
27
+ ]
28
+ ##################### End User Set Parameters #####################
29
+
30
+ # csv column -> h5 dataset
31
+ column_map = {
32
+ "q (1/A)": "q",
33
+ "total": "I_q",
34
+ "elastic": "I_q_elastic",
35
+ "inelastic": "I_q_inelastic",
36
+ "hartree": "I_q_hartree",
37
+ "exchange": "I_q_exchange",
38
+ "cumulant": "I_q_cumulant",
39
+ }
40
+
41
+ for csv_file, molecule, method, basis_set, n_electrons, notes in conversions:
42
+ with open(csv_file) as f:
43
+ header = f.readline()
44
+ columns = f.readline().strip().split(",")
45
+ metadata = json.loads(header[header.index("{"):])
46
+ if metadata["q_unit"] != "1/A":
47
+ raise ValueError(f"{csv_file}: expected q in 1/A, got {metadata['q_unit']}")
48
+ data = np.loadtxt(csv_file, delimiter=",", skiprows=2)
49
+ datasets = {column_map[c]: data[:, i] for i, c in enumerate(columns)}
50
+ # Forward scattering is n_electrons**2, catches a wrong electron count
51
+ if not np.isclose(datasets["I_q"][0], n_electrons**2, rtol=1e-6) or datasets["q"][0] != 0:
52
+ raise ValueError(f"{csv_file}: I(q=0) = {datasets['I_q'][0]}, expected {n_electrons**2}")
53
+
54
+ h5_filename = f"{molecule}__{method}__{basis_set}.h5"
55
+ with h5py.File(h5_filename, "w") as f:
56
+ for name, values in datasets.items():
57
+ f.create_dataset(name, data=values)
58
+ # Add metadata as attributes
59
+ f.attrs["molecule"] = molecule
60
+ f.attrs["method"] = method
61
+ f.attrs["basis_set"] = basis_set
62
+ f.attrs["n_electrons"] = n_electrons
63
+ f.attrs["source"] = f"{metadata['from']['source']} via thistle {metadata['thistle']} ({metadata['kind']}, computed {metadata['created']})"
64
+ f.attrs["metadata"] = json.dumps(metadata)
65
+ if notes:
66
+ f.attrs["notes"] = notes
67
+ print(f"{csv_file} -> {h5_filename}")
@@ -10,7 +10,14 @@ __all__ = [
10
10
  'SF6__HF__aug_cc_pVDZ',
11
11
  'Ne__CCSD_X2C__aug_cc_pVDZ_DK',
12
12
  'Ne__CCSD_X2C__aug_cc_pVTZ_DK',
13
- 'Ne__CCSD_X2C__aug_cc_pVQZ_DK'
13
+ 'Ne__CCSD_X2C__aug_cc_pVQZ_DK',
14
+ 'SF6__HF__cc_pVTZ',
15
+ 'SF6__CCSD_FC__aug_cc_pVDZ',
16
+ 'SF6__CCSD_FC__aug_cc_pVTZ',
17
+ 'NBD__HF__cc_pVTZ',
18
+ 'NBD__CCSD_FC__aug_cc_pVDZ',
19
+ 'QC__HF__cc_pVTZ',
20
+ 'QC__CCSD_FC__aug_cc_pVDZ',
14
21
  ]
15
22
  def __dir__():
16
23
  # Tab completion for IPython
@@ -19,6 +26,17 @@ def __dir__():
19
26
 
20
27
  _data_path = pathlib.Path(__file__).parent / "data/patterns"
21
28
 
29
+ # Optional I_q components a pattern file may carry, with their docstring descriptions.
30
+ _COMPONENTS = {
31
+ "I_q_elastic": "Elastic scattering intensity values corresponding to `q`.",
32
+ "I_q_inelastic": "Inelastic scattering intensity values corresponding to `q`.",
33
+ "I_q_hartree": "Hartree (direct) part of the two-electron term, built from the one-electron density alone. Equal to `I_q_elastic`.",
34
+ "I_q_exchange": "Exchange part of the two-electron term.",
35
+ "I_q_cumulant": "Cumulant (electron correlation) part of the two-electron term. Absent for HF, where it is zero.",
36
+ }
37
+ # Provenance for files made before the 'source' attribute existed
38
+ _DEFAULT_SOURCE = "Molpro & PyXSCAT Library. https://github.com/AMC-dyn/PyXSCAT"
39
+
22
40
  def __getattr__(name):
23
41
  if name in __all__:
24
42
  # Load from HDF5 only when first accessed
@@ -41,7 +59,7 @@ def _make_default_obj(f):
41
59
  -------
42
60
  obj : SimpleNamespace
43
61
  A SimpleNamespace object with attributes: q, I_q, molecule, method, basis_set, n_electrons,
44
- and optionally I_q_elastic and I_q_inelastic if present in the file.
62
+ and optionally any of the I_q components in _COMPONENTS, notes, source, and metadata if present in the file.
45
63
  """
46
64
 
47
65
  attrs = dict(
@@ -52,22 +70,22 @@ def _make_default_obj(f):
52
70
  basis_set=f.attrs["basis_set"],
53
71
  n_electrons=f.attrs["n_electrons"],
54
72
  )
55
- if "I_q_elastic" in f:
56
- attrs["I_q_elastic"] = f["I_q_elastic"][:]
57
- if "I_q_inelastic" in f:
58
- attrs["I_q_inelastic"] = f["I_q_inelastic"][:]
59
- if "notes" in f.attrs:
60
- attrs["notes"] = f.attrs["notes"]
73
+ for name in _COMPONENTS:
74
+ if name in f:
75
+ attrs[name] = f[name][:]
76
+ for name in ("notes", "source", "metadata"):
77
+ if name in f.attrs:
78
+ attrs[name] = f.attrs[name]
61
79
  return SimpleNamespace(**attrs)
62
80
 
63
81
  def _make_default_docstring(obj):
64
82
  """
65
- Generate a default docstring for a data object if created by tests\\convert_ab_initio_to_h5.py
83
+ Generate a default docstring for a data object if created by data\\patterns\\convert_ab_initio_to_h5.py or data\\patterns\\convert_thistle_to_h5.py
66
84
 
67
85
  Parameters
68
86
  ----------
69
87
  obj : SimpleNamespace
70
- The data object with attributes: q, I_q, I_q_elastic, I_q_inelastic, molecule, method, basis_set, n_electrons.
88
+ The data object with attributes: q, I_q, molecule, method, basis_set, n_electrons, and any optional attributes from _make_default_obj.
71
89
 
72
90
  Returns
73
91
  -------
@@ -75,17 +93,17 @@ def _make_default_docstring(obj):
75
93
  The generated docstring.
76
94
  """
77
95
 
78
- elastic_doc = ""
79
- if hasattr(obj, "I_q_elastic"):
80
- elastic_doc = f""" I_q_elastic : ndarray of shape ({len(obj.I_q_elastic)},)
81
- Elastic scattering intensity values corresponding to `q`.
82
- """
83
- inelastic_doc = ""
84
- if hasattr(obj, "I_q_inelastic"):
85
- inelastic_doc = f""" I_q_inelastic : ndarray of shape ({len(obj.I_q_inelastic)},)
86
- Inelastic scattering intensity values corresponding to `q`.
96
+ components_doc = ""
97
+ for name, description in _COMPONENTS.items():
98
+ if hasattr(obj, name):
99
+ components_doc += f""" {name} : ndarray of shape ({len(getattr(obj, name))},)
100
+ {description}
87
101
  """
88
102
 
103
+ decomposition_doc = ""
104
+ if hasattr(obj, "I_q_hartree"):
105
+ decomposition_doc = "I_q = n_electrons + I_q_hartree + I_q_exchange + I_q_cumulant, and I_q = I_q_elastic + I_q_inelastic."
106
+
89
107
  notes_doc = ""
90
108
  if hasattr(obj, "notes"):
91
109
  notes_doc = obj.notes
@@ -99,8 +117,8 @@ def _make_default_docstring(obj):
99
117
  Momentum transfer values (q) in inverse angstroms. Ranges from {obj.q[0]} to {obj.q[-1]} Å⁻¹. ({invAngstroms2au(obj.q[0])} to {invAngstroms2au(obj.q[-1])} a.u.).
100
118
  I_q : ndarray of shape ({len(obj.I_q)},)
101
119
  Total scattering intensity values corresponding to `q`.
102
- {elastic_doc}{inelastic_doc} molecule : str
103
- The molecular formula of the species.
120
+ {components_doc} molecule : str
121
+ The molecular formula, or an abbreviation for isomers (e.g. NBD, QC).
104
122
  method : str
105
123
  The method used for the calculation.
106
124
  basis_set : str
@@ -110,8 +128,9 @@ def _make_default_docstring(obj):
110
128
 
111
129
  Notes
112
130
  -----
113
- Calculated using Molpro & PyXSCAT Library. https://github.com/AMC-dyn/PyXSCAT
131
+ Calculated using {getattr(obj, "source", _DEFAULT_SOURCE)}
114
132
  Inverse angstroms to atomic units conversion factor used: {au2invAngstroms(1.0)} Å⁻¹/a.u.
133
+ {decomposition_doc}
115
134
  {notes_doc}
116
135
  """
117
136
 
@@ -9,11 +9,16 @@ class _PatternData(SimpleNamespace):
9
9
  I_q: any # ndarray
10
10
  I_q_elastic: Optional[any] # ndarray, may not be present
11
11
  I_q_inelastic: Optional[any] # ndarray, may not be present
12
+ I_q_hartree: Optional[any] # ndarray, may not be present
13
+ I_q_exchange: Optional[any] # ndarray, may not be present
14
+ I_q_cumulant: Optional[any] # ndarray, may not be present
12
15
  molecule: str
13
16
  method: str
14
17
  basis_set: str
15
18
  n_electrons: int
16
19
  notes: Optional[any]
20
+ source: Optional[str]
21
+ metadata: Optional[str] # JSON string
17
22
 
18
23
  SF6__CCSD__aug_cc_pVDZ: _PatternData
19
24
  SF6__MP2__aug_cc_pVDZ: _PatternData
@@ -21,3 +26,10 @@ SF6__HF__aug_cc_pVDZ: _PatternData
21
26
  Ne__CCSD_X2C__aug_cc_pVDZ_DK: _PatternData
22
27
  Ne__CCSD_X2C__aug_cc_pVTZ_DK: _PatternData
23
28
  Ne__CCSD_X2C__aug_cc_pVQZ_DK: _PatternData
29
+ SF6__HF__cc_pVTZ: _PatternData
30
+ SF6__CCSD_FC__aug_cc_pVDZ: _PatternData
31
+ SF6__CCSD_FC__aug_cc_pVTZ: _PatternData
32
+ NBD__HF__cc_pVTZ: _PatternData
33
+ NBD__CCSD_FC__aug_cc_pVDZ: _PatternData
34
+ QC__HF__cc_pVTZ: _PatternData
35
+ QC__CCSD_FC__aug_cc_pVDZ: _PatternData
@@ -83,6 +83,13 @@ class TestDir:
83
83
  assert "SF6__CCSD__aug_cc_pVDZ" in result
84
84
 
85
85
 
86
+ class TestTheoryExports:
87
+ @pytest.mark.parametrize("name", PATTERNS_ALL)
88
+ def test_pattern_exported_from_theory(self, name):
89
+ from xrayscatteringtools import theory
90
+ assert hasattr(theory, name), f"add {name} to theory/__init__.py"
91
+
92
+
86
93
  # ── _make_default_obj ──────────────────────────────────────────────────
87
94
 
88
95
 
@@ -137,6 +144,20 @@ class TestMakeDefaultObj:
137
144
  assert hasattr(obj, "I_q")
138
145
  assert not hasattr(obj, "I_q_elastic")
139
146
  assert not hasattr(obj, "I_q_inelastic")
147
+ assert not hasattr(obj, "I_q_hartree")
148
+ assert not hasattr(obj, "source")
149
+
150
+ def test_loads_decomposition_and_source(self, h5_with_elastic):
151
+ with h5py.File(h5_with_elastic, "a") as f:
152
+ f.create_dataset("I_q_hartree", data=np.ones(50) * 7)
153
+ f.create_dataset("I_q_exchange", data=np.ones(50) * -7)
154
+ f.attrs["source"] = "PySCF RHF via thistle"
155
+ with h5py.File(h5_with_elastic, "r") as f:
156
+ obj = _make_default_obj(f)
157
+ np.testing.assert_allclose(obj.I_q_hartree, 7)
158
+ np.testing.assert_allclose(obj.I_q_exchange, -7)
159
+ assert not hasattr(obj, "I_q_cumulant")
160
+ assert obj.source == "PySCF RHF via thistle"
140
161
 
141
162
 
142
163
  # ── _make_default_docstring ────────────────────────────────────────────
@@ -191,6 +212,26 @@ class TestMakeDefaultDocstring:
191
212
  # Should contain the a.u. conversion factor
192
213
  assert str(au2invAngstroms(1.0)) in doc
193
214
 
215
+ def test_docstring_source(self):
216
+ obj = SimpleNamespace(
217
+ q=np.linspace(0.5, 8.0, 30),
218
+ I_q=np.ones(30),
219
+ molecule="NBD",
220
+ method="HF",
221
+ basis_set="cc_pVTZ",
222
+ n_electrons=50,
223
+ )
224
+ assert "Molpro" in _make_default_docstring(obj)
225
+ obj.source = "PySCF RHF via thistle"
226
+ obj.I_q_hartree = np.ones(30)
227
+ obj.I_q_exchange = np.ones(30)
228
+ doc = _make_default_docstring(obj)
229
+ assert "PySCF RHF via thistle" in doc
230
+ assert "Molpro" not in doc
231
+ assert "I_q_hartree" in doc
232
+ assert "I_q_exchange" in doc
233
+ assert "I_q_cumulant" not in doc.split("Notes")[0]
234
+
194
235
 
195
236
  # ── Consistency between shipped patterns ───────────────────────────────
196
237
 
@@ -209,6 +250,25 @@ class TestShippedPatternConsistency:
209
250
  err_msg=f"elastic + inelastic != total for {name}",
210
251
  )
211
252
 
253
+ @pytest.mark.parametrize("name", PATTERNS_ALL)
254
+ def test_two_electron_decomposition_equals_total(self, name):
255
+ obj = getattr(patterns, name)
256
+ if not hasattr(obj, "I_q_hartree"):
257
+ pytest.skip(f"{name} does not have the hartree/exchange/cumulant decomposition")
258
+ cumulant = getattr(obj, "I_q_cumulant", 0)
259
+ np.testing.assert_allclose(
260
+ obj.I_q, obj.n_electrons + obj.I_q_hartree + obj.I_q_exchange + cumulant,
261
+ rtol=1e-9,
262
+ err_msg=f"n_electrons + hartree + exchange + cumulant != total for {name}",
263
+ )
264
+
265
+ @pytest.mark.parametrize("name", PATTERNS_ALL)
266
+ def test_forward_scattering_is_n_electrons_squared(self, name):
267
+ obj = getattr(patterns, name)
268
+ if obj.q[0] > 1e-6:
269
+ pytest.skip(f"{name} does not start at q = 0")
270
+ np.testing.assert_allclose(obj.I_q[0], obj.n_electrons**2, rtol=1e-6)
271
+
212
272
  @pytest.mark.parametrize("name", PATTERNS_ALL)
213
273
  def test_n_electrons_positive(self, name):
214
274
  obj = getattr(patterns, name)